2026-08-17 20:21:41 +02:00
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
import { mkdtemp } from "node:fs/promises";
|
|
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { JSDOM } from "jsdom";
|
|
|
|
|
import { z } from "zod";
|
2026-09-10 13:29:34 +02:00
|
|
|
import { defineDocumentType } from "@gamecult/cultcache-ts";
|
npm: scope cultnet-ts, cultmesh-ts and cultmesh-browser under @gamecult
Unscoped npm names are unclaimed and open to dependency confusion; cultcache-ts was
already @gamecult/cultcache-ts. package.json names, intra-repo dependencies, package-lock,
import specifiers (src, tests, samples, docs/start, first-call docs test), the workspace
list in run-typescript-workspaces, the verify-eve module map and package READMEs now use
the scoped names. Directory names, workspace paths in CI and release tag prefixes are
unchanged. Historical cut docs and past CHANGELOG entries keep the old names.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:25:10 +00:00
|
|
|
import { CultMesh } from "@gamecult/cultmesh-ts";
|
2026-08-17 23:18:15 +02:00
|
|
|
import {
|
|
|
|
|
parseEveCommandReceipt,
|
|
|
|
|
parseEveSurfaceDocument,
|
|
|
|
|
} from "@gamecult/eve-contracts";
|
2026-08-17 20:21:41 +02:00
|
|
|
import { renderEveSurface } from "@gamecult/eve-browser-lowering";
|
|
|
|
|
|
|
|
|
|
const providerId = "sample.counter-provider";
|
|
|
|
|
const surfaceId = "sample.counter";
|
|
|
|
|
const stateKey = "counter:main";
|
|
|
|
|
|
|
|
|
|
const counterDocument = defineDocumentType({
|
|
|
|
|
type: "sample.counter_state",
|
|
|
|
|
schemaId: "sample.counter_state.v1",
|
|
|
|
|
schema: z.object({ counterId: z.string(), count: z.number().int() }),
|
|
|
|
|
name: "counterId",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const surfaceDocument = defineDocumentType({
|
|
|
|
|
type: "gamecult.eve.surface",
|
|
|
|
|
schemaId: "gamecult.eve.surface.v1",
|
2026-08-17 23:18:15 +02:00
|
|
|
schema: z.unknown().transform(value => parseEveSurfaceDocument(value)),
|
2026-08-17 20:21:41 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const receiptDocument = defineDocumentType({
|
2026-08-17 23:18:15 +02:00
|
|
|
type: "gamecult.eve.command_receipt",
|
|
|
|
|
schemaId: "gamecult.eve.command_receipt.v1",
|
|
|
|
|
schema: z.unknown().transform(value => parseEveCommandReceipt(value)),
|
2026-08-17 20:21:41 +02:00
|
|
|
name: "receiptId",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function counterSurface() {
|
|
|
|
|
return {
|
2026-08-17 23:18:15 +02:00
|
|
|
type: "surface-state",
|
|
|
|
|
schema: "gamecult.eve.surface.v1",
|
2026-08-17 20:21:41 +02:00
|
|
|
providerId,
|
2026-08-17 23:18:15 +02:00
|
|
|
providerKind: "sample.daemon",
|
2026-08-17 20:21:41 +02:00
|
|
|
title: "CultMesh two-runtime counter",
|
|
|
|
|
version: 1,
|
2026-08-17 23:18:15 +02:00
|
|
|
updatedAtUtc: "2026-08-17T00:00:00Z",
|
2026-08-17 20:21:41 +02:00
|
|
|
surface: {
|
|
|
|
|
id: surfaceId,
|
2026-08-17 23:18:15 +02:00
|
|
|
title: "Counter",
|
2026-08-17 20:21:41 +02:00
|
|
|
root: {
|
|
|
|
|
id: "counter.root",
|
|
|
|
|
kind: "column",
|
2026-08-17 23:18:15 +02:00
|
|
|
props: {},
|
2026-08-17 20:21:41 +02:00
|
|
|
children: [
|
|
|
|
|
{
|
|
|
|
|
id: "counter.value",
|
|
|
|
|
kind: "metric",
|
|
|
|
|
props: { label: "Canonical count", value: 0 },
|
2026-08-17 23:18:15 +02:00
|
|
|
children: [],
|
2026-08-17 20:21:41 +02:00
|
|
|
stateBindings: [{
|
|
|
|
|
targetProp: "value",
|
|
|
|
|
pointerId: "sample.counter.count",
|
|
|
|
|
sourceId: `${counterDocument.type}:${stateKey}`,
|
|
|
|
|
schemaId: counterDocument.schemaId,
|
|
|
|
|
routeKind: "shared-memory",
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "counter.increment",
|
|
|
|
|
kind: "control.button",
|
|
|
|
|
props: { label: "Increment", command: "sample.counter.increment" },
|
2026-08-17 23:18:15 +02:00
|
|
|
children: [],
|
2026-08-17 20:21:41 +02:00
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
2026-08-17 23:18:15 +02:00
|
|
|
styles: {},
|
2026-08-17 20:21:41 +02:00
|
|
|
},
|
2026-08-17 23:18:15 +02:00
|
|
|
commands: [{
|
|
|
|
|
schema: "gamecult.eve.command.v1",
|
|
|
|
|
command: "sample.counter.increment",
|
|
|
|
|
label: "Increment",
|
|
|
|
|
surfaceId,
|
|
|
|
|
transport: "cultmesh",
|
|
|
|
|
authority: "provider-daemon",
|
|
|
|
|
result: "gamecult.eve.command_receipt.v1",
|
|
|
|
|
}],
|
2026-08-17 20:21:41 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function installDom() {
|
|
|
|
|
const dom = new JSDOM("<!doctype html><html><head></head><body><main id='surface'></main></body></html>", {
|
|
|
|
|
url: "https://eve.sample/",
|
|
|
|
|
pretendToBeVisual: true,
|
|
|
|
|
});
|
|
|
|
|
for (const name of [
|
|
|
|
|
"document",
|
|
|
|
|
"window",
|
|
|
|
|
"Element",
|
|
|
|
|
"HTMLElement",
|
|
|
|
|
"HTMLButtonElement",
|
|
|
|
|
"HTMLInputElement",
|
|
|
|
|
"HTMLTextAreaElement",
|
|
|
|
|
"HTMLImageElement",
|
|
|
|
|
]) globalThis[name] = dom.window[name];
|
|
|
|
|
return dom;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitFor(predicate, description) {
|
|
|
|
|
const deadline = Date.now() + 2_000;
|
|
|
|
|
while (!predicate()) {
|
|
|
|
|
if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}.`);
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 23:18:15 +02:00
|
|
|
assert.throws(
|
|
|
|
|
() => parseEveSurfaceDocument({ providerId, surface: { id: surfaceId } }),
|
|
|
|
|
/Invalid Eve surface/,
|
|
|
|
|
);
|
|
|
|
|
assert.throws(
|
|
|
|
|
() => parseEveCommandReceipt({ receiptId: "unowned" }),
|
|
|
|
|
/Invalid Eve commandReceipt/,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const statePath = join(await mkdtemp(join(tmpdir(), "cultmesh-eve-two-runtime-")), "state.cc");
|
2026-08-17 20:21:41 +02:00
|
|
|
const node = await CultMesh.startNode(statePath, {
|
|
|
|
|
documents: [counterDocument, surfaceDocument, receiptDocument],
|
|
|
|
|
});
|
|
|
|
|
await node.put(counterDocument, stateKey, { counterId: stateKey, count: 0 });
|
|
|
|
|
await node.put(surfaceDocument, surfaceId, counterSurface());
|
|
|
|
|
await node.flush();
|
|
|
|
|
|
|
|
|
|
const state = node.document(counterDocument, stateKey, { pollMs: 5 });
|
|
|
|
|
const headless = state.observe({ context: "headless-client" });
|
|
|
|
|
await headless.ready;
|
|
|
|
|
const receiptsByIdempotencyKey = new Map();
|
|
|
|
|
const increment = CultMesh.operation("sample.counter.increment", async (_request, context) => {
|
|
|
|
|
const idempotencyKey = context.idempotencyKey || "missing";
|
|
|
|
|
const existing = receiptsByIdempotencyKey.get(idempotencyKey)
|
|
|
|
|
|| node.get(receiptDocument, `receipt:${idempotencyKey}`);
|
|
|
|
|
if (existing) return existing;
|
|
|
|
|
const next = await state.authoritativeWriter("provider-daemon").update(current => ({
|
|
|
|
|
...current,
|
|
|
|
|
count: current.count + 1,
|
|
|
|
|
}));
|
|
|
|
|
const receipt = {
|
|
|
|
|
receiptId: `receipt:${idempotencyKey}`,
|
2026-08-17 23:18:15 +02:00
|
|
|
schema: "gamecult.eve.command_receipt.v1",
|
|
|
|
|
commandId: idempotencyKey,
|
|
|
|
|
command: "sample.counter.increment",
|
|
|
|
|
state: "accepted",
|
|
|
|
|
ownerRepo: "CultLib",
|
|
|
|
|
authority: "provider-daemon",
|
|
|
|
|
providerId,
|
|
|
|
|
surfaceId,
|
|
|
|
|
issuedAtUtc: new Date().toISOString(),
|
|
|
|
|
sourceVersion: next.count,
|
2026-08-17 20:21:41 +02:00
|
|
|
idempotencyKey,
|
|
|
|
|
count: next.count,
|
|
|
|
|
};
|
|
|
|
|
receiptsByIdempotencyKey.set(idempotencyKey, receipt);
|
|
|
|
|
await node.put(receiptDocument, receipt.receiptId, receipt);
|
|
|
|
|
await node.flush();
|
|
|
|
|
return receipt;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const dom = installDom();
|
|
|
|
|
const host = document.querySelector("#surface");
|
|
|
|
|
const browserVerse = CultMesh.verse("local", "browser-client");
|
|
|
|
|
let browserReceipt;
|
|
|
|
|
renderEveSurface(await node.document(surfaceDocument, surfaceId).latest(), host, {
|
|
|
|
|
activeSurfaceId: surfaceId,
|
|
|
|
|
clientId: "browser-client",
|
2026-09-10 13:29:34 +02:00
|
|
|
// Eve refuses to build a command intent for a surface that has not
|
|
|
|
|
// advertised where its commands go and what comes back. The provider
|
|
|
|
|
// advertisement is that declaration; a lowering cannot infer it.
|
|
|
|
|
provider: {
|
|
|
|
|
providerId,
|
|
|
|
|
surfaces: [{
|
|
|
|
|
surfaceId,
|
|
|
|
|
worldInteraction: {
|
|
|
|
|
commandBoundary: "sample.counter.commands",
|
|
|
|
|
receiptSchema: "gamecult.eve.command_receipt.v1",
|
|
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
},
|
2026-08-17 20:21:41 +02:00
|
|
|
commandSink: async intent => {
|
|
|
|
|
browserReceipt = await increment.bind(browserVerse).invoke(intent.payload, {
|
|
|
|
|
idempotencyKey: "browser-click-1",
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
stateBindingResolver: async binding => {
|
|
|
|
|
if (binding.pointerId !== "sample.counter.count") return undefined;
|
|
|
|
|
return {
|
|
|
|
|
latest: async () => (await state.latest("browser-client")).count,
|
|
|
|
|
watch: callback => state.watch("browser-client", value => callback(value.count)),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => host.querySelector(".metric-value")?.textContent === "0", "initial browser binding");
|
|
|
|
|
host.querySelector("button").click();
|
|
|
|
|
await waitFor(() => browserReceipt?.count === 1, "typed command receipt");
|
|
|
|
|
await waitFor(() => headless.current.count === 1, "headless convergence");
|
|
|
|
|
await waitFor(() => host.querySelector(".metric-value")?.textContent === "1", "browser convergence");
|
|
|
|
|
|
|
|
|
|
const duplicate = await increment.bind(browserVerse).invoke({}, { idempotencyKey: "browser-click-1" });
|
|
|
|
|
assert.equal(duplicate.receiptId, browserReceipt.receiptId);
|
|
|
|
|
assert.equal(headless.current.count, 1);
|
|
|
|
|
|
|
|
|
|
headless.dispose();
|
2026-08-17 23:18:15 +02:00
|
|
|
renderEveSurface(parseEveSurfaceDocument({
|
|
|
|
|
type: "surface-state",
|
|
|
|
|
schema: "gamecult.eve.surface.v1",
|
|
|
|
|
providerId,
|
|
|
|
|
providerKind: "sample.daemon",
|
|
|
|
|
title: "Closed counter",
|
|
|
|
|
version: 2,
|
|
|
|
|
updatedAtUtc: "2026-08-17T00:00:01Z",
|
|
|
|
|
surface: {
|
|
|
|
|
id: "sample.closed",
|
|
|
|
|
root: { id: "closed.root", kind: "column", props: {}, children: [] },
|
|
|
|
|
styles: {},
|
|
|
|
|
},
|
|
|
|
|
commands: [],
|
|
|
|
|
}), host);
|
2026-08-17 20:21:41 +02:00
|
|
|
dom.window.close();
|
|
|
|
|
await node.flush();
|
|
|
|
|
const reopened = await CultMesh.startNode(statePath, {
|
|
|
|
|
documents: [counterDocument, surfaceDocument, receiptDocument],
|
|
|
|
|
});
|
|
|
|
|
assert.equal(reopened.getRequired(counterDocument, stateKey).count, 1);
|
|
|
|
|
assert.equal(
|
|
|
|
|
reopened.getRequired(receiptDocument, "receipt:browser-click-1").receiptId,
|
|
|
|
|
browserReceipt.receiptId,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
console.log(JSON.stringify({
|
|
|
|
|
providerId,
|
|
|
|
|
surfaceId,
|
|
|
|
|
receiptId: browserReceipt.receiptId,
|
2026-08-17 23:18:15 +02:00
|
|
|
loweringRuntime: "jsdom",
|
2026-08-17 20:21:41 +02:00
|
|
|
browserCount: 1,
|
|
|
|
|
headlessCount: 1,
|
|
|
|
|
restartCount: 1,
|
|
|
|
|
}));
|