diff --git a/dist/bindings.d.ts b/dist/bindings.d.ts index 3520763..7168137 100644 --- a/dist/bindings.d.ts +++ b/dist/bindings.d.ts @@ -35,6 +35,9 @@ export type QxValueType = { } | { kind: "message"; descriptorId: string; +} | { + kind: "record"; + fields: Record; } | { kind: "object-ref"; expectation: unknown; diff --git a/dist/bindings.d.ts.map b/dist/bindings.d.ts.map index ffb6391..aaa2a9a 100644 --- a/dist/bindings.d.ts.map +++ b/dist/bindings.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"bindings.d.ts","sourceRoot":"","sources":["../src/bindings.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"bindings.d.ts","sourceRoot":"","sources":["../src/bindings.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/bindings.js b/dist/bindings.js index eec1164..238f138 100644 --- a/dist/bindings.js +++ b/dist/bindings.js @@ -7,8 +7,18 @@ export const qxDerived = (get) => ({ kind: "derived", get }); export const decodeQxValue = (type, value, messages) => { if (type.kind === "builtin" && type.name === "unit") return null; + if (type.kind === "optional" && !value) + return null; if (!value) throw new Error("Missing QX wire value"); + if (type.kind === "record") { + if (value.kind.case !== "objectValue") + throw new Error("Expected QX record"); + const fields = value.kind.value.fields; + if (Object.keys(fields).some((name) => !Object.hasOwn(type.fields, name))) + throw new Error("Unexpected QX record field"); + return Object.fromEntries(Object.entries(type.fields).map(([name, field]) => [name, decodeQxValue(field, fields[name], messages)])); + } if (type.kind === "optional") return value.kind.case === "nullValue" ? null : decodeQxValue(type.value, value, messages); if (type.kind === "list") { @@ -52,6 +62,18 @@ const requireMessage = (messages, id) => { export const encodeQxValue = (type, value, messages) => { if (type.kind === "builtin" && type.name === "unit") return jsToProtoValue(null); + if (type.kind === "record") { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Expected QX record"); + if (Object.keys(value).some((name) => !Object.hasOwn(type.fields, name))) + throw new Error("Unexpected QX record field"); + const fields = Object.fromEntries(Object.entries(type.fields).map(([name, field]) => { + if (!Object.hasOwn(value, name) && field.kind !== "optional") + throw new Error(`Missing QX record field ${name}`); + return [name, encodeQxValue(field, value[name] ?? (field.kind === "optional" ? null : value[name]), messages)]; + })); + return create(ValueSchema, { kind: { case: "objectValue", value: create(ObjectValueSchema, { fields }) } }); + } if (type.kind === "optional") return value === null ? jsToProtoValue(null) : encodeQxValue(type.value, value, messages); if (type.kind === "list") @@ -69,7 +91,7 @@ export const encodeQxValue = (type, value, messages) => { return jsToProtoValue(value); }; const inputValue = (context, type) => { - if (type.kind === "message") + if (type.kind === "message" || type.kind === "record") return create(ValueSchema, { kind: { case: "objectValue", value: create(ObjectValueSchema, { fields: context.inputProto }) } }); return context.inputProto.value; @@ -78,7 +100,7 @@ const inputFields = (type, value, messages) => { if (type.kind === "builtin" && type.name === "unit") return {}; const encoded = encodeQxValue(type, value, messages); - if (type.kind === "message") { + if (type.kind === "message" || type.kind === "record") { if (encoded.kind.case !== "objectValue") throw new Error("Message inputs must encode an object value"); return Object.fromEntries(Object.entries(encoded.kind.value.fields).map(([key, entry]) => [key, liveValue(entry)])); diff --git a/src/bindings.ts b/src/bindings.ts index 96a063f..cb5f67b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -20,6 +20,7 @@ export type QxValueType = | { kind: "builtin"; name: "unit" | "watch-handle" } | { kind: "scalar"; name: string } | { kind: "message"; descriptorId: string } + | { kind: "record"; fields: Record } | { kind: "object-ref"; expectation: unknown } | { kind: "optional" | "list"; value: QxValueType }; export type QxOperationSpec = { id: string; inputType: QxValueType; outputType: QxValueType }; @@ -37,7 +38,14 @@ export type QxMessages = Record>; // Conversion belongs at the binding boundary. It does not add orchestrator validation. export const decodeQxValue = (type: QxValueType, value: Value | undefined, messages: QxMessages): any => { if (type.kind === "builtin" && type.name === "unit") return null; + if (type.kind === "optional" && !value) return null; if (!value) throw new Error("Missing QX wire value"); + if (type.kind === "record") { + if (value.kind.case !== "objectValue") throw new Error("Expected QX record"); + const fields = value.kind.value.fields; + if (Object.keys(fields).some((name) => !Object.hasOwn(type.fields, name))) throw new Error("Unexpected QX record field"); + return Object.fromEntries(Object.entries(type.fields).map(([name, field]) => [name, decodeQxValue(field, fields[name], messages)])); + } if (type.kind === "optional") return value.kind.case === "nullValue" ? null : decodeQxValue(type.value, value, messages); if (type.kind === "list") { if (value.kind.case !== "listValue") throw new Error("Expected QX list"); @@ -74,6 +82,15 @@ const requireMessage = (messages: QxMessages, id: string) => { }; export const encodeQxValue = (type: QxValueType, value: any, messages: QxMessages): Value => { if (type.kind === "builtin" && type.name === "unit") return jsToProtoValue(null); + if (type.kind === "record") { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected QX record"); + if (Object.keys(value).some((name) => !Object.hasOwn(type.fields, name))) throw new Error("Unexpected QX record field"); + const fields = Object.fromEntries(Object.entries(type.fields).map(([name, field]) => { + if (!Object.hasOwn(value, name) && field.kind !== "optional") throw new Error(`Missing QX record field ${name}`); + return [name, encodeQxValue(field, value[name] ?? (field.kind === "optional" ? null : value[name]), messages)]; + })); + return create(ValueSchema, {kind: {case: "objectValue", value: create(ObjectValueSchema, {fields})}}); + } if (type.kind === "optional") return value === null ? jsToProtoValue(null) : encodeQxValue(type.value, value, messages); if (type.kind === "list") return jsToProtoValue(value.map((entry: unknown) => liveValue(encodeQxValue(type.value, entry, messages)))); if (type.kind === "object-ref") { referenceToWire(value); return jsToProtoValue(value); } @@ -87,14 +104,14 @@ export const encodeQxValue = (type: QxValueType, value: any, messages: QxMessage }; const inputValue = (context: RuntimeContext, type: QxValueType) => { - if (type.kind === "message") return create(ValueSchema, { kind: { case: "objectValue", + if (type.kind === "message" || type.kind === "record") return create(ValueSchema, { kind: { case: "objectValue", value: create(ObjectValueSchema, { fields: context.inputProto }) } }); return context.inputProto.value; }; const inputFields = (type: QxValueType, value: unknown, messages: QxMessages): Record => { if (type.kind === "builtin" && type.name === "unit") return {}; const encoded = encodeQxValue(type, value, messages); - if (type.kind === "message") { + if (type.kind === "message" || type.kind === "record") { if (encoded.kind.case !== "objectValue") throw new Error("Message inputs must encode an object value"); return Object.fromEntries(Object.entries(encoded.kind.value.fields).map(([key, entry]) => [key, liveValue(entry)])); } diff --git a/test/bindings.test.mjs b/test/bindings.test.mjs index 953fdd8..3e0c8e7 100644 --- a/test/bindings.test.mjs +++ b/test/bindings.test.mjs @@ -4,6 +4,27 @@ import { referenceFromWire } from "../dist/references.js"; import { bindQxHandler, decodeQxValue, encodeQxValue, jsToProtoValue, liveValue, protoValueToJs } from "../dist/index.js"; const scalar = (name) => ({ kind: "scalar", name }); const unit = { kind: "builtin", name: "unit" }; +test("declared record inputs preserve opaque references without opening message codecs", async () => { + const target = referenceFromWire("obj:board"); + const type = {kind: "record", fields: { + target: {kind: "object-ref", expectation: {kind: "atom", atomId: "board"}}, + x: scalar("double"), note: {kind: "optional", value: scalar("string")}, + }}; + const encoded = encodeQxValue(type, {target, x: 12}, {}); + const decoded = decodeQxValue(type, encoded, {}); + assert.ok(decoded.target.equals(target)); + assert.equal(decoded.x, 12); + assert.equal(decoded.note, null); + assert.throws(() => JSON.stringify(decoded), /cannot be serialized/); + assert.throws(() => encodeQxValue(type, {target, x: 12, hidden: target}, {}), /Unexpected/); + assert.throws(() => encodeQxValue(type, {x: 12}, {}), /Missing/); + assert.throws(() => encodeQxValue(type, {target: "obj:board", x: 12}, {}), /opaque object reference/); + const handler = bindQxHandler({inputType: type, outputType: unit, ports: {}}, async (context) => { + assert.ok(context.input.target.equals(target)); + assert.equal(context.input.x, 12); + }, {}); + await handler({objectId: target, inputProto: encoded.kind.value.fields}); +}); test("typed sessions rebind ports and cancellation to each acquired invocation", async () => { const first = new AbortController(), second = new AbortController(); let closed = false;