diff --git a/apps/editor/package.json b/apps/editor/package.json index 0fe777e6f..e9edd1bc9 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -48,7 +48,7 @@ "@mdbase-dev/connect": "workspace:*", "@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.145-e6e66bcd172f.tgz", "@mdbase-dev/i18n": "workspace:*", - "@mdbase-dev/sdk": "file:vendor/sdk-19197d2c-production-beta173.tgz", + "@mdbase-dev/sdk": "file:vendor/sdk-e8ffb806-production-beta188.tgz", "@mdbase-dev/ui": "0.1.0-beta.167", "@mdbase/connect-ui": "workspace:*", "@phosphor-icons/react": "^2.1.10", diff --git a/apps/editor/src/SdkEditorRoot.private.test.tsx b/apps/editor/src/SdkEditorRoot.private.test.tsx index 9e53330cc..d6c113284 100644 --- a/apps/editor/src/SdkEditorRoot.private.test.tsx +++ b/apps/editor/src/SdkEditorRoot.private.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen, waitFor, within } from "./test/locale"; import { StrictMode, useEffect } from "react"; -import { connect, type MdbaseClient } from "@mdbase-dev/sdk"; +import type { MdbaseClient } from "@mdbase-dev/sdk"; import { describeError } from "@mdbase-dev/sdk/describe"; import { COLLECTION_UNAVAILABLE_REASONS, mergeCollectionEntries, type CollectionAvailability, type MdbasePrivateCollection } from "@mdbase-dev/sdk/app-grant"; import { MemoryReplica } from "@mdbase-dev/sdk/testing"; @@ -30,7 +30,7 @@ function fakePrivate(entries: MdbasePrivateCollection[]) { return available; }), open: vi.fn(async (_collection: string, _options?: { signal?: AbortSignal }) => { - const client = await connect({ connector: replica.connector(), app: { name: "editor-private-test", version: "test" }, reconnect: false }); + const client = await replica.client(); clients.push(client); return client; }), diff --git a/apps/editor/src/SdkEditorRoot.test.tsx b/apps/editor/src/SdkEditorRoot.test.tsx index 7ac699e63..48e210f0d 100644 --- a/apps/editor/src/SdkEditorRoot.test.tsx +++ b/apps/editor/src/SdkEditorRoot.test.tsx @@ -91,7 +91,7 @@ describe("SdkEditorRoot behind the shared collection gate", () => { it("a refused registration logs only the developer detail", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const h = await testApp({ phase: { problem: "application_registration_refused" } }); - h.controls.publishSnapshot({ ...h.application.getSnapshot(), + h.controls.publishSnapshot({ ...h.mdbase.getSnapshot(), problemDetail: { name: "MdbaseError", code: "forbidden", registration: { code: "invalid_application_manifest", paths: ["/homepage"] } } }); render(); await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("invalid manifest: /homepage"))); diff --git a/apps/editor/src/SdkEditorRoot.tsx b/apps/editor/src/SdkEditorRoot.tsx index 583e7093f..d006e4272 100644 --- a/apps/editor/src/SdkEditorRoot.tsx +++ b/apps/editor/src/SdkEditorRoot.tsx @@ -101,7 +101,7 @@ export function SdkEditorRoot({ sdk, renderNative, onFeedbackContext, privateCol priv.scope && }> - {(connection) => } ; @@ -191,7 +191,6 @@ function usePrivateSection(sdk: Mdbase, privates: PrivateCollections | null) { } let live = true; const life = new AbortController(); - const application = sdk.application; const connection: EditorConnection = { id: client.collection, displayName: entry.displayName, generation: `private:${++privateGeneration}`, records: client, files: client.files, isCurrent: () => live && current(signal) }; const requireCurrent = () => { @@ -205,28 +204,28 @@ function usePrivateSection(sdk: Mdbase, privates: PrivateCollections | null) { }; signal.addEventListener("abort", leave, { once: true }); const actions: EditorScopeActions = { - chooseAnother: async () => { leave(); return application.getSnapshot(); }, + chooseAnother: async () => { leave(); return sdk.getSnapshot(); }, reauthorize: async (options) => { requireCurrent(); const requested = requestSignal(options?.signal); requested.throwIfAborted(); await privates.authorize(entry.collectionId, { ...options, signal: requested }); requested.throwIfAborted(); requireCurrent(); - leave(); return application.getSnapshot(); + leave(); return sdk.getSnapshot(); }, select: async (collectionId, options) => { requireCurrent(); const requested = AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]); requested.throwIfAborted(); leave(); - return application.select(collectionId, { ...options, signal: requested }); + return sdk.select(collectionId, { ...options, signal: requested }); }, forget: async (collectionId, options) => { requireCurrent(); const requested = AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]); requested.throwIfAborted(); - if (collectionId !== entry.collectionId) return application.forget(collectionId, { ...options, signal: requested }); + if (collectionId !== entry.collectionId) return sdk.forget(collectionId, { ...options, signal: requested }); leave(); await privates.forget(collectionId); requested.throwIfAborted(); - return application.getSnapshot(); + return sdk.getSnapshot(); } }; setScope({ connection, actions }); diff --git a/apps/editor/src/SdkEditorRoot.unsynced.test.tsx b/apps/editor/src/SdkEditorRoot.unsynced.test.tsx index fb1e3b3e1..a65013a53 100644 --- a/apps/editor/src/SdkEditorRoot.unsynced.test.tsx +++ b/apps/editor/src/SdkEditorRoot.unsynced.test.tsx @@ -39,7 +39,7 @@ describe("work before connecting", () => { const body = await screen.findByRole("textbox", { name: "Note body" }); expect(body).toHaveValue("First thoughts\n"); fireEvent.change(body, { target: { value: "First thoughts, revised\n" } }); - await waitFor(async () => expect((await h.unsynced.client.find({ path: "draft.md" }, { body: true }))?.body).toBe("# Draft\n\nFirst thoughts, revised\n"), { timeout: 10_000 }); + await waitFor(async () => expect((await h.unsynced.client.find({ path: "draft.md" }, { include: { body: true } }))?.body).toBe("# Draft\n\nFirst thoughts, revised\n"), { timeout: 10_000 }); // The SDK marks the work once something is saved; the gate then shows one quiet line. act(() => h.markWork()); diff --git a/apps/editor/src/native-file-preview-gateway.test.ts b/apps/editor/src/native-file-preview-gateway.test.ts index 28e2ca73e..6059f420b 100644 --- a/apps/editor/src/native-file-preview-gateway.test.ts +++ b/apps/editor/src/native-file-preview-gateway.test.ts @@ -52,7 +52,7 @@ async function fixture(overrides: Partial = {}) { const download = vi.spyOn(f.client.files, "download").mockResolvedValue(new Uint8Array([104, 105])); const downloadStream = vi.spyOn(f.client.files, "downloadStream").mockReturnValue(stream); const close = vi.spyOn(f.client, "close"); - const gateway = new NativeEditorGateway(f.application, f.connection); + const gateway = new NativeEditorGateway(f.mdbase, f.connection); gateways.push(gateway); const selected = (await gateway.listFiles())[0]!; return { ...f, file, metadata, controller, stream, cancel, release, read, list, get, download, downloadStream, close, gateway, selected }; diff --git a/apps/editor/src/native-gateway.test.ts b/apps/editor/src/native-gateway.test.ts index 8eb35c8f4..94888af54 100644 --- a/apps/editor/src/native-gateway.test.ts +++ b/apps/editor/src/native-gateway.test.ts @@ -18,7 +18,7 @@ afterEach(() => { async function fixture(...args: Parameters) { const f = await readyFixture(...args); - const gateway = new NativeEditorGateway(f.application, f.connection, declaration); + const gateway = new NativeEditorGateway(f.mdbase, f.connection, declaration); gateways.push(gateway); return { ...f, gateway }; } @@ -36,7 +36,7 @@ describe("NativeEditorGateway over the public SDK (MemoryReplica, not LAB)", () it("can retire a borrowed scope before mounting without reading its closed hello", async () => { const f = await readyFixture(); f.client.close(); - const gateway = new NativeEditorGateway(f.application, { ...f.connection, isCurrent: () => true }, declaration); + const gateway = new NativeEditorGateway(f.mdbase, { ...f.connection, isCurrent: () => true }, declaration); gateways.push(gateway); expect(gateway.sessionSnapshot()).toEqual({ status: "unselected", connections: [] }); gateway.dispose(); @@ -59,7 +59,7 @@ describe("NativeEditorGateway over the public SDK (MemoryReplica, not LAB)", () const f = await fixture(); const before = f.gateway.sessionSnapshot(); expect(before.status === "ready" && before.connection.private).toBeUndefined(); - const snapshot = f.application.getSnapshot(); + const snapshot = f.mdbase.getSnapshot(); f.controls.publishSnapshot({ ...snapshot, collections: snapshot.collections.map((entry) => ({ ...entry, kind: "private" as const })) }); const after = f.gateway.sessionSnapshot(); expect(after.status === "ready" && after.connection.private).toBe(true); @@ -67,7 +67,7 @@ describe("NativeEditorGateway over the public SDK (MemoryReplica, not LAB)", () it("a Viewer grant exposes no write operations or misleading access-update prompts", async () => { const f = await fixture({ capabilities: ["collection.read"], role: "viewer" }); - const membership = f.application.getSnapshot(); + const membership = f.mdbase.getSnapshot(); f.controls.publishSnapshot({ ...membership, collections: membership.collections.map((entry) => ({ ...entry, role: "viewer" })) }); const snapshot = f.gateway.sessionSnapshot(); const connection = snapshot.status === "ready" ? snapshot.connection : null; @@ -82,7 +82,7 @@ describe("NativeEditorGateway over the public SDK (MemoryReplica, not LAB)", () it("uses live SDK membership and refuses mutations before local capture", async () => { const f = await fixture({}, seedNotes); - const snapshot = f.application.getSnapshot(); + const snapshot = f.mdbase.getSnapshot(); const setRole = (role: "viewer" | "editor") => f.controls.publishSnapshot({ ...snapshot, collections: snapshot.collections.map((entry) => ({ ...entry, role })) }); @@ -168,7 +168,7 @@ describe("NativeEditorGateway over the public SDK (MemoryReplica, not LAB)", () const hello = vi.spyOn(f.client, "hello", "get").mockReturnValue({ ...original, version: { major: 1, minor: 3 }, grant: { role: "editor", capabilities: ["collection.read", "records.edit"] } }); - const snapshot = f.application.getSnapshot(); + const snapshot = f.mdbase.getSnapshot(); f.controls.publishSnapshot({ ...snapshot, collections: snapshot.collections.map((entry) => ({ ...entry, role: "viewer" })) }); expect(f.gateway.sessionSnapshot()).toMatchObject({ connection: { role: "viewer" } }); await expect(f.gateway.create({ path: "early-downgrade.md", title: "Refused", body: "viewer", properties: {} })) @@ -202,18 +202,16 @@ describe("NativeEditorGateway over the public SDK (MemoryReplica, not LAB)", () it("describes type schemas, versions and contract schemas from the native catalog", async () => { const f = await fixture({}, seedNotes); const describe = f.client.describe.bind(f.client); - const map = (value: object): Map => new Map(Object.entries(value).map(([key, item]) => - [key, item && typeof item === "object" && !Array.isArray(item) ? map(item) : item])); const schema = { type: "object", properties: { name: { type: "string" }, email: { type: "string" } } }; vi.spyOn(f.client, "describe").mockImplementation(async (options) => { const base = await describe(options); - if (!(typeof options === "object" && !(options instanceof AbortSignal) && options.definitions)) return base; + if (!options?.definitions) return base; return { ...base, types: [{ name: "person", path: "_types/person.md", version: 2, revision: `sha256:${"a".repeat(64)}`, - implements: [{ contract: "mdbase.person", version: "2.0.0", fields: new Map([["name", "name"]]), digest: `sha256:${"b".repeat(64)}` }], - definition: map({ kind: "mdbase.type", name: "person", version: 2, description: "People", extensions: { "x-app": { on: true } }, schema }), schema: map(schema) }], + implements: [{ contract: "mdbase.person", version: "2.0.0", fields: { name: "name" }, digest: `sha256:${"b".repeat(64)}` }], + definition: { kind: "mdbase.type", name: "person", version: 2, description: "People", extensions: { "x-app": { on: true } }, schema }, schema }], contracts: [{ id: "mdbase.person", version: "2.0.0", path: "_contracts/person.md", digest: `sha256:${"c".repeat(64)}`, contractType: "record", - implementedBy: ["person"], schemas: new Map([["record_schema", map(schema)]]) }] } as Awaited>; + implementedBy: ["person"], schemas: { record_schema: schema } }] } as Awaited>; }); const description = await f.gateway.describe(); expect(description.typing).toBeUndefined(); @@ -227,8 +225,8 @@ describe("NativeEditorGateway over the public SDK (MemoryReplica, not LAB)", () const f = await fixture(); const original = f.client.call.bind(f.client); const pages = [ - { version: 1, contractDigest: "sha256:test", page: { records: [{ id: "person-a", path: "people/a.md" }], cursor: "next-person-page", complete: true }, records: [{ id: "person-a", implementations: [{ typeName: "person", fields: new Map([["name", "Ada"]]), issues: [] }] }] }, - { version: 1, contractDigest: "sha256:test", page: { records: [{ id: "person-b", path: "people/b.md" }], complete: true }, records: [{ id: "person-b", implementations: [{ typeName: "person", fields: new Map([["name", "Bea"]]), issues: [] }] }] }, + { version: 1, contractDigest: "sha256:test", page: { records: [{ id: "person-a", path: "people/a.md" }], cursor: "next-person-page", complete: true }, records: [{ id: "person-a", implementations: [{ typeName: "person", fields: { name: "Ada" }, issues: [] }] }] }, + { version: 1, contractDigest: "sha256:test", page: { records: [{ id: "person-b", path: "people/b.md" }], complete: true }, records: [{ id: "person-b", implementations: [{ typeName: "person", fields: { name: "Bea" }, issues: [] }] }] }, ]; const selections: Array> = []; vi.spyOn(f.client, "call").mockImplementation(((method: string, params: unknown, options: unknown) => { @@ -374,11 +372,11 @@ describe("NativeEditorGateway over the public SDK (MemoryReplica, not LAB)", () ideas = replica.seed({ path: "notes/ideas.md", types: ["note"], frontmatter: { title: "Ideas" }, body: "# Ideas\n" }).id; }); // The SDK decides how it batches changes; deliver one batch by hand. - let deliver!: (batch: Parameters[1]>[0]) => void; + let deliver!: (batch: Parameters[0]>[0]) => void; const watchChanges = f.client.watchChanges.bind(f.client); - vi.spyOn(f.client, "watchChanges").mockImplementation((cursor, onChanges) => { + vi.spyOn(f.client, "watchChanges").mockImplementation((onChanges, options) => { deliver = onChanges; - return watchChanges(cursor, () => {}); + return watchChanges(() => {}, options); }); const observation = f.gateway.observe(); await observation.ready; @@ -418,7 +416,7 @@ describe("NativeEditorGateway over the public SDK (MemoryReplica, not LAB)", () expect(shown).toEqual([{ state: "loading", count: 500 }, { state: "loading", count: 1000 }, { state: "ready", count: 1100 }]); observation.close(); - const [first, second, third] = await Promise.all(["notes/0000.md", "notes/0001.md", "notes/0002.md"].map(async (path) => (await f.client.find({ path }, { effective: true }))!)); + const [first, second, third] = await Promise.all(["notes/0000.md", "notes/0001.md", "notes/0002.md"].map(async (path) => (await f.client.find({ path }, { include: { effective: true } }))!)); vi.spyOn(f.client, "pages").mockImplementation(async function* () { yield { records: [first], cursor: "next", complete: false, asOf: 1, totalCount: 3 }; yield { records: [second, first, third], complete: true, asOf: 1, totalCount: 3 }; @@ -464,13 +462,12 @@ describe("NativeEditorGateway over the public SDK (MemoryReplica, not LAB)", () const document = "---\nkind: mdbase.type\nname: note\n---\n"; const provision = { manifest: { kind: "mdbase.type-pack", id: "dev.mdbase.notes", version: "1.0.0", resources: [] }, resources: [{ source: "note.md", document }], provides: [] } as unknown as Parameters[0]; const native = (adopted: boolean, apply: boolean) => { - const row = new Map([["source", "note.md"], ["target", "_types/note.md"], ["kind", "type"], ["mode", "managed"], - ["action", adopted ? "update" : "conflict"], ["digest", digest("c")], ["current_digest", live]]); - if (adopted) row.set("adopted_from_digest", live); - const assessment = new Map([["status", adopted ? "install" : "conflict"], ["applicable", adopted], ["definitions_manage", true], - ["assessment_digest", digest(adopted ? "2" : "1")], - ["desired", new Map([["id", "dev.mdbase.notes"], ["version", "1.0.0"], ["digest", digest("d")], ["installed_by", "dev.mdbase.editor"], ["resources", []]])], - ["resources", [row]], ["lock", new Map([["target", "mdbase.lock.yaml"], ["action", "create"], ["digest", digest("e")]])]]); + const row = { source: "note.md", target: "_types/note.md", kind: "type", mode: "managed", + action: adopted ? "update" : "conflict", digest: digest("c"), current_digest: live, ...(adopted ? { adopted_from_digest: live } : {}) }; + const assessment = { status: adopted ? "install" : "conflict", applicable: adopted, definitions_manage: true, + assessment_digest: digest(adopted ? "2" : "1"), + desired: { id: "dev.mdbase.notes", version: "1.0.0", digest: digest("d"), installed_by: "dev.mdbase.editor", resources: [] }, + resources: [row], lock: { target: "mdbase.lock.yaml", action: "create", digest: digest("e") } }; return { version: 1, assessment, ...(apply ? { writes: [{ path: "_types/note.md", doc: document, mustNotExist: false }, { path: "mdbase.lock.yaml", doc: "lock\n", mustNotExist: true }] } : {}) }; }; const call = f.client.call.bind(f.client); diff --git a/apps/editor/src/native-gateway.ts b/apps/editor/src/native-gateway.ts index fcc32e2a3..58fc57f9f 100644 --- a/apps/editor/src/native-gateway.ts +++ b/apps/editor/src/native-gateway.ts @@ -3,12 +3,12 @@ import { effectiveCapabilities, isMdbaseError, MdbaseError, - toPlain, - type MdbaseApplication, + type Mdbase, type MdbaseApplicationConnection, type MdbaseApplicationDeclaration, type MdbaseClient, type TypePackPlan, + type TypeSummary, type wire } from "@mdbase-dev/sdk"; import type { @@ -25,7 +25,7 @@ import type { MdbaseRecordChange } from "@mdbase-dev/connect/advanced"; import { NativeNoteObservation } from "./native-note-observation"; import { downloadNativeFile, type NativeFileSelection } from "./native-file-download"; import { fileAssetKey } from "./file-reference-resolution"; -import { NextEditorRecords, plain } from "./next-editor-records"; +import { NextEditorRecords } from "./next-editor-records"; import type { CollectionAuthorizationOptions, CollectionAuthorizationTarget, @@ -75,8 +75,8 @@ const RESOLVE_LINKS_BATCH = 512; /** Production scopes belong to the SDK, for both cloud copies and Private * collections. Isolated tests may supply their own fenced scope actions. */ -export type EditorScopeActions = Pick - & Partial>; +export type EditorScopeActions = Pick + & Partial>; /** One borrowed collection. People access exists only when the SDK supplies its * scoped client; document access never implies metadata permission. */ @@ -184,7 +184,7 @@ export class NativeEditorGateway implements CollectionGateway { if (phase?.kind === "unsynced" && phase.collectionId === this.connection.id) return "unsynced"; if (this.client.ledger.getSnapshot().saveState === "saved_on_device") return "saved_on_device"; const sync = phase?.kind === "ready" && phase.collectionId === this.connection.id ? this.application.getSnapshot?.().sync : null; - return sync?.kind === "offline" || this.client.status.connection === "offline" || this.client.link !== "open" ? "offline" : "synced"; + return sync?.connection === "offline" || sync?.health === "unreachable" || this.client.status.connection === "offline" || this.client.link !== "open" ? "offline" : "synced"; } onSyncState(listener: () => void): () => void { @@ -219,7 +219,7 @@ export class NativeEditorGateway implements CollectionGateway { throw error; }); this.check(); - const described = full ?? await this.client.describe(signal); + const described = full ?? await this.client.describe({ signal }); this.check(); const versions = new Map(described.types.map((type) => [type.name, type.version])); return { @@ -230,18 +230,18 @@ export class NativeEditorGateway implements CollectionGateway { operations: this.operations(), // Native change cursors are opaque; observe() owns the watch position. changeCursor: 0, - configuration: { settings: toPlain(described.settings) }, + configuration: { settings: described.settings }, ...(full ? {} : { typing: "names_only" as const }), types: described.types.map(typeDescriptor), contracts: described.contracts.map((contract) => { const schemas = contract.schemas; - const bindingSchema = jsonObject(schemas?.get("binding_schema")); + const bindingSchema = jsonObject(schemas?.binding_schema); return { contractType: "record" as const, id: contract.id, version: contract.version, digest: contract.digest, - schema: jsonObject(schemas?.get("record_schema")) ?? {}, + schema: jsonObject(schemas?.record_schema) ?? {}, ...(bindingSchema ? { bindingSchema } : {}), implementations: described.types.flatMap((type) => type.implements .filter((implementation) => implementation.contract === contract.id && implementation.version === contract.version) @@ -251,8 +251,8 @@ export class NativeEditorGateway implements CollectionGateway { // Names-only describes carry no versions; 0 never reads as current. typeVersion: full ? versions.get(type.name) ?? 1 : 0, digest: implementation.digest ?? "", - fields: Object.fromEntries(implementation.fields), - ...(implementation.binding === undefined ? {} : { binding: toPlain(implementation.binding) as JsonObject }) + fields: implementation.fields, + ...(implementation.binding === undefined ? {} : { binding: implementation.binding as JsonObject }) }))) }; }) @@ -272,7 +272,7 @@ export class NativeEditorGateway implements CollectionGateway { ? record.implementations.find((implementation) => implementation.typeName === contract.type) : record.implementations.length === 1 ? record.implementations[0] : undefined; const path = result.page.records.find((view) => view.id === record.id)?.path; - if (projection && path) records.push({ path, values: plain(projection.fields) }); + if (projection && path) records.push({ path, values: projection.fields }); } cursor = result.page.cursor; if (cursor) { @@ -331,7 +331,7 @@ export class NativeEditorGateway implements CollectionGateway { } async mostRecentNote(): Promise { - const result = await this.client.query({ order_by: ["-file.mtime"], limit: 1 }, {}, this.signal()); + const result = await this.client.query({ order_by: ["-file.mtime"], limit: 1 }, { signal: this.signal() }); this.check(); return result.records[0]?.path; } @@ -412,12 +412,12 @@ export class NativeEditorGateway implements CollectionGateway { }); try { this.check(); - const receipt = await this.client.awaitReceipt(write.mutationId, 30_000, current); + const receipt = await this.client.awaitReceipt(write.mutationId, { timeoutMs: 30_000, signal: current }); this.check(); if (receipt.mutation !== write.mutationId || receipt.state !== "confirmed" || receipt.status !== "applied" || (receipt.conflicts?.length ?? 0) !== 0 || receipt.published === "publishing" || receipt.published === "not_published") throw new MdbaseError({ code: "outcome_unknown", recovery: "resolve_outcome", message: t`This file upload is not confirmed. Resolve its original operation before trying again.` }); - const file = await this.connection.files.get(write.fileId, current); + const file = await this.connection.files.get(write.fileId, { signal: current }); this.check(); if (file.id !== write.fileId || file.confirmedSeq !== receipt.seq || file.hold || file.state === "pending_upload") throw new MdbaseError({ code: "outcome_unknown", recovery: "resolve_outcome", message: t`The current file does not confirm this upload. Resolve its original operation before trying again.` }); @@ -477,7 +477,7 @@ export class NativeEditorGateway implements CollectionGateway { recoverNoteMutation(requestId: string): Promise { return this.records.recover(requestId); } async validate(path: string): Promise { - const validations = await this.client.validate([{ path }], this.signal()); + const validations = await this.client.validate({ refs: [{ path }], signal: this.signal() }); this.check(); return validations.flatMap((validation) => validation.issues.map((issue) => ({ severity: issue.severity, @@ -492,10 +492,10 @@ export class NativeEditorGateway implements CollectionGateway { async readType(name: string): Promise { const signal = this.signal(); - const type = (await this.client.describe(signal)).types.find((candidate) => candidate.name === name); + const type = (await this.client.describe({ signal })).types.find((candidate) => candidate.name === name); this.check(); if (!type) throw new MdbaseError({ code: "not_found", recovery: "refresh", message: t`The type ${name} is not defined in this collection.` }); - const resource = await this.client.resources.get(type.path, signal); + const resource = await this.client.resources.get(type.path, { signal }); this.check(); if (resource.text === undefined) throw unsupported(t`The collection did not return this type definition's source.`); return { name: type.name, path: resource.path, revision: resource.revision, document: resource.text }; @@ -555,7 +555,7 @@ export class NativeEditorGateway implements CollectionGateway { /** Waits for a definition write's receipt; anything short of confirmed and * applied is reported, never assumed. */ private async confirmed(mutationId: string, signal: AbortSignal, subject: string): Promise { - const receipt = await this.client.awaitReceipt(mutationId, RECEIPT_TIMEOUT_MS, signal); + const receipt = await this.client.awaitReceipt(mutationId, { timeoutMs: RECEIPT_TIMEOUT_MS, signal }); this.check(); if (receipt.state === "rejected") throw receipt.problem ? new MdbaseError(receipt.problem) : new Error(t`The ${subject} was rejected.`); if (receipt.status === "conflicted") throw new MdbaseError({ code: "conflict", recovery: "resolve_conflict", message: t`The ${subject} has a conflict. Resolve it before continuing.` }); @@ -566,12 +566,12 @@ export class NativeEditorGateway implements CollectionGateway { /** Reads back the confirmed type source and the name Core gives it. */ private async typeAt(path: string, written: string, signal: AbortSignal): Promise { - const resource = await this.client.resources.get(path, signal); + const resource = await this.client.resources.get(path, { signal }); this.check(); if (resource.text !== written) { throw new MdbaseError({ code: "conflict", recovery: "refresh", reason: "concurrent_modification", message: t`This type changed after it was saved. Reload it before editing again.` }); } - const type = (await this.client.describe(signal)).types.find((candidate) => candidate.path === path); + const type = (await this.client.describe({ signal })).types.find((candidate) => candidate.path === path); this.check(); if (!type) throw new MdbaseError({ code: "invalid_request", recovery: "fix_request", reason: "invalid_type_definition", message: t`The collection does not read this file as a type definition. Check its name and kind.` }); return { name: type.name, path, revision: resource.revision, document: resource.text }; @@ -665,7 +665,7 @@ function unsupported(message: string): MdbaseError { return new MdbaseError({ code: "invalid_request", recovery: "fix_request", reason: "unsupported", message }); } -function typeDescriptor(type: wire.TypeSummary): EditorCollectionDescription["types"][number] { +function typeDescriptor(type: TypeSummary): EditorCollectionDescription["types"][number] { const definition = jsonObject(type.definition); const description = definition?.description; const collection = jsonObject(definition?.collection); @@ -685,9 +685,7 @@ function typeDescriptor(type: wire.TypeSummary): EditorCollectionDescription["ty } function jsonObject(value: unknown): JsonObject | undefined { - if (value === undefined || value === null) return undefined; - const plainValue = value instanceof Map ? toPlain(value as wire.Value) : value; - return plainValue && typeof plainValue === "object" && !Array.isArray(plainValue) ? plainValue as JsonObject : undefined; + return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined; } /** The editor's assessment shape from the SDK plan (no contract setups). */ diff --git a/apps/editor/src/native-note-observation.ts b/apps/editor/src/native-note-observation.ts index f5a333a8c..66aee601a 100644 --- a/apps/editor/src/native-note-observation.ts +++ b/apps/editor/src/native-note-observation.ts @@ -1,7 +1,6 @@ import { t } from "@lingui/core/macro"; -import { isMdbaseError, MdbaseError, type ChangesWatch, type ChangesWatchState, type MdbaseClient, type wire } from "@mdbase-dev/sdk"; +import { isMdbaseError, MdbaseError, type ChangesWatch, type ChangesWatchState, type MdbaseClient, type RecordView, type wire } from "@mdbase-dev/sdk"; import type { QueryRecord } from "@mdbase-dev/connect"; -import { plain } from "./next-editor-records"; import type { EditorCollectionChange, NativeCollectionChange, @@ -43,7 +42,7 @@ export class NativeNoteObservation implements NoteObservation { constructor(private readonly client: MdbaseClient, private readonly isCurrent: () => boolean = () => true) { this.snapshot = { records: [], state: "loading", generation: 1, problem: null, watchStatus: { state: "connecting" } }; // Registered before the first page so no change between pages is missed. - this.watch = client.watchChanges(undefined, batch => this.accept(batch)); + this.watch = client.watchChanges(batch => this.accept(batch)); this.stopWatchState = this.watch.subscribe((state, error) => this.watchChanged(state, error)); this.ready = this.load(false); } @@ -124,7 +123,7 @@ export class NativeNoteObservation implements NoteObservation { let asOf: number | undefined; let complete = false; let total: number | undefined; - for await (const page of this.client.pages({ limit: PAGE_SIZE }, { effective: true, ...(includeBody ? { body: true } : {}) }, signal)) { + for await (const page of this.client.pages({ limit: PAGE_SIZE }, { include: { effective: true, ...(includeBody ? { body: true } : {}) }, signal })) { this.check(signal); if (asOf !== undefined && asOf !== page.asOf) throw nativeFailure(t`The collection changed while its notes were loading. Reload to try again.`); asOf = page.asOf; @@ -191,7 +190,7 @@ export class NativeNoteObservation implements NoteObservation { // A reset clears the queue while a reread is awaited. if (!this.queued.delete(id)) continue; const previous = this.confirmed.get(id); - const record = await this.client.find(id, { effective: true, ...(this.includeBody ? { body: true } : {}) }, signal); + const record = await this.client.find(id, { include: { effective: true, ...(this.includeBody ? { body: true } : {}) }, signal }); this.check(signal); if (this.loading) { this.queued.add(id); break; } let change: NativeCollectionChange | undefined; @@ -278,13 +277,13 @@ export class NativeNoteObservation implements NoteObservation { } } -function noteRecord(record: wire.RecordView): Note { +function noteRecord(record: RecordView): Note { return { path: record.path, revision: record.revision, types: [...record.types], - frontmatter: plain(record.frontmatter), - effectiveFrontmatter: plain(record.effective ?? record.frontmatter), + frontmatter: record.frontmatter, + effectiveFrontmatter: record.effective ?? record.frontmatter, ...(record.body === undefined ? {} : { body: record.body }), file: { path: record.path, ...(record.mtime === undefined ? {} : { mtime: new Date(record.mtime).toISOString() }) } }; diff --git a/apps/editor/src/next-editor-records.test.ts b/apps/editor/src/next-editor-records.test.ts index 23092d928..d4f818dce 100644 --- a/apps/editor/src/next-editor-records.test.ts +++ b/apps/editor/src/next-editor-records.test.ts @@ -1,4 +1,4 @@ -import { connect, MdbaseError, type MdbaseClient } from "@mdbase-dev/sdk"; +import { MdbaseError, type MdbaseClient } from "@mdbase-dev/sdk"; import { MemoryReplica } from "@mdbase-dev/sdk/testing"; import { afterEach, describe, expect, it, vi } from "vitest"; import { NextEditorRecords } from "./next-editor-records"; @@ -14,7 +14,7 @@ afterEach(() => { async function fixture(pending = false) { const replica = new MemoryReplica({ confirmDelayMs: pending ? null : 0 }); const record = replica.seed({ path: "notes/plan.md", frontmatter: { title: "Plan", status: "open" }, body: "Original body\n" }); - const client = await connect({ connector: replica.connector(), app: { name: "editor-record-test", version: "test" }, reconnect: false }); + const client = await replica.client(); clients.push(client); const adapter = new NextEditorRecords(client); adapters.push(adapter); @@ -29,13 +29,13 @@ describe("NextEditorRecords (SDK stand-in, not LAB)", () => { expect(note.body).toBe("Original body\n"); expect(note.document).toContain("Original body"); expect(note.effectiveFrontmatter.title).toBe("Plan"); - expect(get).toHaveBeenCalledWith({ path: "notes/plan.md" }, { body: true, effective: true, document: true }, expect.any(AbortSignal)); + expect(get).toHaveBeenCalledWith({ path: "notes/plan.md" }, { include: { body: true, effective: true, document: true }, signal: expect.any(AbortSignal) }); expect(note.file).toEqual({ path: "notes/plan.md" }); }); it("refuses absent source bytes rather than reconstructing formatted YAML", async () => { const f = await fixture(); - const record = await f.client.get(f.record.id, { body: true }); + const record = await f.client.get(f.record.id, { include: { body: true } }); vi.spyOn(f.client, "get").mockResolvedValue(record); await expect(f.adapter.read(record.path)).rejects.toThrow("complete note source"); }); @@ -197,7 +197,7 @@ describe("NextEditorRecords (SDK stand-in, not LAB)", () => { it("refuses protected records before any submission", async () => { const f = await fixture(); - const record = await f.client.get(f.record.id, { body: true, effective: true, document: true }); + const record = await f.client.get(f.record.id, { include: { body: true, effective: true, document: true } }); vi.spyOn(f.client, "get").mockResolvedValue({ ...record, state: { ...record.state, unresolved: 1 } }); const base = await f.adapter.read(record.path); const update = vi.spyOn(f.client, "update"); @@ -215,7 +215,7 @@ describe("NextEditorRecords (SDK stand-in, not LAB)", () => { it("refuses a metadata-only fallback base before submitting document replacement", async () => { const f = await fixture(true); - const partial = await f.client.get(f.record.id, { body: true }); + const partial = await f.client.get(f.record.id, { include: { body: true } }); vi.spyOn(f.client, "get").mockResolvedValue(partial); const replace = vi.spyOn(f.client, "replaceDocument"); vi.spyOn(f.client, "awaitReceipt").mockImplementation(async mutation => ({ mutation, state: "pending" })); @@ -226,7 +226,7 @@ describe("NextEditorRecords (SDK stand-in, not LAB)", () => { it("refuses a complete source whose bytes do not match the native fallback revision", async () => { const f = await fixture(); - const record = await f.client.get(f.record.id, { body: true, document: true }); + const record = await f.client.get(f.record.id, { include: { body: true, document: true } }); // Controlled malformed read response, not native parser qualification. vi.spyOn(f.client, "get").mockResolvedValue({ ...record, document: `${record.document}\n` }); const replace = vi.spyOn(f.client, "replaceDocument"); @@ -239,15 +239,15 @@ describe("NextEditorRecords (SDK stand-in, not LAB)", () => { const f = await fixture(true); const base = await f.adapter.read(f.record.path); const originalGet = f.client.get.bind(f.client); - const get = vi.spyOn(f.client, "get").mockImplementation(async (ref, include, signal) => { - const record = await originalGet(ref, include, signal); + const get = vi.spyOn(f.client, "get").mockImplementation(async (ref, options) => { + const record = await originalGet(ref, options); // Controlled protected read response; MemoryReplica does not merge holds. return { ...record, state: { ...record.state, unresolved: 1 } }; }); const originalReceipt = f.client.awaitReceipt.bind(f.client); - const receipt = vi.spyOn(f.client, "awaitReceipt").mockImplementation(async (mutation, timeout, signal) => { + const receipt = vi.spyOn(f.client, "awaitReceipt").mockImplementation(async (mutation, options) => { f.replica.confirmAll(); - return originalReceipt(mutation, timeout, signal); + return originalReceipt(mutation, options); }); await expect(f.adapter.updateProperties(base.path, { title: "Changed" }, base.revision)).rejects.toMatchObject({ code: "outcome_unknown", reason: "unqualified_readback" }); const [mutation] = f.adapter.pendingMutations(); @@ -273,7 +273,7 @@ describe("NextEditorRecords (SDK stand-in, not LAB)", () => { it("ignores late source replies after its repository lifetime is closed", async () => { const f = await fixture(); - const record = await f.client.get(f.record.id, { body: true, document: true }); + const record = await f.client.get(f.record.id, { include: { body: true, document: true } }); vi.spyOn(f.client, "get").mockImplementation(async () => { f.adapter.dispose(); return record; }); await expect(f.adapter.read(record.path)).rejects.toMatchObject({ name: "AbortError" }); }); diff --git a/apps/editor/src/next-editor-records.ts b/apps/editor/src/next-editor-records.ts index ffd911e35..8d30261fc 100644 --- a/apps/editor/src/next-editor-records.ts +++ b/apps/editor/src/next-editor-records.ts @@ -1,5 +1,5 @@ import { t } from "@lingui/core/macro"; -import { isMdbaseError, MdbaseError, revisionOf, toPlain, type MdbaseClient, type PlainValue, type Write, type wire } from "@mdbase-dev/sdk"; +import { isMdbaseError, MdbaseError, revisionOf, type MdbaseClient, type PlainValue, type RecordView, type Write, type wire } from "@mdbase-dev/sdk"; import type { JsonObject } from "@mdbase-dev/connect"; import type { MdbaseRecordChange } from "@mdbase-dev/connect/advanced"; import type { CreateNoteInput, NoteDocument } from "./model"; @@ -23,7 +23,7 @@ const RECEIPT_WAIT_MS = 3_000; * ID. Sign-in owns the client. */ export class NextEditorRecords { private readonly lifetime = new AbortController(); - private readonly seen = new Map(); + private readonly seen = new Map(); private seenBytes = 0; /** Captured writes this adapter couldn't settle (unknown outcome, failed readback). */ private readonly unknown = new Map(); @@ -37,7 +37,7 @@ export class NextEditorRecords { async read(path: string, signal?: AbortSignal): Promise { const current = this.signal(signal); - const record = await this.client.get({ path }, noteInclude, current); + const record = await this.client.get({ path }, { include: noteInclude, signal: current }); this.check(current); return this.remember(record); } @@ -141,10 +141,10 @@ export class NextEditorRecords { dispose(): void { this.lifetime.abort(); this.clearSeen(); } - private async base(path: string, revision: string, signal: AbortSignal): Promise { + private async base(path: string, revision: string, signal: AbortSignal): Promise { this.check(signal); const key = JSON.stringify([path, revision]); - const record = this.seen.get(key)?.record ?? await this.client.get({ path }, noteInclude, signal); + const record = this.seen.get(key)?.record ?? await this.client.get({ path }, { include: noteInclude, signal }); this.check(signal); if (record.path !== path || record.revision !== revision) throw new MdbaseError({ code: "conflict", reason: "revision_mismatch", recovery: "refresh", message: t`This note changed after it was opened. Reload before saving.` }); @@ -186,16 +186,16 @@ export class NextEditorRecords { } private async captured(mutationId: string): Promise { - try { await this.client.receipt(mutationId, AbortSignal.timeout(10_000)); return true; } + try { await this.client.receipt(mutationId, { signal: AbortSignal.timeout(10_000) }); return true; } catch (error) { return !isMdbaseError(error, "not_found"); } } private async confirm(mutation: NextEditorPendingMutation, signal: AbortSignal): Promise { // Still pending after the wait is saved on this device, not a failure. - const receipt = await this.client.awaitReceipt(mutation.mutationId, RECEIPT_WAIT_MS, signal).catch((error: unknown) => { + const receipt = await this.client.awaitReceipt(mutation.mutationId, { timeoutMs: RECEIPT_WAIT_MS, signal }).catch((error: unknown) => { if (isMdbaseError(error, "cancelled")) throw error; this.check(signal); - return this.client.receipt(mutation.mutationId, signal); + return this.client.receipt(mutation.mutationId, { signal }); }); this.check(signal); if (receipt.mutation !== mutation.mutationId) throw new Error(t`The native receipt changed its mutation identity.`); @@ -207,7 +207,7 @@ export class NextEditorRecords { if (receipt.status === "conflicted") throw new Refused({ code: "conflict", recovery: "resolve_conflict", message: t`This note change has a conflict. Resolve it before continuing.` }); if (mutation.operation === "delete") { this.unknown.delete(mutation.mutationId); this.clearSeen(); return undefined; } - const record = await this.client.get(mutation.recordId, noteInclude, signal); + const record = await this.client.get(mutation.recordId, { include: noteInclude, signal }); this.check(signal); if (record.id !== mutation.recordId) throw new Error(t`The saved note changed its record identity.`); if (record.state.hold || record.state.unresolved) @@ -216,15 +216,15 @@ export class NextEditorRecords { return this.remember(record); } - private remember(record: wire.RecordView): NoteDocument { + private remember(record: RecordView): NoteDocument { completeSource(record); const key = JSON.stringify([record.path, record.revision]); const previous = this.seen.get(key); if (previous) this.seenBytes -= previous.bytes; this.seen.delete(key); const bytes = 2 * (record.body.length + record.document.length + - JSON.stringify(plain(record.frontmatter)).length + - JSON.stringify(plain(record.effective ?? record.frontmatter)).length); + JSON.stringify(record.frontmatter).length + + JSON.stringify(record.effective ?? record.frontmatter).length); if (bytes <= 8 * 1024 * 1024) { this.seen.set(key, { record, bytes }); this.seenBytes += bytes; @@ -235,7 +235,7 @@ export class NextEditorRecords { this.seen.delete(oldest); } return { path: record.path, revision: record.revision, types: [...record.types], - frontmatter: plain(record.frontmatter), effectiveFrontmatter: plain(record.effective ?? record.frontmatter), + frontmatter: record.frontmatter, effectiveFrontmatter: record.effective ?? record.frontmatter, body: record.body, document: record.document, file: { path: record.path, ...(record.mtime === undefined ? {} : { mtime: new Date(record.mtime).toISOString() }) } }; } @@ -256,14 +256,12 @@ export class NextEditorRecords { } const noteInclude = { body: true, effective: true, document: true }; -function completeSource(record: wire.RecordView): asserts record is wire.RecordView & { body: string; document: string } { +function completeSource(record: RecordView): asserts record is RecordView & { body: string; document: string } { if (record.body === undefined || record.document === undefined) throw unsupported(t`Mdbase did not return the complete note source. No source was reconstructed.`); if (revisionOf(record.document) !== record.revision) throw unsupported(t`The complete note source does not match its native revision. No source was reconstructed or submitted.`); } -/** Plain JSON view of native frontmatter, for the editor's JSON domain model. */ -export function plain(value: wire.FmMap): JsonObject { return Object.fromEntries([...value].map(([key, item]) => [key, toPlain(item)])) as JsonObject; } /** The log refused the write: a plain failure, nothing to recover. */ class Refused extends MdbaseError {} function unsupported(message: string): MdbaseError { return new MdbaseError({ code: "invalid_request", recovery: "fix_request", reason: "unsupported", message }); } diff --git a/apps/editor/src/private-collections.ts b/apps/editor/src/private-collections.ts index e6b9fa85a..07df33e74 100644 --- a/apps/editor/src/private-collections.ts +++ b/apps/editor/src/private-collections.ts @@ -1,4 +1,4 @@ -import type { MdbaseApplicationSnapshot, MdbaseClient } from "@mdbase-dev/sdk"; +import type { MdbaseClient, MdbaseSnapshot } from "@mdbase-dev/sdk"; import type { CollectionAvailability, MdbaseCollectionEntry, @@ -6,7 +6,7 @@ import type { MdbaseWaitOptions } from "@mdbase-dev/sdk/app-grant"; -type AppInstallationCollection = MdbaseApplicationSnapshot["collections"][number]; +type AppInstallationCollection = MdbaseSnapshot["collections"][number]; /** Isolated test-adapter surface. Production collection authority and lifetime * belong to the SDK's account-bound Native controller. */ diff --git a/apps/editor/src/test/native-fixture.ts b/apps/editor/src/test/native-fixture.ts index 8cf665e94..9725c00d0 100644 --- a/apps/editor/src/test/native-fixture.ts +++ b/apps/editor/src/test/native-fixture.ts @@ -1,4 +1,4 @@ -import { connect, MdbasePeopleClient, type Mdbase, type MdbaseApplicationConnection, type MdbaseClient } from "@mdbase-dev/sdk"; +import { MdbasePeopleClient, type MdbaseApplicationConnection } from "@mdbase-dev/sdk"; import { createApplicationFixture, createTestApp, MemoryReplica, type MemoryReplicaOptions, type TestAppCollection, type TestAppOptions } from "@mdbase-dev/sdk/testing"; import { vi } from "vitest"; import manifest from "../../public/.well-known/mdbase-app.json"; @@ -23,7 +23,7 @@ export async function testApp(options: Omit = {}) async function nativeConnection(options: MemoryReplicaOptions = {}) { const replica = new MemoryReplica({ confirmDelayMs: 0, ...options }); - const client: MdbaseClient = await connect({ connector: replica.connector(), app: { name: "editor-native-test", version: "test" }, reconnect: false }); + const client = await replica.client(); owned.push(client); const metadata = vi.fn(async () => { throw new Error("No inferred metadata permission"); }); const current = vi.fn(() => true); @@ -57,32 +57,11 @@ export async function unsyncedFixture(phase: "signed_out" | "ready", seed?: (uns // A well-formed snapshot for the Notes collection: ready, or signed out. const template = await testApp({ collections: [{ id: notes.replica.collection, name: "Notes" }], phase }); const fixture = createApplicationFixture({ declaration: template.controls.declaration, connections: [notes.connection], - initialSnapshot: template.application.getSnapshot(), unsynced: unsynced.connection }); + initialSnapshot: template.mdbase.getSnapshot(), unsynced: unsynced.connection }); owned.push({ close: () => fixture.controls.close() }); - const app = fixture.entry.application(); - const call = (method: ((...args: never[]) => T) | undefined) => { - if (!method) throw new Error("The fixture lacks work before connecting"); - return method; - }; - // As the SDK's front door does over its entry; only what the editor and the shared UI call. - const mdbase = { - getSnapshot: () => app.getSnapshot(), subscribe: (listener: () => void) => app.subscribe(listener), - start: (options?: object) => app.start(options), signIn: (options?: object) => app.authorize(options), - addCollection: (options?: object) => app.approve(null, options), approve: (id?: string | null, options?: object) => app.approve(id, options), - select: (id: string, options?: object) => app.select(id, options), chooseAnother: (options?: object) => app.chooseAnother(options), - forget: (id: string, options?: object) => app.forget(id, options), signOut: (options?: object) => call(app.signOut)(options as never), - reauthorize: (options?: object) => app.reauthorize(options), assess: (options?: object) => app.assess(options), - connection: (id: string) => fixture.entry.connection(id), setForeground: (active: boolean) => app.setForeground(active), - close: () => app.close(), application: app, - unsynced: { - open: (options?: object) => call(app.openUnsynced)(options as never), - planTransfer: (options?: object) => call(app.planUnsyncedTransfer)(options as never), - transfer: (plan: unknown, options?: object) => call(app.transferUnsynced)(plan as never, options as never), - discard: (options?: object) => call(app.discardUnsynced)(options as never) - } - } as unknown as Mdbase; + const mdbase = fixture.mdbase; const calls = (action: string) => fixture.controls.observedActions().filter((entry) => entry.action === action); /** What the SDK reports once something was saved in the unsynced collection. */ - const markWork = () => fixture.controls.publishSnapshot({ ...app.getSnapshot(), unsyncedWork: { since: 1_762_000_000_000 } }); - return { mdbase, controls: fixture.controls, application: app, notes, unsynced, calls, markWork }; + const markWork = () => fixture.controls.publishSnapshot({ ...mdbase.getSnapshot(), unsyncedWork: { since: 1_762_000_000_000 } }); + return { mdbase, controls: fixture.controls, notes, unsynced, calls, markWork }; } diff --git a/apps/editor/tests/note-body.ts b/apps/editor/tests/note-body.ts index 5504d1b1f..382c4d11d 100644 --- a/apps/editor/tests/note-body.ts +++ b/apps/editor/tests/note-body.ts @@ -1,10 +1,16 @@ import { expect, type Locator } from "@playwright/test"; -/** Replaces the body of the demo's opening note. That note embeds an image, +/** Waits for the demo's opening note to settle. That note embeds an image, * which settles only after the collection has resolved its link, and the editor - * redraws as it does. A redraw between fill's select-all and its typing drops - * the selection, so the text would be inserted instead of replacing the body. */ -export async function replaceOpeningNoteBody(body: Locator, text: string): Promise { + * redraws as it does: the embed's text changes from "Opening …" to its actions. */ +export async function openingNoteSettled(body: Locator): Promise { await expect(body.locator(".cm-file-embed.ready")).toHaveCount(1); +} + +/** Replaces the body of the demo's opening note once it has settled. A redraw + * between fill's select-all and its typing drops the selection, so the text + * would be inserted instead of replacing the body. */ +export async function replaceOpeningNoteBody(body: Locator, text: string): Promise { + await openingNoteSettled(body); await body.fill(text); } diff --git a/apps/editor/tests/round5.spec.ts b/apps/editor/tests/round5.spec.ts index 07a630c60..f89b6a9c9 100644 --- a/apps/editor/tests/round5.spec.ts +++ b/apps/editor/tests/round5.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { openingNoteSettled } from "./note-body"; for (const width of [1440, 390]) for (const theme of ["light", "dark"] as const) for (const motion of ["no-preference", "reduce"] as const) { test.describe(`W18 ${width} ${theme} ${motion}`, () => { @@ -10,6 +11,7 @@ for (const width of [1440, 390]) for (const theme of ["light", "dark"] as const) const body = page.getByRole("textbox", { name: "Note body", exact: true }); await expect(body).toBeVisible(); await page.evaluate(() => document.fonts.ready); + await openingNoteSettled(body); const original = await body.innerText(); await expect(body).not.toBeFocused(); const list = page.locator(".note-scroll"); @@ -60,6 +62,7 @@ for (const width of [1440, 390]) for (const theme of ["light", "dark"] as const) const body = page.locator(".code-editor-writer .cm-content"); await expect(body).toBeVisible(); await page.evaluate(() => document.fonts.ready); + await openingNoteSettled(body); const original = (await body.textContent())!; const list = page.locator(".note-scroll"); await page.keyboard.press("g"); await page.keyboard.press("n"); diff --git a/apps/editor/vendor/sdk-e8ffb806-production-beta188.facts.json b/apps/editor/vendor/sdk-e8ffb806-production-beta188.facts.json new file mode 100644 index 000000000..a57c878c2 --- /dev/null +++ b/apps/editor/vendor/sdk-e8ffb806-production-beta188.facts.json @@ -0,0 +1,5 @@ +{ + "source": "e8ffb806d16c58fe03cf50c96fb66e29ebb612ba", + "sha256": "9ac39582b5440272f7e95177ac4f4840aebd4c8ddf832b24545232abe99c6b27", + "packagedTrustModuleSha256": "cf9690968d68b3c1eed2356ccfa2c72198b2f8cf07b9b24b1bb7be499b05bffb" +} diff --git a/apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz b/apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz new file mode 100644 index 000000000..b4d999ced Binary files /dev/null and b/apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz differ diff --git a/apps/editor/vendor/sdk-production-beta188.json b/apps/editor/vendor/sdk-production-beta188.json new file mode 100644 index 000000000..43d323535 --- /dev/null +++ b/apps/editor/vendor/sdk-production-beta188.json @@ -0,0 +1,48 @@ +{ + "schema": "tnapp/production-sdk-original/1", + "artifact": "sdk-e8ffb806-production-beta188.tgz", + "version": "0.1.0-beta.188", + "sourceCommit": "e8ffb806d16c58fe03cf50c96fb66e29ebb612ba", + "environment": "production", + "bytes": 4819840, + "sha256": "9ac39582b5440272f7e95177ac4f4840aebd4c8ddf832b24545232abe99c6b27", + "sri": "sha512-EDaInyP8q2Lz0Akz8P+CNj5O6OvinMJWR1bE04fKtWRFDt9mVRBRBYRja5okOtb3gtnKYyMdQFGLlFln7F1RrA==", + "publication": { + "repository": "mdbase-dev/mdbase-connect", + "workflow": ".github/workflows/publish-sdk-npm.yml", + "ref": "refs/tags/v0.1.0-beta.188", + "npm": "@mdbase-dev/sdk@0.1.0-beta.188", + "integrity": "sha512-EDaInyP8q2Lz0Akz8P+CNj5O6OvinMJWR1bE04fKtWRFDt9mVRBRBYRja5okOtb3gtnKYyMdQFGLlFln7F1RrA==", + "tarball": "https://registry.npmjs.org/@mdbase-dev/sdk/-/sdk-0.1.0-beta.188.tgz", + "attestations": "https://registry.npmjs.org/-/npm/v1/attestations/@mdbase-dev%2fsdk@0.1.0-beta.188" + }, + "trust": { + "package": "@mdbase-dev/sdk", + "sha256": "7912e40838213e60b65848350d319a48019021317b112e3326429b250f0133d7", + "bytes": 840, + "cpOrigin": "https://connect.mdbase.dev", + "logOrigin": "https://log.mdbase.dev", + "sourceCommit": "36abd4b065be9495a7f5ae0808865a1f2279af04", + "sourceVersion": "0.1.0-beta.141" + }, + "runtime": { + "sha256": "7004351583c3a79411e644fafe9f5786816d32ea058605d1905fd50666012f40", + "bytes": 3875815, + "maxBytes": 3875815, + "source": { + "repository": "mdbase-dev/mdbase-connect", + "commit": "aae332572834fbfa065c0f696017c0de3540649d" + } + }, + "packagedTrustModuleSha256": "cf9690968d68b3c1eed2356ccfa2c72198b2f8cf07b9b24b1bb7be499b05bffb", + "bundledDependencies": [ + { + "name": "@mdbase-dev/connect-protocol", + "member": "package/vendor/mdbase-dev-connect-protocol-0.1.0-beta.145-e6e66bcd172f.tgz", + "bytes": 106404, + "sha256": "e901144b8d93dc96fcf71d9d8f85dddbf647e9908913be21191c484e9224bb98" + } + ], + "artifactFacts": "sdk-e8ffb806-production-beta188.facts.json", + "artifactFactsSha256": "ede87c478fa8630e6e0b4920dd4775f70eb6b03452d654f78e3665a5857bd664" +} diff --git a/changelog.d/editor-sdk-repin.md b/changelog.d/editor-sdk-repin.md index 85989feee..8791e1cd6 100644 --- a/changelog.d/editor-sdk-repin.md +++ b/changelog.d/editor-sdk-repin.md @@ -1,3 +1,3 @@ ## Changed -- Pin the Editor to the original production SDK 0.1.0-beta.173 from 19197d2cef809d3f531acb150b7e9aac06b3833f and its byte-identical bundled protocol. +- Pin the Editor to the original production SDK 0.1.0-beta.188 from e8ffb806d16c58fe03cf50c96fb66e29ebb612ba and its byte-identical bundled protocol. diff --git a/changelog.d/sdk-polish-consumers.md b/changelog.d/sdk-polish-consumers.md new file mode 100644 index 000000000..449056618 --- /dev/null +++ b/changelog.d/sdk-polish-consumers.md @@ -0,0 +1,5 @@ +## Changed + +- Move the Editor, the shared app UI and the MCP service to the reworked `@mdbase-dev/sdk` API: one `createMdbase` entry, trailing read options, plain JSON values, and `connect` from `@mdbase-dev/sdk/thin-client`. +- Pin the shared app UI's development SDK and the MCP service's SDK to the published `@mdbase-dev/sdk@0.1.0-beta.188`. The MCP service was on a vendored `0.0.0` build; that tarball and an unreferenced one are removed, and the server and MCP images no longer copy `packages/app-ui/vendor`. +- `@mdbase-dev/ui` now needs `@mdbase-dev/sdk` `0.1.0-beta.188` or later when the SDK is installed: it reads the storage warning from `snapshot.sync.attention`. diff --git a/deploy/docker/Dockerfile.mcp b/deploy/docker/Dockerfile.mcp index 58de55c76..1357b448f 100644 --- a/deploy/docker/Dockerfile.mcp +++ b/deploy/docker/Dockerfile.mcp @@ -6,17 +6,12 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY patches patches # The workspace lock resolves file-pinned editor SDK bytes even for filtered installs. -COPY apps/editor/vendor/sdk-19197d2c-production-beta173.tgz apps/editor/vendor/mdbase-dev-connect-protocol-0.1.0-beta.145-e6e66bcd172f.tgz apps/editor/vendor/ +COPY apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz apps/editor/vendor/mdbase-dev-connect-protocol-0.1.0-beta.145-e6e66bcd172f.tgz apps/editor/vendor/ COPY packages/protocol/package.json packages/protocol/package.json COPY packages/client/package.json packages/client/package.json COPY services/mcp/package.json services/mcp/package.json -# services/mcp and packages/app-ui share the vendored SDK tarball under packages/app-ui; frozen installs read it. -COPY packages/app-ui/vendor packages/app-ui/vendor RUN pnpm install --frozen-lockfile --filter @mdbase/connect-mcp... -# Same-named vendored SDK builds can hoist over each other; fail unless MCP resolves its own. -COPY scripts/check-vendored-sdk.mjs scripts/check-vendored-sdk.mjs -RUN node scripts/check-vendored-sdk.mjs services/mcp COPY packages/protocol packages/protocol COPY packages/client packages/client diff --git a/deploy/docker/Dockerfile.server b/deploy/docker/Dockerfile.server index d45896207..582f5c8b8 100644 --- a/deploy/docker/Dockerfile.server +++ b/deploy/docker/Dockerfile.server @@ -6,7 +6,7 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY patches patches # The workspace lock resolves file-pinned editor SDK bytes even for filtered installs. -COPY apps/editor/vendor/sdk-19197d2c-production-beta173.tgz apps/editor/vendor/mdbase-dev-connect-protocol-0.1.0-beta.145-e6e66bcd172f.tgz apps/editor/vendor/ +COPY apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz apps/editor/vendor/mdbase-dev-connect-protocol-0.1.0-beta.145-e6e66bcd172f.tgz apps/editor/vendor/ COPY apps/portal/package.json apps/portal/package.json COPY packages/app-ui/package.json packages/app-ui/package.json COPY packages/i18n/package.json packages/i18n/package.json @@ -15,8 +15,6 @@ COPY packages/protocol/package.json packages/protocol/package.json COPY packages/sync/package.json packages/sync/package.json COPY packages/ui/package.json packages/ui/package.json COPY services/server/package.json services/server/package.json -# The workspace lockfile names the vendored SDK tarball that services/mcp and packages/app-ui share; frozen installs read it. -COPY packages/app-ui/vendor packages/app-ui/vendor RUN pnpm install --frozen-lockfile \ --filter @mdbase/connect-server... \ diff --git a/packages/app-ui/package.json b/packages/app-ui/package.json index a9368177d..5aec3a5ff 100644 --- a/packages/app-ui/package.json +++ b/packages/app-ui/package.json @@ -163,7 +163,7 @@ "@lingui/core": "6.9.0", "@lingui/react": "6.9.0", "@mdbase-dev/i18n": "*", - "@mdbase-dev/sdk": "*", + "@mdbase-dev/sdk": ">=0.1.0-beta.188", "react": "^19.2.0", "react-dom": "^19.2.0", "@codemirror/commands": "^6.0.0" @@ -183,7 +183,7 @@ "@lingui/react": "6.9.0", "@lingui/vite-plugin": "6.9.0", "@mdbase-dev/i18n": "workspace:*", - "@mdbase-dev/sdk": "0.1.0-beta.160", + "@mdbase-dev/sdk": "0.1.0-beta.188", "@rolldown/plugin-babel": "^0.2.4", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", diff --git a/packages/app-ui/src/collections/CollectionSaveWarning.tsx b/packages/app-ui/src/collections/CollectionSaveWarning.tsx index b341507a1..91d47f360 100644 --- a/packages/app-ui/src/collections/CollectionSaveWarning.tsx +++ b/packages/app-ui/src/collections/CollectionSaveWarning.tsx @@ -13,8 +13,7 @@ export function useCollectionSaveWarning(supplied?: Mdbase) { const inherited = useContext(CollectionMdbaseContext); const mdbase = supplied ?? inherited; const snapshot = useSyncExternalStore(mdbase?.subscribe ?? emptySubscribe, mdbase?.getSnapshot ?? emptySnapshot, emptySnapshot); - const warning = snapshot?.saveWarning; - return warning ? describeError(warning) : null; + return snapshot?.sync?.attention.includes("storage_may_be_evicted") ? describeError("storage_may_be_evicted") : null; } export function CollectionSaveWarning({ mdbase }: { readonly mdbase?: Mdbase }): JSX.Element | null { diff --git a/packages/app-ui/src/collections/components.test.tsx b/packages/app-ui/src/collections/components.test.tsx index 96b90553e..4183ec828 100644 --- a/packages/app-ui/src/collections/components.test.tsx +++ b/packages/app-ui/src/collections/components.test.tsx @@ -53,7 +53,6 @@ function fakeMdbase(phase: MdbasePhase, extra: Partial = {}) { subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); }, signIn: vi.fn(pending), addCollection: vi.fn((_options?: { readonly signal?: AbortSignal }) => pending()), - addPrivateCollection: vi.fn(pending), approve: vi.fn(pending), select: vi.fn(pending), chooseAnother: vi.fn(pending), @@ -278,7 +277,6 @@ describe("SDK storage and Private states", () => { adds[0]!.click(); expect(mdbase.addCollection).toHaveBeenCalledExactlyOnceWith({ signal: expect.any(AbortSignal) }); }); - expect(mdbase.addPrivateCollection).not.toHaveBeenCalled(); }); it("still aborts a legacy tracked private add without claiming it settled early", async () => { @@ -324,7 +322,6 @@ describe("SDK storage and Private states", () => { expect(signal.aborted).toBe(false); act(() => button("Cancel add").click()); expect(signal.aborted).toBe(true); - expect(mdbase.addPrivateCollection).not.toHaveBeenCalled(); }); it("uses normal Add in the gesture and renders the SDK computer-approval state", async () => { @@ -333,7 +330,6 @@ describe("SDK storage and Private states", () => { act(() => { button("Add a collection…").click(); expect(mdbase.addCollection).toHaveBeenCalledExactlyOnceWith({ signal: expect.any(AbortSignal) }); - expect(mdbase.addPrivateCollection).not.toHaveBeenCalled(); }); const problem = describeError("device_approval_required")!; act(() => set({ phase: { kind: "problem", collectionId: A, problem } })); @@ -349,21 +345,19 @@ describe("SDK storage and Private states", () => { act(() => button("Add a collection…").click()); expect(before).toHaveBeenCalledWith("add"); expect(mdbase.addCollection).not.toHaveBeenCalled(); - expect(mdbase.addPrivateCollection).not.toHaveBeenCalled(); render(); act(() => switcherTrigger("Alpha").click()); act(() => button("Add a collection…").click()); expect(mdbase.addCollection).toHaveBeenCalledExactlyOnceWith({ signal: expect.any(AbortSignal) }); - expect(mdbase.addPrivateCollection).not.toHaveBeenCalled(); }); it("shows only the SDK save warning and removes it on confirmation", () => { - const { entry, set } = fakeMdbase({ kind: "ready", collectionId: A, collection: { collectionId: A, displayName: "Alpha", role: "owner" }, connection: {} as never, rechecking: false, problem: null, unavailableTypes: [] }, { saveWarning: null }); + const { entry, set } = fakeMdbase({ kind: "ready", collectionId: A, collection: { collectionId: A, displayName: "Alpha", role: "owner" }, connection: {} as never, rechecking: false, problem: null, unavailableTypes: [] }, { sync: { attention: [] } as never }); render({() => }); expect(container.querySelector(".mdbase-collections-save-warning")).toBeNull(); - act(() => set({ saveWarning: "storage_may_be_evicted" })); + act(() => set({ sync: { attention: ["storage_may_be_evicted"] } as never })); expect(container.querySelector(".mdbase-collections-save-warning")?.textContent).toBe(describeError("storage_may_be_evicted")?.message); - act(() => set({ saveWarning: null })); + act(() => set({ sync: { attention: [] } as never })); expect(container.querySelector(".mdbase-collections-save-warning")).toBeNull(); }); diff --git a/packages/app-ui/vendor/mdbase-dev-sdk-0.0.0-b1c47a32.tgz b/packages/app-ui/vendor/mdbase-dev-sdk-0.0.0-b1c47a32.tgz deleted file mode 100644 index 0440e1bde..000000000 Binary files a/packages/app-ui/vendor/mdbase-dev-sdk-0.0.0-b1c47a32.tgz and /dev/null differ diff --git a/packages/app-ui/vendor/sdk-0.0.0-bd131b16.tgz b/packages/app-ui/vendor/sdk-0.0.0-bd131b16.tgz deleted file mode 100644 index c5e16153b..000000000 Binary files a/packages/app-ui/vendor/sdk-0.0.0-bd131b16.tgz and /dev/null differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95bc71386..1db5a3609 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,11 +205,11 @@ importers: specifier: workspace:* version: link:../../packages/i18n '@mdbase-dev/sdk': - specifier: file:vendor/sdk-19197d2c-production-beta173.tgz - version: file:apps/editor/vendor/sdk-19197d2c-production-beta173.tgz + specifier: file:vendor/sdk-e8ffb806-production-beta188.tgz + version: file:apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz '@mdbase-dev/ui': specifier: 0.1.0-beta.167 - version: 0.1.0-beta.167(@codemirror/commands@6.11.1)(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(@lezer/common@1.5.2)(@lingui/core@6.9.0(@babel/core@7.29.7(supports-color@10.2.2)))(@lingui/react@6.9.0(@babel/core@7.29.7(supports-color@10.2.2))(react@19.3.0))(@mdbase-dev/i18n@packages+i18n)(@mdbase-dev/sdk@file:apps/editor/vendor/sdk-19197d2c-production-beta173.tgz)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 0.1.0-beta.167(@codemirror/commands@6.11.1)(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(@lezer/common@1.5.2)(@lingui/core@6.9.0(@babel/core@7.29.7(supports-color@10.2.2)))(@lingui/react@6.9.0(@babel/core@7.29.7(supports-color@10.2.2))(react@19.3.0))(@mdbase-dev/i18n@packages+i18n)(@mdbase-dev/sdk@file:apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@mdbase/connect-ui': specifier: workspace:* version: link:../../packages/ui @@ -427,8 +427,8 @@ importers: specifier: workspace:* version: link:../i18n '@mdbase-dev/sdk': - specifier: 0.1.0-beta.160 - version: 0.1.0-beta.160 + specifier: 0.1.0-beta.188 + version: 0.1.0-beta.188 '@rolldown/plugin-babel': specifier: ^0.2.4 version: 0.2.4(@babel/core@7.29.7(supports-color@10.2.2))(@babel/runtime@7.29.7)(rolldown@1.2.14)(vite@8.3.4(@types/node@24.19.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.1)) @@ -696,8 +696,8 @@ importers: specifier: workspace:* version: link:../../packages/protocol '@mdbase-dev/sdk': - specifier: file:../../packages/app-ui/vendor/sdk-0.0.0-bd131b16.tgz - version: file:packages/app-ui/vendor/sdk-0.0.0-bd131b16.tgz + specifier: 0.1.0-beta.188 + version: 0.1.0-beta.188 '@modelcontextprotocol/sdk': specifier: ^1.31.0 version: 1.32.1(supports-color@10.2.2)(zod@4.6.6) @@ -2302,22 +2302,15 @@ packages: resolution: {integrity: sha512-uLeG6+yHiKmtpg5KY4XEa8kGx5mLg6pqLZ4H4+x0An7Q/bhfz5Hgh+P8Ngh6DWvGf6PIuJ3Smw8w6GmCK1gsvQ==, tarball: file:apps/editor/vendor/mdbase-dev-connect-protocol-0.1.0-beta.145-e6e66bcd172f.tgz} version: 0.1.0-beta.145 - '@mdbase-dev/sdk@0.1.0-beta.160': - resolution: {integrity: sha512-0rxrXlGKIToexkB7J8qwDXYMGjXxPNCm87Qm3G68q4R5Do2b7eshjprkaHTRaAGuWv5hZigcbQaBZowBJQSWVg==} + '@mdbase-dev/sdk@0.1.0-beta.188': + resolution: {integrity: sha512-EDaInyP8q2Lz0Akz8P+CNj5O6OvinMJWR1bE04fKtWRFDt9mVRBRBYRja5okOtb3gtnKYyMdQFGLlFln7F1RrA==} engines: {node: '>=20'} bundledDependencies: - '@mdbase-dev/connect-protocol' - '@mdbase-dev/sdk@file:apps/editor/vendor/sdk-19197d2c-production-beta173.tgz': - resolution: {integrity: sha512-fP2/jO21hgly84qeXYfFUrIu+PRKi8tsJ80ezAjnhWbQSsXQWuTsT5myfiNiSDAp0ohJLbT3O7t1R+HenkPPLw==, tarball: file:apps/editor/vendor/sdk-19197d2c-production-beta173.tgz} - version: 0.1.0-beta.173 - engines: {node: '>=20'} - bundledDependencies: - - '@mdbase-dev/connect-protocol' - - '@mdbase-dev/sdk@file:packages/app-ui/vendor/sdk-0.0.0-bd131b16.tgz': - resolution: {integrity: sha512-2ym7ZaLeolSPKYUdZtzaHtNd0VcVubPMjnrQKrkcwjjI2Gn+12i98Mp2zwy6+Y9NUKGCs3tAElFStj/NpbIBJg==, tarball: file:packages/app-ui/vendor/sdk-0.0.0-bd131b16.tgz} - version: 0.0.0-bd131b16 + '@mdbase-dev/sdk@file:apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz': + resolution: {integrity: sha512-EDaInyP8q2Lz0Akz8P+CNj5O6OvinMJWR1bE04fKtWRFDt9mVRBRBYRja5okOtb3gtnKYyMdQFGLlFln7F1RrA==, tarball: file:apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz} + version: 0.1.0-beta.188 engines: {node: '>=20'} bundledDependencies: - '@mdbase-dev/connect-protocol' @@ -8155,25 +8148,19 @@ snapshots: '@noble/hashes': 2.4.0 yaml: 2.9.1 - '@mdbase-dev/sdk@0.1.0-beta.160': - dependencies: - '@noble/ciphers': 2.4.0 - '@noble/curves': 2.4.0 - '@noble/hashes': 2.4.0 - - '@mdbase-dev/sdk@file:apps/editor/vendor/sdk-19197d2c-production-beta173.tgz': + '@mdbase-dev/sdk@0.1.0-beta.188': dependencies: '@noble/ciphers': 2.4.0 '@noble/curves': 2.4.0 '@noble/hashes': 2.4.0 - '@mdbase-dev/sdk@file:packages/app-ui/vendor/sdk-0.0.0-bd131b16.tgz': + '@mdbase-dev/sdk@file:apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz': dependencies: '@noble/ciphers': 2.4.0 '@noble/curves': 2.4.0 '@noble/hashes': 2.4.0 - '@mdbase-dev/ui@0.1.0-beta.167(@codemirror/commands@6.11.1)(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(@lezer/common@1.5.2)(@lingui/core@6.9.0(@babel/core@7.29.7(supports-color@10.2.2)))(@lingui/react@6.9.0(@babel/core@7.29.7(supports-color@10.2.2))(react@19.3.0))(@mdbase-dev/i18n@packages+i18n)(@mdbase-dev/sdk@file:apps/editor/vendor/sdk-19197d2c-production-beta173.tgz)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@mdbase-dev/ui@0.1.0-beta.167(@codemirror/commands@6.11.1)(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(@lezer/common@1.5.2)(@lingui/core@6.9.0(@babel/core@7.29.7(supports-color@10.2.2)))(@lingui/react@6.9.0(@babel/core@7.29.7(supports-color@10.2.2))(react@19.3.0))(@mdbase-dev/i18n@packages+i18n)(@mdbase-dev/sdk@file:apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@fontsource/azeret-mono': 5.3.0 '@fontsource/ibm-plex-sans': 5.3.0 @@ -8187,7 +8174,7 @@ snapshots: '@codemirror/state': 6.7.5 '@codemirror/view': 6.43.12 '@lezer/common': 1.5.2 - '@mdbase-dev/sdk': file:apps/editor/vendor/sdk-19197d2c-production-beta173.tgz + '@mdbase-dev/sdk': file:apps/editor/vendor/sdk-e8ffb806-production-beta188.tgz react-dom: 19.3.0(react@19.3.0) '@messageformat/date-skeleton@1.1.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1079a5ada..b32bf814a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -72,7 +72,7 @@ auditConfig: minimumReleaseAgeExclude: - hono@4.13.7 - '@mdbase-dev/ui@0.1.0-beta.167' - - '@mdbase-dev/sdk@0.1.0-beta.160' + - '@mdbase-dev/sdk@0.1.0-beta.188' patchedDependencies: '@sigstore/core@3.2.1': patches/@sigstore__core@3.2.1.patch diff --git a/services/mcp/package.json b/services/mcp/package.json index 1936d6e5a..e4d81aa9d 100644 --- a/services/mcp/package.json +++ b/services/mcp/package.json @@ -18,7 +18,7 @@ "@fastify/rate-limit": "^11.2.0", "@mdbase-dev/connect": "workspace:*", "@mdbase-dev/connect-protocol": "workspace:*", - "@mdbase-dev/sdk": "file:../../packages/app-ui/vendor/sdk-0.0.0-bd131b16.tgz", + "@mdbase-dev/sdk": "0.1.0-beta.188", "@modelcontextprotocol/sdk": "^1.31.0", "fastify": "^5.12.5", "pg": "^8.23.0", diff --git a/services/mcp/src/next-connector.test.ts b/services/mcp/src/next-connector.test.ts index 16708c57d..85da40657 100644 --- a/services/mcp/src/next-connector.test.ts +++ b/services/mcp/src/next-connector.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { clientPrologue, connect, generateKeyPair, routeFromControl, type MessageCarrier, type WebSocketLike } from "@mdbase-dev/sdk"; +import { clientPrologue, connect, generateKeyPair, routeFromControl, type MessageCarrier, type WebSocketLike } from "@mdbase-dev/sdk/thin-client"; import { MemoryReplica, serveNoise } from "@mdbase-dev/sdk/testing"; import { gatewayConnector } from "./next-connector.js"; @@ -123,7 +123,7 @@ describe("gatewayConnector", () => { expect(hex(sent[0] as Uint8Array)).toBe(hex(prologue)); const write = await client.create({ path: "a.md", body: "hosted" }); await write.confirmed; - expect((await client.get({ path: "a.md" }, { body: true })).body).toBe("hosted"); + expect((await client.get({ path: "a.md" }, { include: { body: true } })).body).toBe("hosted"); client.close(); }); @@ -154,7 +154,7 @@ describe("gatewayConnector", () => { expect(piped.some((message) => hex(message) === hex(prologue))).toBe(false); const write = await client.create({ path: "a.md", body: "device" }); await write.confirmed; - expect((await client.get({ path: "a.md" }, { body: true })).body).toBe("device"); + expect((await client.get({ path: "a.md" }, { include: { body: true } })).body).toBe("device"); client.close(); }); diff --git a/services/mcp/src/next-connector.ts b/services/mcp/src/next-connector.ts index c8fb976c3..be35dd6ce 100644 --- a/services/mcp/src/next-connector.ts +++ b/services/mcp/src/next-connector.ts @@ -10,7 +10,7 @@ import { clientPrologue, mdbaseError, noiseConnector, relayPipeCarrier, webSocketCarrier, type Connector, type KeyPair, type RelayRoute, type StaticKey, type WebSocketFactory -} from "@mdbase-dev/sdk"; +} from "@mdbase-dev/sdk/thin-client"; export interface GatewayConnectorOptions { collection: string; diff --git a/services/mcp/src/next-gateway.test.ts b/services/mcp/src/next-gateway.test.ts index da4769293..414a070ea 100644 --- a/services/mcp/src/next-gateway.test.ts +++ b/services/mcp/src/next-gateway.test.ts @@ -1,6 +1,6 @@ import { createPublicKey, verify } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; -import { keyPairFromSecret, mdbaseError, type KeyPair, type MdbaseClient } from "@mdbase-dev/sdk"; +import { keyPairFromSecret, mdbaseError, type KeyPair, type MdbaseClient } from "@mdbase-dev/sdk/thin-client"; import type { GrantKeyRecord, GrantKeyStore } from "@mdbase-dev/connect/crypto"; import { LEGACY, NextGateway, type NextConnection } from "./next-gateway.js"; import { clientKeyAttachMessage, clientNoiseKeyMessage, type NoiseKeyStore } from "./next-keys.js"; @@ -109,7 +109,7 @@ describe("NextGateway", () => { await expect(setup.gateway.run(native, "access", "create", { path: "notes/a.md", body: "Body" }, "req-1")).resolves.toMatchObject({ path: "notes/a.md", revision: "rev1" }); const id = (create.mock.calls[0] as unknown as [{ id: string }])[0].id; expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-/u); - expect(find).toHaveBeenCalledWith(id, { effective: true, body: true }, undefined); + expect(find).toHaveBeenCalledWith(id, { include: { effective: true, body: true }, signal: undefined }); setup.gateway.close(); }); @@ -118,7 +118,7 @@ describe("NextGateway", () => { const find = vi.fn(async () => record); const setup = await writing({ update, find }); await expect(setup.gateway.run(native, "access", "update", { path: "notes/a.md", body: "Body" }, "req-1")).resolves.toMatchObject({ path: "notes/a.md" }); - expect(find).toHaveBeenLastCalledWith(record.id, { effective: true, body: true }, undefined); + expect(find).toHaveBeenLastCalledWith(record.id, { include: { effective: true, body: true }, signal: undefined }); setup.gateway.close(); }); @@ -451,7 +451,7 @@ describe("NextGateway", () => { const [input, options] = first.mock.calls[0] as unknown as [{ id: string }, { mutationId: string }]; expect(second).toHaveBeenCalledWith(expect.objectContaining({ id: input.id }), expect.objectContaining({ mutationId: "req-1" })); expect(options.mutationId).toBe("req-1"); - expect(find).toHaveBeenCalledWith(input.id, { effective: true, body: true }, undefined); + expect(find).toHaveBeenCalledWith(input.id, { include: { effective: true, body: true }, signal: undefined }); setup.gateway.close(); }); @@ -460,7 +460,7 @@ describe("NextGateway", () => { const receipt = vi.fn(async () => ({ mutation: "req-1", state: "confirmed" })); const setup = await sessions([{ find: vi.fn(async () => record), delete: remove }, { find: vi.fn(async () => null), receipt }]); await expect(setup.gateway.run(native, "access", "delete", { path: "notes/a.md" }, "req-1")).resolves.toEqual({ path: "notes/a.md", deleted: true }); - expect(receipt).toHaveBeenCalledWith("req-1", undefined); + expect(receipt).toHaveBeenCalledWith("req-1", { signal: undefined }); setup.gateway.close(); }); @@ -469,7 +469,7 @@ describe("NextGateway", () => { const awaitReceipt = vi.fn(async () => ({ mutation: "req-1", state: "confirmed" })); const setup = await sessions([{ find: vi.fn(async () => null), receipt, awaitReceipt }]); await expect(setup.gateway.run(native, "access", "delete", { path: "notes/a.md" }, "req-1")).resolves.toEqual({ path: "notes/a.md", deleted: true }); - expect(awaitReceipt).toHaveBeenCalledWith("req-1", 30_000, undefined); + expect(awaitReceipt).toHaveBeenCalledWith("req-1", { timeoutMs: 30_000, signal: undefined }); setup.gateway.close(); }); @@ -506,7 +506,7 @@ describe("NextGateway", () => { { find, receipt: vi.fn(async () => ({ mutation: "req-1", state: "confirmed" })) } ]); await expect(setup.gateway.run(native, "access", "rename", { from: "notes/a.md", to: "notes/b" }, "req-1")).resolves.toMatchObject({ path: "notes/b.md", from: "notes/a.md", to: "notes/b.md" }); - expect(find).toHaveBeenLastCalledWith(record.id, { effective: true, body: true }, undefined); + expect(find).toHaveBeenLastCalledWith(record.id, { include: { effective: true, body: true }, signal: undefined }); setup.gateway.close(); }); diff --git a/services/mcp/src/next-gateway.ts b/services/mcp/src/next-gateway.ts index 0d78b0901..34e3a0e34 100644 --- a/services/mcp/src/next-gateway.ts +++ b/services/mcp/src/next-gateway.ts @@ -3,7 +3,8 @@ // the hosted replica (cloud copy) or a user device through the relay (Private), and // runs the tool there. Collections the control plane does not route stay on the // legacy authority path until it is removed. -import { connect, controlRouteResolver, describeError, isMdbaseError, routeFromControl, uuidv7, type KeyPair, type MdbaseClient, type RecordView, type Write } from "@mdbase-dev/sdk"; +import { connect, controlRouteResolver, isMdbaseError, routeFromControl, uuidv7, type KeyPair, type MdbaseClient, type RecordView, type Write } from "@mdbase-dev/sdk/thin-client"; +import { describeError } from "@mdbase-dev/sdk/describe"; import type { GrantKeyStore } from "@mdbase-dev/connect/crypto"; import { GatewayOperationError } from "./errors.js"; import { gatewayConnector } from "./next-connector.js"; @@ -342,12 +343,12 @@ async function execute(client: MdbaseClient, connection: NextConnection, operati const write = { mutationId: requestId, wait: "confirmed" as const, include: { effective: true, body: true }, ...(signal ? { signal } : {}) }; switch (operation) { case "describe": { - const [description, cursor] = await Promise.all([client.describe({ definitions: true, ...(signal ? { signal } : {}) }), client.changes(undefined, 1, signal)]); + const [description, cursor] = await Promise.all([client.describe({ definitions: true, ...(signal ? { signal } : {}) }), client.changes({ limit: 1, signal })]); return collectionDescription({ collectionId: connection.collection_id, displayName: connection.display_name, operations: connection.operations, changeCursor: cursor.cursor }, description); } case "changes": { const { cursor, limit } = changesInput(input as { after?: number | string; limit?: number }); - return changesOutput(await client.changes(cursor, limit, signal), limit); + return changesOutput(await client.changes({ cursor, limit, signal }), limit); } case "query": { if (typeof input.release_cursor === "string") return { released: true }; @@ -362,15 +363,15 @@ async function execute(client: MdbaseClient, connection: NextConnection, operati ]); return contractQueryOutput(plan, result, implementationDigests(plan.contract, description)); } - return queryOutput(plan, await client.query(plan.query as Parameters[0], plan.include, signal)); + return queryOutput(plan, await client.query(plan.query as Parameters[0], { include: plan.include, signal })); } case "read": return recordDocument(await read(client, String(input.path), signal)); case "validate": - return validationOutput(await client.validate(undefined, signal)); + return validationOutput(await client.validate({ signal })); case "read_type": { const type = await findType(client, input, signal); - return typeDocument(type.name, await client.resources.get(type.path, signal)); + return typeDocument(type.name, await client.resources.get(type.path, { signal })); } case "create": { const id = memo.createId; @@ -380,20 +381,20 @@ async function execute(client: MdbaseClient, connection: NextConnection, operati ...(typeof input.type === "string" ? { type: input.type } : {}), ...(input.frontmatter ? { frontmatter: input.frontmatter as Record } : {}), ...(typeof input.body === "string" ? { body: input.body } : {}) - }, write), () => client.find(id, RECORD_VIEW, signal)); + }, write), () => client.find(id, { include: RECORD_VIEW, signal })); return recordDocument(created); } case "update": { const current = await read(client, String(input.path), signal, true); if (typeof input.document === "string") { if (input.patch !== undefined || input.body !== undefined) throw new GatewayOperationError("invalid_request", "document cannot be combined with patch or body."); - return { ...recordDocument(await settle(client.replaceDocument(current, input.document, { ...write, ...(typeof input.if_revision === "string" ? { ifRevision: input.if_revision } : {}) }), () => client.find(current.id, RECORD_VIEW, signal))) }; + return { ...recordDocument(await settle(client.replaceDocument(current, input.document, { ...write, ...(typeof input.if_revision === "string" ? { ifRevision: input.if_revision } : {}) }), () => client.find(current.id, { include: RECORD_VIEW, signal }))) }; } return recordDocument(await settle(client.update(current, { ...(input.patch ? { patch: input.patch as Record } : {}), ...(typeof input.body === "string" ? { body: input.body } : {}), ...(typeof input.if_revision === "string" ? { ifRevision: input.if_revision } : {}) - }, write), () => client.find(current.id, RECORD_VIEW, signal))); + }, write), () => client.find(current.id, { include: RECORD_VIEW, signal }))); } case "delete": { const current = await find(client, String(input.path), signal); @@ -412,31 +413,31 @@ async function execute(client: MdbaseClient, connection: NextConnection, operati // Run again after its answer was lost: the record is already at its new path. // By ID when an earlier attempt saw it; the replica may have normalized the path. const moved = !await confirmedBefore(client, requestId, signal) ? null - : memo.recordId ? await client.find(memo.recordId, RECORD_VIEW, signal) : await find(client, String(input.to), signal); + : memo.recordId ? await client.find(memo.recordId, { include: RECORD_VIEW, signal }) : await find(client, String(input.to), signal); if (!moved) throw notFound(String(input.from)); return { ...recordDocument(moved), from: String(input.from), to: moved.path }; } memo.recordId = current.id; const renamed = await settle(client.rename(current, String(input.to), { ...write, updateRefs: input.update_refs !== false, ...(typeof input.if_revision === "string" ? { ifRevision: input.if_revision } : {}) - }), () => client.find(current.id, RECORD_VIEW, signal)); + }), () => client.find(current.id, { include: RECORD_VIEW, signal })); return { ...recordDocument(renamed), from: current.path, to: renamed.path }; } case "create_type": { const document = String(input.document); if (typeof input.path === "string") { await settle(client.resources.put(input.path, document, { ...write, mustNotExist: true }), null); - const resource = await client.resources.get(input.path, signal); + const resource = await client.resources.get(input.path, { signal }); return typeDocument(input.path.replace(/^.*\//u, "").replace(/\.md$/u, ""), resource); } const created = await client.createType(document, write); await settle(Promise.resolve(created.write), null); - return typeDocument(created.name, await client.resources.get(created.path, signal)); + return typeDocument(created.name, await client.resources.get(created.path, { signal })); } case "update_type": { const type = await findType(client, input, signal); await settle(client.resources.put(type.path, String(input.document), { ...write, baseRevision: String(input.if_revision) }), null); - return typeDocument(type.name, await client.resources.get(type.path, signal)); + return typeDocument(type.name, await client.resources.get(type.path, { signal })); } default: throw new GatewayOperationError("unsupported_operation", `${operation} is not available for this collection.`); @@ -450,7 +451,7 @@ async function read(client: MdbaseClient, path: string, signal?: AbortSignal, do } function find(client: MdbaseClient, path: string, signal?: AbortSignal, document = false): Promise { - return client.find({ path }, { effective: true, body: true, ...(document ? { document: true } : {}) }, signal); + return client.find({ path }, { include: { effective: true, body: true, ...(document ? { document: true } : {}) }, signal }); } function notFound(path: string): GatewayOperationError { @@ -464,8 +465,8 @@ function notFound(path: string): GatewayOperationError { */ async function confirmedBefore(client: MdbaseClient, mutationId: string, signal?: AbortSignal): Promise { try { - let receipt = await client.receipt(mutationId, signal); - if (receipt.state === "pending") receipt = await client.awaitReceipt(mutationId, WRITE_TIMEOUT_MS, signal); + let receipt = await client.receipt(mutationId, { signal }); + if (receipt.state === "pending") receipt = await client.awaitReceipt(mutationId, { timeoutMs: WRITE_TIMEOUT_MS, signal }); if (receipt.state === "pending") { throw new GatewayOperationError("outcome_unknown", "The write was accepted but not yet confirmed. Retry with the same mutation_id to check."); } diff --git a/services/mcp/src/next-keys.ts b/services/mcp/src/next-keys.ts index 3641a8b14..26c1f878e 100644 --- a/services/mcp/src/next-keys.ts +++ b/services/mcp/src/next-keys.ts @@ -3,7 +3,7 @@ // `mcp-noise:`, existing columns), and is attested by the grant // signing key, at consent (`client_noise_key`) or by the one-time attach route. import { randomBytes } from "node:crypto"; -import { keyPairFromSecret, type KeyPair } from "@mdbase-dev/sdk"; +import { keyPairFromSecret, type KeyPair } from "@mdbase-dev/sdk/thin-client"; import type { GrantKeyRecord } from "@mdbase-dev/connect/crypto"; import type { DatabasePool } from "./db.js"; import { SecretBox } from "./security.js"; diff --git a/services/mcp/src/next-mapping.test.ts b/services/mcp/src/next-mapping.test.ts index 31f5400d8..d1109a2ef 100644 --- a/services/mcp/src/next-mapping.test.ts +++ b/services/mcp/src/next-mapping.test.ts @@ -1,6 +1,6 @@ // Golden mapping of every legacy query_records input onto the mdbase-next query. import { describe, expect, it } from "vitest"; -import type { RecordView } from "@mdbase-dev/sdk"; +import type { RecordView } from "@mdbase-dev/sdk/thin-client"; import { changesInput, changesOutput, contractQueryOutput, implementationDigests, nextQueryPlan, queryOutput, recordDocument } from "./next-mapping.js"; const effective = { effective: true, body: false }; @@ -49,13 +49,13 @@ describe("query_records input mapping (golden)", () => { const view = (over: Partial = {}): RecordView => ({ id: "0190aaaa-0000-7000-8000-000000000001", path: "notes/a.md", revision: "rev1", - frontmatter: new Map([["title", "Persisted" as never]]), - effective: new Map([["title", "Persisted" as never], ["status", "open" as never]]), + frontmatter: { title: "Persisted" }, + effective: { title: "Persisted", status: "open" }, body: "Body", types: ["note"], state: { pending: false } as never, mtime: Date.UTC(2026, 9, 1), ...over }); describe("query_records output mapping", () => { - const result = { records: [view({ values: new Map([["title", "Persisted" as never]]) })], cursor: "next", complete: true, asOf: 7, totalCount: 3, hasMore: true }; + const result = { records: [view({ values: { title: "Persisted" } })], cursor: "next", complete: true, asOf: 7, totalCount: 3, hasMore: true }; it("keeps the legacy row and meta shape per frontmatter_mode", () => { const plan = nextQueryPlan({ frontmatter_mode: "both" }) as Extract, { kind: "query" }>; expect(queryOutput(plan, result)).toEqual({ @@ -100,13 +100,13 @@ describe("contract query output", () => { it("names the contract and implementation digests of each valid projection", () => { const plan = nextQueryPlan({ contract: { id: "dev.tasks/task", version: "1.0.0" } }) as Extract, { kind: "contract" }>; const digests = implementationDigests(plan.contract, { types: [ - { name: "task", path: "_types/task.md", implements: [{ contract: "dev.tasks/task", version: "1.0.0", fields: new Map(), digest: "impl-digest" }] }, + { name: "task", path: "_types/task.md", implements: [{ contract: "dev.tasks/task", version: "1.0.0", fields: {}, digest: "impl-digest" }] }, { name: "note", path: "_types/note.md", implements: [] } ] } as never); const record = view({ types: ["task"] }); const output = contractQueryOutput(plan, { version: 1, contractDigest: "contract-digest", page: { records: [record], complete: true, asOf: 1 }, - records: [{ id: record.id, implementations: [{ typeName: "task", fields: new Map([["title", "Persisted" as never]]), issues: [] }] }] + records: [{ id: record.id, implementations: [{ typeName: "task", fields: { title: "Persisted" }, issues: [] }] }] } as never, digests) as { results: Array> }; expect(output.results[0]).toMatchObject({ path: "notes/a.md", frontmatter: { title: "Persisted" }, diff --git a/services/mcp/src/next-mapping.ts b/services/mcp/src/next-mapping.ts index 70371c929..3dc42511d 100644 --- a/services/mcp/src/next-mapping.ts +++ b/services/mcp/src/next-mapping.ts @@ -1,15 +1,13 @@ // MCP tool inputs/outputs <-> mdbase-next replica API. The tool surface (names, // input schemas, output shapes) stays the legacy authority's; this module is the // only place the two vocabularies meet. Pure functions: golden-tested per input. -import { toPlain, type ContractQueryResult, type Include, type QueryResult, type RecordView, type RecordValidation, type wire } from "@mdbase-dev/sdk"; +import type { ContractQueryResult, DescribeResult, Include, QueryResult, RecordView, RecordValidation, wire } from "@mdbase-dev/sdk/thin-client"; import { GatewayOperationError } from "./errors.js"; type ChangesResult = wire.ChangesResult; -type DescribeResult = wire.DescribeResult; type ResourceView = wire.ResourceView; type Json = Record; -const plain = (value: unknown) => toPlain(value as Parameters[0]); /** Spec 11 members the replica parses natively, passed through unchanged. */ const QUERY_PASSTHROUGH = [ @@ -73,8 +71,8 @@ function file(view: RecordView): Json { } function frontmatter(view: RecordView, mode: FrontmatterMode): Json { - const persisted = plain(view.frontmatter) as Json; - const effective = view.effective ? plain(view.effective) as Json : persisted; + const persisted: Json = view.frontmatter; + const effective: Json = view.effective ?? persisted; if (mode === "persisted") return { frontmatter: persisted }; if (mode === "effective") return { frontmatter: effective }; return { frontmatter: persisted, effective_frontmatter: effective }; @@ -86,16 +84,15 @@ function queryRecord(view: RecordView, mode: FrontmatterMode = "effective"): Jso path: view.path, revision: view.revision, ...frontmatter(view, mode), ...(view.body === undefined ? {} : { body: view.body }), types: view.types, file: file(view), - ...(view.values ? { values: plain(view.values) } : {}) + ...(view.values ? { values: view.values } : {}) }; } /** Legacy `RecordDocument` (read, create, update, rename). */ export function recordDocument(view: RecordView): Json { - const persisted = plain(view.frontmatter) as Json; return { path: view.path, revision: view.revision, types: view.types, - frontmatter: persisted, effective_frontmatter: view.effective ? plain(view.effective) : persisted, + frontmatter: view.frontmatter, effective_frontmatter: view.effective ?? view.frontmatter, ...(view.body === undefined ? {} : { body: view.body }), ...(view.document === undefined ? {} : { document: view.document }), file: file(view) @@ -108,7 +105,7 @@ function meta(result: QueryResult): Json { has_more: result.hasMore ?? result.cursor !== undefined, ...(result.cursor ? { cursor: result.cursor } : {}), ...(result.columns ? { columns: result.columns } : {}), - ...(result.groups ? { groups: result.groups.map((group) => ({ values: plain(group.values), count: group.count, ...(group.summaries ? { summaries: plain(group.summaries) } : {}) })) } : {}), + ...(result.groups ? { groups: result.groups.map((group) => ({ values: group.values, count: group.count, ...(group.summaries ? { summaries: group.summaries } : {}) })) } : {}), ...(result.complete ? {} : { complete: false }) }; } @@ -117,7 +114,7 @@ export function queryOutput(plan: Extract, res if (plan.output === "metadata") { return { output: "metadata", - results: result.records.map((view) => ({ path: view.path, types: view.types, revision: view.revision, values: view.values ? plain(view.values) : {} })), + results: result.records.map((view) => ({ path: view.path, types: view.types, revision: view.revision, values: view.values ?? {} })), meta: meta(result) }; } @@ -142,7 +139,7 @@ export function contractQueryOutput(plan: Extract ({ contract_type: contract.contractType, id: contract.id, version: contract.version, digest: contract.digest, - schema: contract.schemas?.get("record_schema") === undefined ? {} : plain(contract.schemas.get("record_schema")), + schema: contract.schemas?.record_schema === undefined ? {} : contract.schemas.record_schema, implementations: result.types.flatMap((type) => type.implements .filter((implementation) => implementation.contract === contract.id && implementation.version === contract.version) .map((implementation) => ({ type_name: type.name, type_version: type.version ?? 1, type_path: type.path, - ...(implementation.digest ? { digest: implementation.digest } : {}), fields: Object.fromEntries(implementation.fields) + ...(implementation.digest ? { digest: implementation.digest } : {}), fields: implementation.fields }))) })), - ...(result.settings === null || result.settings === undefined ? {} : { configuration: plain(result.settings) }) + ...(result.settings === null || result.settings === undefined ? {} : { configuration: result.settings }) }; }