Compare commits

..

8 Commits

12 changed files with 309 additions and 55 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"version": 1, "version": 1,
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos", "sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
"sourceCommit": "351b7d74d316380481d8fcf212e03d6ec777a2f3", "sourceCommit": "a9e94c961430415bcea302c47181d44ec7e2344c",
"sourcePath": "quixos-instance/packages/camino-package-runtime", "sourcePath": "quixos-instance/packages/camino-package-runtime",
"exportName": "camino-package-runtime", "exportName": "camino-package-runtime",
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/camino-package-runtime.git" "mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/camino-package-runtime.git"
+6 -1
View File
@@ -1,5 +1,5 @@
import { type Value } from "./camino/api_pb.js"; import { type Value } from "./camino/api_pb.js";
import { type RuntimeHandler, type DerivedHandler } from "./index.js"; import { liveValue, type RuntimeHandler, type DerivedHandler } from "./index.js";
export type { QxObjectRef } from "./references.js"; export type { QxObjectRef } from "./references.js";
declare const watchBrand: unique symbol; declare const watchBrand: unique symbol;
export type QxWatchHandle = string & { export type QxWatchHandle = string & {
@@ -9,6 +9,8 @@ export type MessageBinding<T> = {
encode(value: T): Value; encode(value: T): Value;
decode(value: Value): T; decode(value: Value): T;
}; };
export type QxLiveValue = ReturnType<typeof liveValue>;
export declare const opaqueReactPropsBinding: MessageBinding<Record<string, unknown>>;
export type BindingValue<B> = B extends MessageBinding<infer T> ? T : never; export type BindingValue<B> = B extends MessageBinding<infer T> ? T : never;
export type QxHandler<C, O> = (context: C) => O | Promise<O>; export type QxHandler<C, O> = (context: C) => O | Promise<O>;
export type QxDerived<C, O> = { export type QxDerived<C, O> = {
@@ -35,6 +37,9 @@ export type QxValueType = {
} | { } | {
kind: "message"; kind: "message";
descriptorId: string; descriptorId: string;
} | {
kind: "record";
fields: Record<string, QxValueType>;
} | { } | {
kind: "object-ref"; kind: "object-ref";
expectation: unknown; expectation: unknown;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"bindings.d.ts","sourceRoot":"","sources":["../src/bindings.ts"],"names":[],"mappings":"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"} {"version":3,"file":"bindings.d.ts","sourceRoot":"","sources":["../src/bindings.ts"],"names":[],"mappings":"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"}
+50 -5
View File
@@ -2,13 +2,38 @@ import { create } from "@bufbuild/protobuf";
import { ValueSchema, ObjectValueSchema } from "./camino/api_pb.js"; import { ValueSchema, ObjectValueSchema } from "./camino/api_pb.js";
import { derived, jsToProtoValue, liveValue, protoValueToJs } from "./index.js"; import { derived, jsToProtoValue, liveValue, protoValueToJs } from "./index.js";
import { assertReferenceFree, referenceToWire } from "./references.js"; import { assertReferenceFree, referenceToWire } from "./references.js";
const reactPropsDescriptor = "org.quixos.web-studio.ReactProps";
// Explicit temporary props exception, matching orch's RPC contract. Field shapes
// remain unchecked pending generics; ordinary messages and state do not gain it.
export const opaqueReactPropsBinding = {
encode(value) {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("React props must be an object");
return jsToProtoValue(value);
},
decode(value) {
if (value.kind.case !== "objectValue")
throw new Error("React props must be an object");
return protoValueToJs(value);
},
};
export const qxDerived = (get) => ({ kind: "derived", get }); export const qxDerived = (get) => ({ kind: "derived", get });
// Conversion belongs at the binding boundary. It does not add orchestrator validation. // Conversion belongs at the binding boundary. It does not add orchestrator validation.
export const decodeQxValue = (type, value, messages) => { export const decodeQxValue = (type, value, messages) => {
if (type.kind === "builtin" && type.name === "unit") if (type.kind === "builtin" && type.name === "unit")
return null; return null;
if (type.kind === "optional" && !value)
return null;
if (!value) if (!value)
throw new Error("Missing QX wire 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") if (type.kind === "optional")
return value.kind.case === "nullValue" ? null : decodeQxValue(type.value, value, messages); return value.kind.case === "nullValue" ? null : decodeQxValue(type.value, value, messages);
if (type.kind === "list") { if (type.kind === "list") {
@@ -17,8 +42,10 @@ export const decodeQxValue = (type, value, messages) => {
return value.kind.value.values.map((entry) => decodeQxValue(type.value, entry, messages)); return value.kind.value.values.map((entry) => decodeQxValue(type.value, entry, messages));
} }
if (type.kind === "message") { if (type.kind === "message") {
if (type.descriptorId !== reactPropsDescriptor)
assertReferenceFree(protoValueToJs(value)); assertReferenceFree(protoValueToJs(value));
const decoded = requireMessage(messages, type.descriptorId).decode(value); const decoded = requireMessage(messages, type.descriptorId).decode(value);
if (type.descriptorId !== reactPropsDescriptor)
assertReferenceFree(decoded); assertReferenceFree(decoded);
return decoded; return decoded;
} }
@@ -52,6 +79,18 @@ const requireMessage = (messages, id) => {
export const encodeQxValue = (type, value, messages) => { export const encodeQxValue = (type, value, messages) => {
if (type.kind === "builtin" && type.name === "unit") if (type.kind === "builtin" && type.name === "unit")
return jsToProtoValue(null); 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") if (type.kind === "optional")
return value === null ? jsToProtoValue(null) : encodeQxValue(type.value, value, messages); return value === null ? jsToProtoValue(null) : encodeQxValue(type.value, value, messages);
if (type.kind === "list") if (type.kind === "list")
@@ -60,16 +99,18 @@ export const encodeQxValue = (type, value, messages) => {
referenceToWire(value); referenceToWire(value);
return jsToProtoValue(value); return jsToProtoValue(value);
} }
if (type.kind !== "message" || type.descriptorId !== reactPropsDescriptor)
assertReferenceFree(value); assertReferenceFree(value);
if (type.kind === "message") { if (type.kind === "message") {
const encoded = requireMessage(messages, type.descriptorId).encode(value); const encoded = requireMessage(messages, type.descriptorId).encode(value);
if (type.descriptorId !== reactPropsDescriptor)
assertReferenceFree(protoValueToJs(encoded)); assertReferenceFree(protoValueToJs(encoded));
return encoded; return encoded;
} }
return jsToProtoValue(value); return jsToProtoValue(value);
}; };
const inputValue = (context, type) => { const inputValue = (context, type) => {
if (type.kind === "message") if (type.kind === "message" || type.kind === "record")
return create(ValueSchema, { kind: { case: "objectValue", return create(ValueSchema, { kind: { case: "objectValue",
value: create(ObjectValueSchema, { fields: context.inputProto }) } }); value: create(ObjectValueSchema, { fields: context.inputProto }) } });
return context.inputProto.value; return context.inputProto.value;
@@ -78,7 +119,7 @@ const inputFields = (type, value, messages) => {
if (type.kind === "builtin" && type.name === "unit") if (type.kind === "builtin" && type.name === "unit")
return {}; return {};
const encoded = encodeQxValue(type, value, messages); const encoded = encodeQxValue(type, value, messages);
if (type.kind === "message") { if (type.kind === "message" || type.kind === "record") {
if (encoded.kind.case !== "objectValue") if (encoded.kind.case !== "objectValue")
throw new Error("Message inputs must encode an object value"); 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)])); return Object.fromEntries(Object.entries(encoded.kind.value.fields).map(([key, entry]) => [key, liveValue(entry)]));
@@ -93,7 +134,7 @@ export const bindQxHandler = (spec, handler, messages) => {
case "state": { case "state": {
const state = raw.state(port.id); const state = raw.state(port.id);
return [name, { return [name, {
...(port.primitives.includes("read") ? { get: async () => decodeQxValue(port.valueType, (await state.live()).$quixosValue, messages) } : {}), ...(port.primitives.includes("read") ? { get: async () => decodeQxValue(port.valueType, (await state.live()).$quixosValue, messages), live: () => state.live() } : {}),
...(port.primitives.includes("write") ? { set: async (value) => state.set(liveValue(encodeQxValue(port.valueType, value, messages))) } : {}), ...(port.primitives.includes("write") ? { set: async (value) => state.set(liveValue(encodeQxValue(port.valueType, value, messages))) } : {}),
}]; }];
} }
@@ -105,9 +146,13 @@ export const bindQxHandler = (spec, handler, messages) => {
} }
case "interface": { case "interface": {
const target = raw.interface(port.id); const target = raw.interface(port.id);
return [name, Object.fromEntries(Object.entries(port.operations).map(([name, operation]) => [name, return [name, { objectId: target.objectId,
live: Object.fromEntries(Object.entries(port.operations).map(([name, operation]) => [name,
(input) => target.live(operation.id, inputFields(operation.inputType, input, messages)),
])),
...Object.fromEntries(Object.entries(port.operations).map(([name, operation]) => [name,
async (input) => decodeQxValue(operation.outputType, (await target.live(operation.id, inputFields(operation.inputType, input, messages))).$quixosValue, messages), async (input) => decodeQxValue(operation.outputType, (await target.live(operation.id, inputFields(operation.inputType, input, messages))).$quixosValue, messages),
]))]; ])) }];
} }
case "constructor": return [name, { construct: (input) => raw.constructor(port.id).construct(inputFields(port.inputType, input, messages)) }]; case "constructor": return [name, { construct: (input) => raw.constructor(port.id).construct(inputFields(port.inputType, input, messages)) }];
} }
+55
View File
@@ -234,12 +234,67 @@ export type GetWorkspaceResponse = Message<"quixos.orch.GetWorkspaceResponse"> &
* @generated from field: string source_root_commit = 3; * @generated from field: string source_root_commit = 3;
*/ */
sourceRootCommit: string; sourceRootCommit: string;
/**
* Checked constructors whose wire input can be empty. Web Studio intersects
* this with its temporary Createable marker; the marker is not a factory.
*
* @generated from field: repeated string empty_input_constructible_atom_ids = 4;
*/
emptyInputConstructibleAtomIds: string[];
/**
* @generated from field: repeated quixos.orch.CapabilityInputContract capability_inputs = 5;
*/
capabilityInputs: CapabilityInputContract[];
/**
* @generated from field: repeated quixos.orch.ConstructorInputContract constructor_inputs = 6;
*/
constructorInputs: ConstructorInputContract[];
}; };
/** /**
* Describes the message quixos.orch.GetWorkspaceResponse. * Describes the message quixos.orch.GetWorkspaceResponse.
* Use `create(GetWorkspaceResponseSchema)` to create a new message. * Use `create(GetWorkspaceResponseSchema)` to create a new message.
*/ */
export declare const GetWorkspaceResponseSchema: GenMessage<GetWorkspaceResponse>; export declare const GetWorkspaceResponseSchema: GenMessage<GetWorkspaceResponse>;
/**
* @generated from message quixos.orch.CapabilityInputContract
*/
export type CapabilityInputContract = Message<"quixos.orch.CapabilityInputContract"> & {
/**
* @generated from field: string interface_revision_id = 1;
*/
interfaceRevisionId: string;
/**
* @generated from field: string operation_id = 2;
*/
operationId: string;
/**
* @generated from field: string type_json = 3;
*/
typeJson: string;
};
/**
* Describes the message quixos.orch.CapabilityInputContract.
* Use `create(CapabilityInputContractSchema)` to create a new message.
*/
export declare const CapabilityInputContractSchema: GenMessage<CapabilityInputContract>;
/**
* @generated from message quixos.orch.ConstructorInputContract
*/
export type ConstructorInputContract = Message<"quixos.orch.ConstructorInputContract"> & {
/**
* @generated from field: string atom_id = 1;
*/
atomId: string;
/**
* @generated from field: string type_json = 2;
*/
typeJson: string;
};
/**
* Describes the message quixos.orch.ConstructorInputContract.
* Use `create(ConstructorInputContractSchema)` to create a new message.
*/
export declare const ConstructorInputContractSchema: GenMessage<ConstructorInputContract>;
/** /**
* @generated from message quixos.orch.ListActivationsRequest * @generated from message quixos.orch.ListActivationsRequest
*/ */
+1 -1
View File
File diff suppressed because one or more lines are too long
+21 -11
View File
File diff suppressed because one or more lines are too long
Generated
+7 -7
View File
@@ -74,17 +74,17 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1789328055, "lastModified": 1789426470,
"narHash": "sha256-tZVGOpDnT80j3roNQXpCxw8BxNZxqVutclxr49Vhg4w=", "narHash": "sha256-THZZ+S5Uxi2yRw5NwIfyGNn5IUJiVe9qb4271e4loD4=",
"ref": "refs/tags/quixos-reachability/9867bf4552c09ee71ebeefe08e652fd870894130", "ref": "refs/tags/quixos-reachability/c16271510afaab9b3972b82e6936aa4c5edf790f",
"rev": "9867bf4552c09ee71ebeefe08e652fd870894130", "rev": "c16271510afaab9b3972b82e6936aa4c5edf790f",
"revCount": 45, "revCount": 57,
"type": "git", "type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
}, },
"original": { "original": {
"ref": "refs/tags/quixos-reachability/9867bf4552c09ee71ebeefe08e652fd870894130", "ref": "refs/tags/quixos-reachability/c16271510afaab9b3972b82e6936aa4c5edf790f",
"rev": "9867bf4552c09ee71ebeefe08e652fd870894130", "rev": "c16271510afaab9b3972b82e6936aa4c5edf790f",
"type": "git", "type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
} }
+1 -1
View File
@@ -4,7 +4,7 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/tags/quixos-reachability/9867bf4552c09ee71ebeefe08e652fd870894130&rev=9867bf4552c09ee71ebeefe08e652fd870894130"; quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/tags/quixos-reachability/c16271510afaab9b3972b82e6936aa4c5edf790f&rev=c16271510afaab9b3972b82e6936aa4c5edf790f";
quixosNixHelpers = { quixosNixHelpers = {
url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/tags/quixos-reachability/7177130c0365f2fa58ea4877366e1c5d17db4c01&rev=7177130c0365f2fa58ea4877366e1c5d17db4c01"; url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/tags/quixos-reachability/7177130c0365f2fa58ea4877366e1c5d17db4c01&rev=7177130c0365f2fa58ea4877366e1c5d17db4c01";
flake = false; flake = false;
+44 -9
View File
@@ -8,6 +8,20 @@ import { assertReferenceFree, referenceToWire } from "./references.js";
declare const watchBrand: unique symbol; declare const watchBrand: unique symbol;
export type QxWatchHandle = string & { readonly [watchBrand]: true }; export type QxWatchHandle = string & { readonly [watchBrand]: true };
export type MessageBinding<T> = { encode(value: T): Value; decode(value: Value): T }; export type MessageBinding<T> = { encode(value: T): Value; decode(value: Value): T };
export type QxLiveValue = ReturnType<typeof liveValue>;
const reactPropsDescriptor = "org.quixos.web-studio.ReactProps";
// Explicit temporary props exception, matching orch's RPC contract. Field shapes
// remain unchecked pending generics; ordinary messages and state do not gain it.
export const opaqueReactPropsBinding: MessageBinding<Record<string, unknown>> = {
encode(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("React props must be an object");
return jsToProtoValue(value);
},
decode(value) {
if (value.kind.case !== "objectValue") throw new Error("React props must be an object");
return protoValueToJs(value) as Record<string, unknown>;
},
};
export type BindingValue<B> = B extends MessageBinding<infer T> ? T : never; export type BindingValue<B> = B extends MessageBinding<infer T> ? T : never;
export type QxHandler<C, O> = (context: C) => O | Promise<O>; export type QxHandler<C, O> = (context: C) => O | Promise<O>;
export type QxDerived<C, O> = { kind: "derived"; get: QxHandler<C, O> }; export type QxDerived<C, O> = { kind: "derived"; get: QxHandler<C, O> };
@@ -20,6 +34,7 @@ export type QxValueType =
| { kind: "builtin"; name: "unit" | "watch-handle" } | { kind: "builtin"; name: "unit" | "watch-handle" }
| { kind: "scalar"; name: string } | { kind: "scalar"; name: string }
| { kind: "message"; descriptorId: string } | { kind: "message"; descriptorId: string }
| { kind: "record"; fields: Record<string, QxValueType> }
| { kind: "object-ref"; expectation: unknown } | { kind: "object-ref"; expectation: unknown }
| { kind: "optional" | "list"; value: QxValueType }; | { kind: "optional" | "list"; value: QxValueType };
export type QxOperationSpec = { id: string; inputType: QxValueType; outputType: QxValueType }; export type QxOperationSpec = { id: string; inputType: QxValueType; outputType: QxValueType };
@@ -37,16 +52,23 @@ export type QxMessages = Record<string, MessageBinding<any>>;
// Conversion belongs at the binding boundary. It does not add orchestrator validation. // Conversion belongs at the binding boundary. It does not add orchestrator validation.
export const decodeQxValue = (type: QxValueType, value: Value | undefined, messages: QxMessages): any => { export const decodeQxValue = (type: QxValueType, value: Value | undefined, messages: QxMessages): any => {
if (type.kind === "builtin" && type.name === "unit") return null; 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 (!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 === "optional") return value.kind.case === "nullValue" ? null : decodeQxValue(type.value, value, messages);
if (type.kind === "list") { if (type.kind === "list") {
if (value.kind.case !== "listValue") throw new Error("Expected QX list"); if (value.kind.case !== "listValue") throw new Error("Expected QX list");
return value.kind.value.values.map((entry) => decodeQxValue(type.value, entry, messages)); return value.kind.value.values.map((entry) => decodeQxValue(type.value, entry, messages));
} }
if (type.kind === "message") { if (type.kind === "message") {
assertReferenceFree(protoValueToJs(value)); if (type.descriptorId !== reactPropsDescriptor) assertReferenceFree(protoValueToJs(value));
const decoded = requireMessage(messages, type.descriptorId).decode(value); const decoded = requireMessage(messages, type.descriptorId).decode(value);
assertReferenceFree(decoded); if (type.descriptorId !== reactPropsDescriptor) assertReferenceFree(decoded);
return decoded; return decoded;
} }
if (type.kind === "object-ref") { if (type.kind === "object-ref") {
@@ -74,27 +96,36 @@ const requireMessage = (messages: QxMessages, id: string) => {
}; };
export const encodeQxValue = (type: QxValueType, value: any, messages: QxMessages): Value => { export const encodeQxValue = (type: QxValueType, value: any, messages: QxMessages): Value => {
if (type.kind === "builtin" && type.name === "unit") return jsToProtoValue(null); 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 === "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 === "list") return jsToProtoValue(value.map((entry: unknown) => liveValue(encodeQxValue(type.value, entry, messages))));
if (type.kind === "object-ref") { referenceToWire(value); return jsToProtoValue(value); } if (type.kind === "object-ref") { referenceToWire(value); return jsToProtoValue(value); }
assertReferenceFree(value); if (type.kind !== "message" || type.descriptorId !== reactPropsDescriptor) assertReferenceFree(value);
if (type.kind === "message") { if (type.kind === "message") {
const encoded = requireMessage(messages, type.descriptorId).encode(value); const encoded = requireMessage(messages, type.descriptorId).encode(value);
assertReferenceFree(protoValueToJs(encoded)); if (type.descriptorId !== reactPropsDescriptor) assertReferenceFree(protoValueToJs(encoded));
return encoded; return encoded;
} }
return jsToProtoValue(value); return jsToProtoValue(value);
}; };
const inputValue = (context: RuntimeContext, type: QxValueType) => { 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 }) } }); value: create(ObjectValueSchema, { fields: context.inputProto }) } });
return context.inputProto.value; return context.inputProto.value;
}; };
const inputFields = (type: QxValueType, value: unknown, messages: QxMessages): Record<string, unknown> => { const inputFields = (type: QxValueType, value: unknown, messages: QxMessages): Record<string, unknown> => {
if (type.kind === "builtin" && type.name === "unit") return {}; if (type.kind === "builtin" && type.name === "unit") return {};
const encoded = encodeQxValue(type, value, messages); 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"); 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)])); return Object.fromEntries(Object.entries(encoded.kind.value.fields).map(([key, entry]) => [key, liveValue(entry)]));
} }
@@ -111,7 +142,7 @@ export const bindQxHandler = <C, O>(
case "state": { case "state": {
const state = raw.state(port.id); const state = raw.state(port.id);
return [name, { return [name, {
...(port.primitives.includes("read") ? { get: async () => decodeQxValue(port.valueType, (await state.live()).$quixosValue, messages) } : {}), ...(port.primitives.includes("read") ? { get: async () => decodeQxValue(port.valueType, (await state.live()).$quixosValue, messages), live: () => state.live() } : {}),
...(port.primitives.includes("write") ? { set: async (value: unknown) => state.set(liveValue(encodeQxValue(port.valueType, value, messages))) } : {}), ...(port.primitives.includes("write") ? { set: async (value: unknown) => state.set(liveValue(encodeQxValue(port.valueType, value, messages))) } : {}),
}]; }];
} }
@@ -123,10 +154,14 @@ export const bindQxHandler = <C, O>(
} }
case "interface": { case "interface": {
const target = raw.interface(port.id); const target = raw.interface(port.id);
return [name, Object.fromEntries(Object.entries(port.operations).map(([name, operation]) => [name, return [name, {objectId: target.objectId,
live: Object.fromEntries(Object.entries(port.operations).map(([name, operation]) => [name,
(input: unknown) => target.live(operation.id, inputFields(operation.inputType, input, messages)),
])),
...Object.fromEntries(Object.entries(port.operations).map(([name, operation]) => [name,
async (input: unknown) => decodeQxValue(operation.outputType, async (input: unknown) => decodeQxValue(operation.outputType,
(await target.live(operation.id, inputFields(operation.inputType, input, messages))).$quixosValue, messages), (await target.live(operation.id, inputFields(operation.inputType, input, messages))).$quixosValue, messages),
]))]; ]))}];
} }
case "constructor": return [name, { construct: (input: unknown) => raw.constructor(port.id).construct(inputFields(port.inputType, input, messages)) }]; case "constructor": return [name, { construct: (input: unknown) => raw.constructor(port.id).construct(inputFields(port.inputType, input, messages)) }];
} }
+78 -11
View File
File diff suppressed because one or more lines are too long
+39 -2
View File
@@ -1,9 +1,43 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { referenceFromWire } from "../dist/references.js"; import { referenceFromWire } from "../dist/references.js";
import { bindQxHandler, decodeQxValue, encodeQxValue, jsToProtoValue, liveValue, protoValueToJs } from "../dist/index.js"; import { bindQxHandler, decodeQxValue, encodeQxValue, jsToProtoValue, liveValue, protoValueToJs, opaqueReactPropsBinding } from "../dist/index.js";
const scalar = (name) => ({ kind: "scalar", name }); const scalar = (name) => ({ kind: "scalar", name });
const unit = { kind: "builtin", name: "unit" }; const unit = { kind: "builtin", name: "unit" };
test("opaque React props retain managed references while ordinary messages remain closed", () => {
const descriptorId = "org.quixos.web-studio.ReactProps";
const reference = referenceFromWire("obj:task");
const type = {kind: "message", descriptorId};
const messages = {[descriptorId]: opaqueReactPropsBinding};
const decoded = decodeQxValue(type, encodeQxValue(type, {subject: reference, title: "Task"}, messages), messages);
assert.ok(decoded.subject.equals(reference));
assert.equal(decoded.title, "Task");
assert.throws(() => opaqueReactPropsBinding.encode(null), /object/);
assert.throws(() => opaqueReactPropsBinding.encode([]), /object/);
assert.throws(() => encodeQxValue({kind: "message", descriptorId: "Ordinary"}, {subject: reference},
{Ordinary: opaqueReactPropsBinding}), /Managed references/);
});
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 () => { test("typed sessions rebind ports and cancellation to each acquired invocation", async () => {
const first = new AbortController(), second = new AbortController(); const first = new AbortController(), second = new AbortController();
let closed = false; let closed = false;
@@ -56,6 +90,9 @@ test("typed state and interface ports preserve declared values and exact operati
const handler = bindQxHandler(spec, async ({ input, ports }) => { const handler = bindQxHandler(spec, async ({ input, ports }) => {
assert.equal(input, 2n ** 60n); assert.equal(input, 2n ** 60n);
assert.equal(await ports.data.get(), 9n); assert.equal(await ports.data.get(), 9n);
assert.deepEqual((await ports.data.live()).$quixosValue, jsToProtoValue(9n));
assert.ok(ports.reader.objectId.equals(referenceFromWire("obj:reader")));
assert.deepEqual((await ports.reader.live["payload.get"]()).$quixosValue, jsToProtoValue(new Uint8Array([7])));
await ports.data.set(input); await ports.data.set(input);
return ports.reader["payload.get"](); return ports.reader["payload.get"]();
}, {}); }, {});
@@ -63,7 +100,7 @@ test("typed state and interface ports preserve declared values and exact operati
state: (id) => { assert.equal(id, "state-id"); return { state: (id) => { assert.equal(id, "state-id"); return {
live: async () => liveValue(jsToProtoValue(9n)), set: async (value) => { written = value; }, live: async () => liveValue(jsToProtoValue(9n)), set: async (value) => { written = value; },
}; }, }; },
interface: (id) => { assert.equal(id, "interface-id"); return { live: async (operation, input) => { interface: (id) => { assert.equal(id, "interface-id"); return { objectId: referenceFromWire("obj:reader"), live: async (operation, input) => {
assert.equal(operation, "get-id"); assert.deepEqual(input, {}); return liveValue(jsToProtoValue(new Uint8Array([7]))); assert.equal(operation, "get-id"); assert.deepEqual(input, {}); return liveValue(jsToProtoValue(new Uint8Array([7])));
} }; }, } }; },
}); });