2026-09-08 14:27:24 -07:00
|
|
|
import { create } from "@bufbuild/protobuf";
|
|
|
|
|
import { ValueSchema, ObjectValueSchema, type Value } from "./camino/api_pb.js";
|
|
|
|
|
import { derived, jsToProtoValue, liveValue, protoValueToJs,
|
|
|
|
|
type RuntimeContext, type RuntimeHandler, type DerivedHandler } from "./index.js";
|
|
|
|
|
|
2026-09-10 18:27:41 -07:00
|
|
|
export type { QxObjectRef } from "./references.js";
|
|
|
|
|
import { assertReferenceFree, referenceToWire } from "./references.js";
|
2026-09-08 14:27:24 -07:00
|
|
|
declare const watchBrand: unique symbol;
|
|
|
|
|
export type QxWatchHandle = string & { readonly [watchBrand]: true };
|
|
|
|
|
export type MessageBinding<T> = { encode(value: T): Value; decode(value: Value): T };
|
|
|
|
|
export type BindingValue<B> = B extends MessageBinding<infer T> ? T : never;
|
|
|
|
|
export type QxHandler<C, O> = (context: C) => O | Promise<O>;
|
|
|
|
|
export type QxDerived<C, O> = { kind: "derived"; get: QxHandler<C, O> };
|
2026-09-10 18:27:41 -07:00
|
|
|
export type QxSession<C> = {id: string; run<T>(work: (context: C) => Promise<T>): Promise<T>; close(): Promise<void>};
|
|
|
|
|
export type QxContextLifecycle<C> = {signal?: AbortSignal; openSession?: () => Promise<QxSession<C>>};
|
2026-09-08 14:27:24 -07:00
|
|
|
export const qxDerived = <C, O>(get: QxHandler<C, O>): QxDerived<C, O> => ({ kind: "derived", get });
|
|
|
|
|
|
|
|
|
|
/** Versioned binding ABI. This mirrors the language-neutral value IR. */
|
|
|
|
|
export type QxValueType =
|
|
|
|
|
| { kind: "builtin"; name: "unit" | "watch-handle" }
|
|
|
|
|
| { kind: "scalar"; name: string }
|
|
|
|
|
| { kind: "message"; descriptorId: string }
|
|
|
|
|
| { kind: "object-ref"; expectation: unknown }
|
|
|
|
|
| { kind: "optional" | "list"; value: QxValueType };
|
|
|
|
|
export type QxOperationSpec = { id: string; inputType: QxValueType; outputType: QxValueType };
|
|
|
|
|
export type QxPortSpec =
|
|
|
|
|
| { kind: "state"; id: string; valueType: QxValueType; primitives: string[] }
|
|
|
|
|
| { kind: "edge"; id: string; primitives: string[] }
|
|
|
|
|
| { kind: "interface"; id: string; operations: Record<string, QxOperationSpec> }
|
|
|
|
|
| { kind: "constructor"; id: string; inputType: QxValueType };
|
|
|
|
|
export type QxHandlerSpec = {
|
|
|
|
|
inputType: QxValueType; outputType: QxValueType; eventType?: QxValueType;
|
|
|
|
|
ports: Record<string, QxPortSpec>;
|
|
|
|
|
};
|
|
|
|
|
export type QxMessages = Record<string, MessageBinding<any>>;
|
|
|
|
|
|
|
|
|
|
// 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 (!value) throw new Error("Missing QX wire value");
|
|
|
|
|
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");
|
|
|
|
|
return value.kind.value.values.map((entry) => decodeQxValue(type.value, entry, messages));
|
|
|
|
|
}
|
2026-09-10 18:27:41 -07:00
|
|
|
if (type.kind === "message") {
|
|
|
|
|
assertReferenceFree(protoValueToJs(value));
|
|
|
|
|
const decoded = requireMessage(messages, type.descriptorId).decode(value);
|
|
|
|
|
assertReferenceFree(decoded);
|
|
|
|
|
return decoded;
|
|
|
|
|
}
|
|
|
|
|
if (type.kind === "object-ref") {
|
|
|
|
|
if (value.kind.case !== "refValue") throw new Error("Expected a declared RPC object reference");
|
|
|
|
|
return protoValueToJs(value);
|
|
|
|
|
}
|
|
|
|
|
if (value.kind.case === "refValue") throw new Error("Reference supplied to a non-reference value");
|
2026-09-08 14:27:24 -07:00
|
|
|
if (type.kind === "scalar") {
|
|
|
|
|
if (type.name === "int64" || type.name === "uint64") {
|
|
|
|
|
if (value.kind.case !== "integerValue") throw new Error("Expected QX integer");
|
|
|
|
|
return BigInt(value.kind.value);
|
|
|
|
|
}
|
|
|
|
|
if (type.name === "bytes") {
|
|
|
|
|
if (value.kind.case !== "bytesValue") throw new Error("Expected QX bytes");
|
|
|
|
|
return value.kind.value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return protoValueToJs(value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const requireMessage = (messages: QxMessages, id: string) => {
|
|
|
|
|
const binding = messages[id];
|
|
|
|
|
if (!binding) throw new Error(`Missing message binding ${id}`);
|
|
|
|
|
return binding;
|
|
|
|
|
};
|
|
|
|
|
export const encodeQxValue = (type: QxValueType, value: any, messages: QxMessages): Value => {
|
|
|
|
|
if (type.kind === "builtin" && type.name === "unit") return jsToProtoValue(null);
|
|
|
|
|
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))));
|
2026-09-10 18:27:41 -07:00
|
|
|
if (type.kind === "object-ref") { referenceToWire(value); return jsToProtoValue(value); }
|
|
|
|
|
assertReferenceFree(value);
|
|
|
|
|
if (type.kind === "message") {
|
|
|
|
|
const encoded = requireMessage(messages, type.descriptorId).encode(value);
|
|
|
|
|
assertReferenceFree(protoValueToJs(encoded));
|
|
|
|
|
return encoded;
|
|
|
|
|
}
|
2026-09-08 14:27:24 -07:00
|
|
|
return jsToProtoValue(value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const inputValue = (context: RuntimeContext, type: QxValueType) => {
|
|
|
|
|
if (type.kind === "message") 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<string, unknown> => {
|
|
|
|
|
if (type.kind === "builtin" && type.name === "unit") return {};
|
|
|
|
|
const encoded = encodeQxValue(type, value, messages);
|
|
|
|
|
if (type.kind === "message") {
|
|
|
|
|
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 { value: liveValue(encoded) };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** The sole unchecked cast connects generated contracts to the dynamic RPC runtime. */
|
|
|
|
|
export const bindQxHandler = <C, O>(
|
|
|
|
|
spec: QxHandlerSpec, handler: QxHandler<C, O> | QxDerived<C, O>, messages: QxMessages,
|
|
|
|
|
): RuntimeHandler | DerivedHandler => {
|
2026-09-10 18:27:41 -07:00
|
|
|
const bindContext = (raw: RuntimeContext): C => {
|
2026-09-08 14:27:24 -07:00
|
|
|
const ports = Object.fromEntries(Object.entries(spec.ports).map(([name, port]) => {
|
|
|
|
|
switch (port.kind) {
|
|
|
|
|
case "state": {
|
|
|
|
|
const state = raw.state(port.id);
|
|
|
|
|
return [name, {
|
|
|
|
|
...(port.primitives.includes("read") ? { get: async () => decodeQxValue(port.valueType, (await state.live()).$quixosValue, messages) } : {}),
|
|
|
|
|
...(port.primitives.includes("write") ? { set: async (value: unknown) => state.set(liveValue(encodeQxValue(port.valueType, value, messages))) } : {}),
|
|
|
|
|
}];
|
|
|
|
|
}
|
|
|
|
|
case "edge": {
|
|
|
|
|
const edge = raw.edge(port.id);
|
2026-09-10 18:27:41 -07:00
|
|
|
return [name, {...Object.fromEntries(port.primitives.map((primitive) => [primitive, edge[primitive as "resolve" | "connect" | "disconnect"]])),
|
|
|
|
|
...(port.primitives.includes("resolve") ? {collection: edge.collection} : {}),
|
|
|
|
|
...(port.primitives.includes("resolve") && port.primitives.includes("connect") && port.primitives.includes("disconnect") ? {replace: edge.replace} : {})}];
|
2026-09-08 14:27:24 -07:00
|
|
|
}
|
|
|
|
|
case "interface": {
|
|
|
|
|
const target = raw.interface(port.id);
|
|
|
|
|
return [name, Object.fromEntries(Object.entries(port.operations).map(([name, operation]) => [name,
|
|
|
|
|
async (input: unknown) => decodeQxValue(operation.outputType,
|
|
|
|
|
(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)) }];
|
|
|
|
|
}
|
|
|
|
|
}));
|
2026-09-10 18:27:41 -07:00
|
|
|
return { objectId: raw.objectId, signal: raw.signal,
|
|
|
|
|
...(raw.openSession ? {openSession: async () => {
|
|
|
|
|
const session = await raw.openSession!();
|
|
|
|
|
return {id: session.id, close: () => session.close(),
|
|
|
|
|
run: <T>(work: (context: C) => Promise<T>) => session.run((next) => work(bindContext(next)))};
|
|
|
|
|
}} : {}),
|
2026-09-08 14:27:24 -07:00
|
|
|
input: decodeQxValue(spec.inputType, inputValue(raw, spec.inputType), messages), ports } as C;
|
2026-09-10 18:27:41 -07:00
|
|
|
};
|
|
|
|
|
const execute = async (raw: RuntimeContext) => {
|
|
|
|
|
const context = bindContext(raw);
|
2026-09-08 14:27:24 -07:00
|
|
|
const value = await (typeof handler === "function" ? handler(context) : handler.get(context));
|
|
|
|
|
return liveValue(encodeQxValue(spec.eventType ?? spec.outputType, value, messages));
|
|
|
|
|
};
|
|
|
|
|
return typeof handler === "function" ? execute : derived(execute);
|
|
|
|
|
};
|