diff --git a/src/bindings/portable-client.ts b/src/bindings/portable-client.ts index e6ee821..1f9ac00 100644 --- a/src/bindings/portable-client.ts +++ b/src/bindings/portable-client.ts @@ -53,29 +53,68 @@ export function generatePortableClientBindings( } const queries = new Map(); const views = new Map(); + const serverProjections = new Map(); const results = new Map(); for (const query of plan.definition.queries) { const declaration = query.query.checked.declaration; - if (!declaration.placement) continue; const pkg = workspace.packageImports.find((p) => p.revisionId === query.query.packageRevisionId)!; const alias = JSON.stringify([pkg.displayName, declaration.displayName]); if (seen.has(`query:${alias}`)) throw new PortableBindingError(alias, "Ambiguous query alias"); seen.add(`query:${alias}`); - if (declaration.placement.kind === "local") { - const list = views.get(pkg.displayName) ?? []; - list.push( - `${JSON.stringify(declaration.displayName)}: ${JSON.stringify({ id: query.id, plan: plan.digest, source: JSON.stringify([query.query.packageRevisionId, declaration.placement.sourceQueryId]) })} as const satisfies LocalQueryDescriptor`, - ); - views.set(pkg.displayName, list); + const addResult = () => { if (conditional(query.query.checked.selection)) { unavailable.push({ key: query.id, reason: "CONDITIONAL_RESULT_DECODER_UNSUPPORTED" }); - continue; + return; } const decoders = results.get(pkg.displayName) ?? []; decoders.push( `${JSON.stringify(declaration.displayName)}: (data: Json): ${tsType(query.query.checked.output)} => decodeArgument(${JSON.stringify(portableValueSchema(query.query.checked.output))}, { codec: "camino-value-v1", usage: "argument", value: data }, { workspace: workspaceId(${JSON.stringify(workspace.workspaceId)}), limits: { bytes: ${declaration.budgets.resultBytes + 128}, nodes: 100000, depth: 64 } }) as ${tsType(query.query.checked.output)}`, ); results.set(pkg.displayName, decoders); + }; + if (!declaration.placement) { + const operations = query.operations.map( + (key) => plan.definition.operations.find((operation) => operation.key === key)!, + ); + const live = (selections: readonly QuerySelection[]): boolean => + selections.some((selection) => !!selection.liveSelectionId || live(selection.selection)); + if ( + operations.some( + (operation) => operation.execution.kind !== "snapshot" || !operation.execution.targets.includes("server"), + ) || + live(query.query.checked.selection) + ) { + unavailable.push({ key: query.id, reason: "QUERY_NOT_SNAPSHOT_PROJECTION" }); + continue; + } + const list = serverProjections.get(pkg.displayName) ?? []; + list.push( + JSON.stringify(declaration.displayName) + + ": " + + JSON.stringify({ + kind: "server-projection", + id: query.id, + plan: plan.digest, + root: declaration.root.interface, + rootKind: declaration.root.kind, + }) + + " as const satisfies ServerProjection<" + + JSON.stringify(declaration.root.interface) + + ", " + + JSON.stringify(declaration.root.kind) + + ">", + ); + serverProjections.set(pkg.displayName, list); + addResult(); + continue; + } + if (declaration.placement.kind === "local") { + const list = views.get(pkg.displayName) ?? []; + list.push( + `${JSON.stringify(declaration.displayName)}: ${JSON.stringify({ id: query.id, plan: plan.digest, source: JSON.stringify([query.query.packageRevisionId, declaration.placement.sourceQueryId]) })} as const satisfies LocalQueryDescriptor`, + ); + views.set(pkg.displayName, list); + addResult(); continue; } const list = queries.get(pkg.displayName) ?? []; @@ -86,5 +125,5 @@ export function generatePortableClientBindings( } const groups = (entries: Map) => [...entries].map(([name, values]) => `${JSON.stringify(name)}: {${values.join(",\n")}}`).join(",\n"); - return `// Generated application descriptors. Regenerate; do not edit.\nimport { defineOperationDescriptor, decodeArgument, workspaceId, type Json, type PreparedChange, type ManagedReference, type ReplicaProjection, type LocalQueryDescriptor } from ${JSON.stringify(options.runtimeModule ?? "@quixos/camino-replica-core")};\nexport const planDigest = ${JSON.stringify(plan.digest)};\nexport const operations = {${groups(atoms)}} as const;\nexport const queries = {${groups(queries)}} as const;\nexport const views = {${groups(views)}} as const;\nexport const results = {${groups(results)}} as const;\nexport const unavailable = ${JSON.stringify(unavailable)} as const;\n`; + return `// Generated application descriptors. Regenerate; do not edit.\nimport { defineOperationDescriptor, decodeArgument, workspaceId, type Json, type PreparedChange, type ManagedReference, type ReplicaProjection, type ServerProjection, type LocalQueryDescriptor } from ${JSON.stringify(options.runtimeModule ?? "@quixos/camino-replica-core")};\nexport const planDigest = ${JSON.stringify(plan.digest)};\nexport const operations = {${groups(atoms)}} as const;\nexport const queries = {${groups(queries)}} as const;\nexport const views = {${groups(views)}} as const;\nexport const serverProjections = {${groups(serverProjections)}} as const;\nexport const results = {${groups(results)}} as const;\nexport const unavailable = ${JSON.stringify(unavailable)} as const;\n`; } diff --git a/test/fixtures/local-first-reader/ReaderRuntime.qx b/test/fixtures/local-first-reader/ReaderRuntime.qx index f31385b..7f86b54 100644 --- a/test/fixtures/local-first-reader/ReaderRuntime.qx +++ b/test/fixtures/local-first-reader/ReaderRuntime.qx @@ -82,4 +82,5 @@ revision "package:reader-runtime@1" { query HighlightWindowList id "reader:highlight-window-list" root workspace HighlightData document "queries/highlight-list.graphql" operation "HighlightList" { view Comment as CommentData; local from HighlightWindow; max rows 200; watch; } query HighlightEditorInputs id "reader:editor-inputs" root HighlightData document "queries/editor-inputs.graphql" operation "HighlightEditorInputs" { view Comment as CommentData; replica; max rows 1; } query HighlightEditorView id "reader:editor-view" root HighlightData document "queries/editor-view.graphql" operation "HighlightEditorView" { view Comment as CommentData; local from HighlightEditorInputs; max rows 1; watch; } + query ConfirmedColor id "reader:confirmed-color" root HighlightData document "queries/confirmed-color.graphql" operation "ConfirmedColor" { view Comment as CommentData; max rows 1; watch; } } diff --git a/test/fixtures/local-first-reader/queries/confirmed-color.graphql b/test/fixtures/local-first-reader/queries/confirmed-color.graphql new file mode 100644 index 0000000..44f7cb2 --- /dev/null +++ b/test/fixtures/local-first-reader/queries/confirmed-color.graphql @@ -0,0 +1,3 @@ +query ConfirmedColor { + root { color } +} diff --git a/test/local-first-reader.test.ts b/test/local-first-reader.test.ts index 29b5b65..65beb16 100644 --- a/test/local-first-reader.test.ts +++ b/test/local-first-reader.test.ts @@ -8,7 +8,7 @@ test("complete reader contracts compose factories, portable selectors, CRDT edit assert.equal(workspace.constructors.length, 0); assert.equal(workspace.atoms.length, 3); const linked = linkQueries(workspace); - assert.equal(linked.length, 8); + assert.equal(linked.length, 9); assert.equal(linked.filter((q) => q.checked.declaration.placement?.kind === "local").length, 4); const screen = linked.find((q) => q.checked.declaration.displayName === "ReaderScreen")!; assert.ok(screen.fields.some((f) => f.memberId === "highlight:excerpt" && f.binding.kind === "package")); diff --git a/test/portable-bindings.test.ts b/test/portable-bindings.test.ts index 4373227..d2d4929 100644 --- a/test/portable-bindings.test.ts +++ b/test/portable-bindings.test.ts @@ -79,3 +79,27 @@ test("a portable subset of a mixed interface excludes remote dependencies", asyn selected.operations!.push(operation.id); assert.throws(generate, /select a portable subset/); }); + +test("server projection descriptors are distinct from canonical inputs and have result decoders", async () => { + const { generatePortableClientBindings } = await import("../src/bindings/portable-client.js"); + const plan = compileExecutionPlan(await loadReaderWorkspace()); + const source = generatePortableClientBindings(plan, { target: "browser" }); + const projections = source.slice( + source.indexOf("export const serverProjections"), + source.indexOf("export const results"), + ); + assert.match(projections, /ConfirmedColor/); + assert.match(projections, /satisfies ServerProjection/); + assert.match(source.slice(source.indexOf("export const results")), /ConfirmedColor/); + const live = compileExecutionPlan( + await loadReaderWorkspace((name, text) => + name === "queries/confirmed-color.graphql" ? text.replace("color", "color @live") : text, + ), + ); + const rejected = generatePortableClientBindings(live, { target: "browser" }); + assert.doesNotMatch( + rejected.slice(rejected.indexOf("export const serverProjections"), rejected.indexOf("export const results")), + /ConfirmedColor/, + ); + assert.match(rejected, /QUERY_NOT_SNAPSHOT_PROJECTION/); +});