iterate 0.2.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -81
- package/dist/api.d.ts +643 -0
- package/dist/api.mjs +0 -0
- package/dist/app-server.d.ts +51 -0
- package/dist/app-server.mjs +481 -0
- package/dist/app-server.mjs.map +1 -0
- package/dist/app-session.d.ts +49 -0
- package/dist/app-session.mjs +235 -0
- package/dist/app-session.mjs.map +1 -0
- package/dist/app.d.ts +29 -0
- package/dist/app.mjs +180 -0
- package/dist/app.mjs.map +1 -0
- package/dist/client/live-state.d.ts +63 -0
- package/dist/client/oauth.d.ts +17 -0
- package/dist/client/react.d.ts +77 -0
- package/dist/client/socket.d.ts +7 -0
- package/dist/client.mjs +156 -0
- package/dist/client.mjs.map +1 -0
- package/dist/expression.d.ts +88 -0
- package/dist/expression.mjs +301 -0
- package/dist/expression.mjs.map +1 -0
- package/dist/lib-BWr-5mFO.mjs +36 -0
- package/dist/lib-BWr-5mFO.mjs.map +1 -0
- package/dist/lib.d.ts +70 -0
- package/dist/lib.mjs +228 -0
- package/dist/lib.mjs.map +1 -0
- package/dist/node.d.ts +15 -0
- package/dist/node.mjs +47 -0
- package/dist/node.mjs.map +1 -0
- package/dist/oauth-scopes.d.ts +32 -0
- package/dist/oauth-scopes.mjs +40 -0
- package/dist/oauth-scopes.mjs.map +1 -0
- package/dist/oauth.mjs +41 -0
- package/dist/oauth.mjs.map +1 -0
- package/dist/principal.d.ts +8 -0
- package/dist/principal.mjs +8 -0
- package/dist/principal.mjs.map +1 -0
- package/dist/project-ingress.d.ts +58 -0
- package/dist/project-ingress.mjs +104 -0
- package/dist/project-ingress.mjs.map +1 -0
- package/dist/react.mjs +285 -0
- package/dist/react.mjs.map +1 -0
- package/dist/sdk/auth.d.ts +25 -0
- package/dist/sdk/index.d.ts +155 -0
- package/dist/sdk/record-pipelined-steps.d.ts +19 -0
- package/dist/sdk.mjs +245 -0
- package/dist/sdk.mjs.map +1 -0
- package/dist/stream/processor.d.ts +383 -0
- package/dist/stream/processor.mjs +605 -0
- package/dist/stream/processor.mjs.map +1 -0
- package/dist/stream/run.d.ts +61 -0
- package/dist/stream/run.mjs +45 -0
- package/dist/stream/run.mjs.map +1 -0
- package/dist/stream/test-support.d.ts +45 -0
- package/dist/stream/test-support.mjs +196 -0
- package/dist/stream/test-support.mjs.map +1 -0
- package/dist/usingCtx-inzbY1Qz.mjs +57 -0
- package/package.json +93 -30
- package/bin/iterate.js +0 -86
- package/dist/cli-DMS4kJph.mjs +0 -868
- package/dist/cli-DMS4kJph.mjs.map +0 -1
- package/dist/config-DtnR7Lv7.mjs +0 -170
- package/dist/config-DtnR7Lv7.mjs.map +0 -1
- package/dist/index.d.mts +0 -5
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -8
- package/dist/index.mjs.map +0 -1
- package/dist/stream-tui/agent-chat-terminal.d.mts +0 -1
- package/dist/stream-tui/agent-chat-terminal.mjs +0 -933
- package/dist/stream-tui/agent-chat-terminal.mjs.map +0 -1
- package/dist/worker.d.mts +0 -33
- package/dist/worker.mjs +0 -18
- package/dist/worker.mjs.map +0 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { SubscriptionListEntry } from "../api.ts";
|
|
2
|
+
import type { StreamEvent } from "../stream/processor.ts";
|
|
3
|
+
import { type LiveStateItx, type LiveStateSeed } from "./live-state.ts";
|
|
4
|
+
export type LiveStateStatus = "connecting" | "live" | "error";
|
|
5
|
+
/** One live state as a component reads it: the latest value (undefined until the first seed lands),
|
|
6
|
+
* the revision it is at, whether its subscription is connecting, live or failed, and the failure. */
|
|
7
|
+
export type LiveStateResult<S = unknown> = {
|
|
8
|
+
value: S | undefined;
|
|
9
|
+
rev: number | null;
|
|
10
|
+
status: LiveStateStatus;
|
|
11
|
+
error?: string;
|
|
12
|
+
};
|
|
13
|
+
/** Subscribe to a producer's live state and render its latest value. Pass a ready `itx` (a capnweb
|
|
14
|
+
* `api.authenticate(credentials).user` or `.projects.get(id)`), the producer's `key`, and a `readSeed`
|
|
15
|
+
* thunk that reads `{rev, state}` (`() => itx.invoke("itx.facets.get('slug').liveSnapshot()")`).
|
|
16
|
+
* Re-subscribes when the session, `key`, or `name` changes; unmount (and every re-subscribe)
|
|
17
|
+
* disposes the previous server-side subscription. */
|
|
18
|
+
export declare function useLiveState<S>(itx: LiveStateItx | undefined, opts: {
|
|
19
|
+
key: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
readSeed: () => Promise<LiveStateSeed<S>>;
|
|
22
|
+
}): LiveStateResult<S>;
|
|
23
|
+
/** One presence: who acted on the context and when last, from the log's stamps. */
|
|
24
|
+
export type IterateContextPresence = {
|
|
25
|
+
actor: string;
|
|
26
|
+
email?: string;
|
|
27
|
+
grant?: string;
|
|
28
|
+
lastSeenAt: string;
|
|
29
|
+
};
|
|
30
|
+
/** The slice of a context handle `useIterateContext` reads — a capnweb `IterateContextApi` stub
|
|
31
|
+
* satisfies it structurally. `invoke` seeds a named facet's live state
|
|
32
|
+
* (`itx.facets.get('<name>').liveSnapshot()`, as an expression). */
|
|
33
|
+
export type IterateContextHandle = LiveStateItx & {
|
|
34
|
+
readEvents(afterOffset?: number, limit?: number): Promise<{
|
|
35
|
+
events: unknown[];
|
|
36
|
+
atHead: boolean;
|
|
37
|
+
scannedThroughOffset: number;
|
|
38
|
+
}>;
|
|
39
|
+
processors: {
|
|
40
|
+
list(): Promise<SubscriptionListEntry[]> | SubscriptionListEntry[];
|
|
41
|
+
};
|
|
42
|
+
rpcStubs: {
|
|
43
|
+
list(): Promise<string[]> | string[];
|
|
44
|
+
};
|
|
45
|
+
invoke(call: string): Promise<unknown>;
|
|
46
|
+
};
|
|
47
|
+
/** THE ITERATE CONTEXT, live — one hook, one stream subscription. THE LOG: subscribe to every
|
|
48
|
+
* committed event (or `consumes`) BEFORE the catch-up read, so nothing lands between the two;
|
|
49
|
+
* pushes and pages both dedupe into one map by offset; `caughtUp` once the read reached the head;
|
|
50
|
+
* `error` when the connect failed. Off that same log, THE PROCESSORS TABLE, re-read whenever the
|
|
51
|
+
* log grows a row-changing event (a subscription configured, halted or resumed — the table is core
|
|
52
|
+
* state, one call away, no push of its own), and WHO IS HERE: the rpc stubs lent right now
|
|
53
|
+
* (`itx.rpcStubs.list()` — physical, re-read at every new head, since presence changes are
|
|
54
|
+
* ephemeral facts) and, from the log, every principal that acted, newest first. And named facets'
|
|
55
|
+
* LIVE STATE, each seeded through `itx.facets.get('<name>').liveSnapshot()` — one entry per name,
|
|
56
|
+
* always. `liveState` OMITTED opens `core` (the core reduce answers under that name) plus every
|
|
57
|
+
* hosted facet in the processors table the hook holds, following the table as it loads and changes;
|
|
58
|
+
* `liveState` GIVEN is exactly the names to open, no implicit `core`. Re-connects when `itx`
|
|
59
|
+
* changes; unmount disposes every server-side subscription. */
|
|
60
|
+
export declare function useIterateContext(itx: IterateContextHandle | undefined, opts?: {
|
|
61
|
+
consumes?: string[];
|
|
62
|
+
liveState?: string[];
|
|
63
|
+
}): {
|
|
64
|
+
events: StreamEvent[];
|
|
65
|
+
caughtUp: boolean;
|
|
66
|
+
error?: string;
|
|
67
|
+
processors: {
|
|
68
|
+
rows: SubscriptionListEntry[];
|
|
69
|
+
loaded: boolean;
|
|
70
|
+
error?: string;
|
|
71
|
+
};
|
|
72
|
+
presence: {
|
|
73
|
+
actors: IterateContextPresence[];
|
|
74
|
+
rpcStubs: string[];
|
|
75
|
+
};
|
|
76
|
+
liveState: Record<string, LiveStateResult>;
|
|
77
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function openSocketWithRetry(url: string | URL, options?: {
|
|
2
|
+
delaysMs?: readonly number[];
|
|
3
|
+
handshakeTimeoutMs?: number;
|
|
4
|
+
/** the constructor to use — a test's fake, `WebSocket` otherwise */
|
|
5
|
+
WebSocket?: typeof WebSocket;
|
|
6
|
+
sleep?: (ms: number) => Promise<void>;
|
|
7
|
+
}): Promise<WebSocket>;
|
package/dist/client.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { applyPatch } from "./lib.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/client/live-state.ts
|
|
4
|
+
/** The delta as it arrives over the wire — PARSED, never cast: `from`/`to` MUST be real numbers (a
|
|
5
|
+
* non-numeric rev would poison the held revision and silently wedge every later frame), and each
|
|
6
|
+
* patch op is a known RFC-6902-subset shape. A frame that fails this heals by re-reading the seed
|
|
7
|
+
* rather than being applied — the same recovery the store already runs on a revision gap. */
|
|
8
|
+
const LiveStateDeltaMessage = z.object({
|
|
9
|
+
key: z.string(),
|
|
10
|
+
from: z.number(),
|
|
11
|
+
to: z.number(),
|
|
12
|
+
patch: z.array(z.union([
|
|
13
|
+
z.object({
|
|
14
|
+
op: z.literal("add"),
|
|
15
|
+
path: z.string(),
|
|
16
|
+
value: z.unknown()
|
|
17
|
+
}),
|
|
18
|
+
z.object({
|
|
19
|
+
op: z.literal("replace"),
|
|
20
|
+
path: z.string(),
|
|
21
|
+
value: z.unknown()
|
|
22
|
+
}),
|
|
23
|
+
z.object({
|
|
24
|
+
op: z.literal("remove"),
|
|
25
|
+
path: z.string()
|
|
26
|
+
})
|
|
27
|
+
])).nullable()
|
|
28
|
+
});
|
|
29
|
+
function createLiveStateStore() {
|
|
30
|
+
let held = {
|
|
31
|
+
rev: null,
|
|
32
|
+
state: void 0
|
|
33
|
+
};
|
|
34
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
35
|
+
const notify = () => listeners.forEach((l) => l());
|
|
36
|
+
return {
|
|
37
|
+
get: () => held.state,
|
|
38
|
+
rev: () => held.rev,
|
|
39
|
+
subscribe: (listener) => {
|
|
40
|
+
listeners.add(listener);
|
|
41
|
+
return () => void listeners.delete(listener);
|
|
42
|
+
},
|
|
43
|
+
seed: (seed) => {
|
|
44
|
+
if (held.rev !== null && seed.rev < held.rev) return;
|
|
45
|
+
held = {
|
|
46
|
+
rev: seed.rev,
|
|
47
|
+
state: seed.state
|
|
48
|
+
};
|
|
49
|
+
notify();
|
|
50
|
+
},
|
|
51
|
+
apply: (delta, resync) => {
|
|
52
|
+
if (held.rev !== null && delta.to <= held.rev) return;
|
|
53
|
+
if (delta.from !== held.rev || !delta.patch) {
|
|
54
|
+
resync();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
held = {
|
|
58
|
+
rev: delta.to,
|
|
59
|
+
state: applyPatch(held.state, delta.patch)
|
|
60
|
+
};
|
|
61
|
+
notify();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Subscribe to a producer's live state and reduce it into a store. `readSeed` reads the seed
|
|
66
|
+
* (`itx.invoke("itx.facets.get('slug').liveSnapshot()")` for a processor, or a mini-app's
|
|
67
|
+
* own `state()` method). Subscribe happens BEFORE the first seed, so a delta racing the seed just
|
|
68
|
+
* triggers one seed re-read — never a lost update. Gap heals are SINGLE-FLIGHT (a burst of gapped
|
|
69
|
+
* frames triggers one seed read, not one per frame); a failed heal is reported through `onResync`
|
|
70
|
+
* and retried by the next delivered delta (its `from` still mismatches, so it re-triggers). */
|
|
71
|
+
async function connectLiveState(itx, opts) {
|
|
72
|
+
const store = createLiveStateStore();
|
|
73
|
+
let healing = false;
|
|
74
|
+
let healWantedAgain = false;
|
|
75
|
+
let disposed = false;
|
|
76
|
+
const reseed = () => {
|
|
77
|
+
if (disposed) return;
|
|
78
|
+
if (healing) {
|
|
79
|
+
healWantedAgain = true;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
healing = true;
|
|
83
|
+
const settled = () => {
|
|
84
|
+
healing = false;
|
|
85
|
+
if (disposed || !healWantedAgain) return;
|
|
86
|
+
healWantedAgain = false;
|
|
87
|
+
reseed();
|
|
88
|
+
};
|
|
89
|
+
opts.readSeed().then((s) => {
|
|
90
|
+
if (!disposed) {
|
|
91
|
+
store.seed(s);
|
|
92
|
+
opts.onResync?.("healed");
|
|
93
|
+
}
|
|
94
|
+
settled();
|
|
95
|
+
}, (e) => {
|
|
96
|
+
if (!disposed) opts.onResync?.(e instanceof Error ? e : new Error(String(e)));
|
|
97
|
+
settled();
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
const subscription = await itx.subscribe({
|
|
101
|
+
name: opts.name,
|
|
102
|
+
consumes: ["events.iterate.com/itx/live-state-changed"],
|
|
103
|
+
target: (events) => {
|
|
104
|
+
if (disposed) return;
|
|
105
|
+
for (const e of events) {
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = LiveStateDeltaMessage.safeParse(JSON.parse(JSON.stringify(e.payload)));
|
|
109
|
+
} catch {
|
|
110
|
+
parsed = void 0;
|
|
111
|
+
}
|
|
112
|
+
if (!parsed?.success) {
|
|
113
|
+
reseed();
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (parsed.data.key !== opts.key) continue;
|
|
117
|
+
try {
|
|
118
|
+
store.apply(parsed.data, reseed);
|
|
119
|
+
} catch {
|
|
120
|
+
reseed();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
const seed = opts.readSeed();
|
|
127
|
+
const { signal } = opts;
|
|
128
|
+
const aborted = signal && new Promise((_, reject) => {
|
|
129
|
+
const abort = () => reject(signal.reason ?? /* @__PURE__ */ new Error("connectLiveState: aborted while the first seed was pending"));
|
|
130
|
+
if (signal.aborted) abort();
|
|
131
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
132
|
+
});
|
|
133
|
+
if (aborted) seed.catch(() => void 0);
|
|
134
|
+
store.seed(await (aborted ? Promise.race([seed, aborted]) : seed));
|
|
135
|
+
} catch (error) {
|
|
136
|
+
disposed = true;
|
|
137
|
+
try {
|
|
138
|
+
subscription[Symbol.dispose]();
|
|
139
|
+
} catch {}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
store,
|
|
144
|
+
async dispose() {
|
|
145
|
+
if (disposed) return;
|
|
146
|
+
disposed = true;
|
|
147
|
+
try {
|
|
148
|
+
subscription[Symbol.dispose]();
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
export { connectLiveState, createLiveStateStore };
|
|
155
|
+
|
|
156
|
+
//# sourceMappingURL=client.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.mjs","names":[],"sources":["../src/client/live-state.ts"],"sourcesContent":["// client/live-state.ts — THE CLIENT HALF of live state (`iterate/client`), framework-free, for\n// browsers and node test clients. Two concepts:\n// live state store — `createLiveStateStore`: the pure reduce — seed from the producer, apply each delta, heal on a gap\n// live state client — `connectLiveState`: wire an itx session's `subscribe` + a seed read to the store\n\nimport { z } from \"zod\";\nimport { applyPatch, type PatchOp } from \"../lib.ts\";\n\n// ── live state store ── THE CLIENT HALF of live state, for browsers and node test clients.\n// A deliberately small store for the platform's live-state wire:\n//\n// • SEED from the producer — `{rev, state}` read via an RPC method (a processor's\n// `liveSnapshot()`, a mini-app's `state()`).\n// • APPLY each `{key, from, to, patch}` delta the subscription delivers: a patch lands only when\n// its `from` matches the held rev; a mismatch means a missed delta (or a reborn producer's fresh\n// epoch) — resync by re-reading the seed.\n//\n// The patch format is lib.ts (an RFC-6902 subset), so this store shares ONE applyPatch with\n// the producer — no second diff implementation. No capnweb import: a caller wires the transport and\n// hands deltas in, so the same store backs a node test client and the React hook (client/react.tsx).\n\n/** One live-state delta off the wire — the payload of an `events.iterate.com/itx/live-state-changed`\n * ephemeral event, delivered raw to the subscriber. `patch: null` = the change was too large to\n * send — the rev moved, re-read the seed. */\nexport type LiveStateDelta = { key: string; from: number; to: number; patch: PatchOp[] | null };\n\n/** The delta as it arrives over the wire — PARSED, never cast: `from`/`to` MUST be real numbers (a\n * non-numeric rev would poison the held revision and silently wedge every later frame), and each\n * patch op is a known RFC-6902-subset shape. A frame that fails this heals by re-reading the seed\n * rather than being applied — the same recovery the store already runs on a revision gap. */\nconst LiveStateDeltaMessage: z.ZodType<LiveStateDelta> = z.object({\n key: z.string(),\n from: z.number(),\n to: z.number(),\n patch: z\n .array(\n z.union([\n z.object({ op: z.literal(\"add\"), path: z.string(), value: z.unknown() }),\n z.object({ op: z.literal(\"replace\"), path: z.string(), value: z.unknown() }),\n z.object({ op: z.literal(\"remove\"), path: z.string() }),\n ]),\n )\n .nullable(),\n});\n\n/** What the producer's seed read returns: the current revision paired with the current value. */\nexport type LiveStateSeed<S> = { rev: number; state: S };\n\nexport type LiveStateStore<S> = {\n /** The current value, or undefined until the first seed lands. */\n get(): S | undefined;\n /** The held revision, or null before the first seed. */\n rev(): number | null;\n /** Subscribe to changes (for React's useSyncExternalStore, or a test's await-loop). */\n subscribe(listener: () => void): () => void;\n /** Seed (or re-seed) from a seed read — the first paint, and the heal after a gap. */\n seed(seed: LiveStateSeed<S>): void;\n /** Reduce one delta in; on a revision gap call `resync` and hold the value until a fresh seed. */\n apply(delta: LiveStateDelta, resync: () => void): void;\n};\n\nexport function createLiveStateStore<S>(): LiveStateStore<S> {\n // `rev: null` until the first seed lands.\n let held: { rev: number | null; state: S | undefined } = { rev: null, state: undefined };\n const listeners = new Set<() => void>();\n const notify = () => listeners.forEach((l) => l());\n return {\n get: () => held.state,\n rev: () => held.rev,\n subscribe: (listener) => {\n listeners.add(listener);\n return () => void listeners.delete(listener);\n },\n seed: (seed) => {\n // MONOTONIC: a late-resolving OLDER seed read must never move the store backwards past state\n // deltas have already advanced (a delta-triggered resync can race the initial seed). Revisions\n // are time-seeded epochs plus increments, so \"newer\" is numeric.\n if (held.rev !== null && seed.rev < held.rev) return;\n held = { rev: seed.rev, state: seed.state };\n notify();\n },\n apply: (delta, resync) => {\n // A delta at-or-behind the held rev is a duplicate/out-of-order frame — drop it silently.\n // (Epochs are minted from the clock, so a reborn producer's fresh chain sits numerically above\n // every rev an old chain handed out; a frame wholly behind us is genuinely old. The one\n // exception is a clock that regressed across a producer rebirth — accepted: the next applied\n // or gapped frame resyncs from a fresh seed anyway.)\n if (held.rev !== null && delta.to <= held.rev) return;\n // A gap (its `from` is not the held rev — including \"no seed yet\") means a missed delta or a\n // reborn epoch, and a `null` patch means the change was too large to send — either way re-read\n // the seed instead of applying onto a diverged base.\n if (delta.from !== held.rev || !delta.patch) {\n resync();\n return;\n }\n held = { rev: delta.to, state: applyPatch(held.state as S, delta.patch) };\n notify();\n },\n };\n}\n\n// ── live state client ── wire an itx session's `subscribe` + a seed read to a LiveStateStore.\n// This is the whole cleanroom client: a subscription that consumes the one live-state event type\n// (`itx.subscribe({ target, consumes: [\"events.iterate.com/itx/live-state-changed\"] })`) delivers every\n// key's deltas in batches; this filters the watched `key` and reduces each delta into the store; a\n// `readSeed` thunk reads `{rev, state}` for the first paint and every gap heal. Transport lives here so\n// the store above and the React hook stay pure.\n\n/** The slice of an itx session this needs — a capnweb `IterateContextRpcTarget` proxy satisfies it structurally:\n * `subscribe` hands back a DISPOSABLE handle (disposing it removes the subscription server-side). */\nexport type LiveStateItx = {\n subscribe(input: {\n name?: string;\n consumes?: string[];\n target: (events: unknown[], range: unknown) => void;\n }): Promise<{ [Symbol.dispose](): void }>;\n};\n\n/** A connected live-state subscription: the store rendering it, and the dispose that removes the\n * server-side subscription (the handle's disposer; the session's end does the same). */\nexport type LiveStateConnection<S> = {\n store: LiveStateStore<S>;\n /** Unsubscribe on the server and stop reducing deltas. Safe to call more than once. */\n dispose(): Promise<void>;\n};\n\n/** Subscribe to a producer's live state and reduce it into a store. `readSeed` reads the seed\n * (`itx.invoke(\"itx.facets.get('slug').liveSnapshot()\")` for a processor, or a mini-app's\n * own `state()` method). Subscribe happens BEFORE the first seed, so a delta racing the seed just\n * triggers one seed re-read — never a lost update. Gap heals are SINGLE-FLIGHT (a burst of gapped\n * frames triggers one seed read, not one per frame); a failed heal is reported through `onResync`\n * and retried by the next delivered delta (its `from` still mismatches, so it re-triggers). */\nexport async function connectLiveState<S>(\n itx: LiveStateItx,\n opts: {\n key: string;\n name?: string;\n readSeed: () => Promise<LiveStateSeed<S>>;\n /** Called after each gap heal attempt: \"healed\" on a fresh seed, the error when the seed read\n * failed (the store keeps its last value; the next delta retries). */\n onResync?: (result: \"healed\" | Error) => void;\n /** Abort while the FIRST seed is still pending (a component unmounting): the row just configured\n * is recalled and the connect rejects — a seed read that never answers leaves nothing lent. */\n signal?: AbortSignal;\n },\n): Promise<LiveStateConnection<S>> {\n const store = createLiveStateStore<S>();\n let healing = false;\n let healWantedAgain = false; // a gap seen WHILE a heal was in flight: the seed may predate it\n let disposed = false;\n const reseed = () => {\n if (disposed) return;\n if (healing) {\n healWantedAgain = true;\n return;\n }\n healing = true;\n const settled = () => {\n healing = false;\n if (disposed || !healWantedAgain) return;\n healWantedAgain = false;\n reseed();\n };\n void opts.readSeed().then(\n (s) => {\n if (!disposed) {\n store.seed(s);\n opts.onResync?.(\"healed\");\n }\n settled();\n },\n (e: unknown) => {\n if (!disposed) opts.onResync?.(e instanceof Error ? e : new Error(String(e)));\n settled();\n },\n );\n };\n const subscription = await itx.subscribe({\n name: opts.name,\n consumes: [\"events.iterate.com/itx/live-state-changed\"],\n // A batch of live-state deltas (every key's); keep the watched key's.\n target: (events: unknown[]) => {\n if (disposed) return;\n for (const e of events) {\n // capnweb hands each event as a live proxy value — deep-copy to a plain object, then PARSE\n // the frame (never cast network data). The whole decode is guarded: an ABSENT payload makes\n // `JSON.parse(JSON.stringify(undefined))` throw before validation, and any throw here would\n // skip every later delta in the batch. A malformed OR undecodable frame heals from a fresh seed\n // instead of poisoning the held rev or escaping this callback.\n let parsed: ReturnType<(typeof LiveStateDeltaMessage)[\"safeParse\"]> | undefined;\n try {\n parsed = LiveStateDeltaMessage.safeParse(\n JSON.parse(JSON.stringify((e as { payload: unknown }).payload)),\n );\n } catch {\n parsed = undefined;\n }\n if (!parsed?.success) {\n reseed();\n continue;\n }\n if (parsed.data.key !== opts.key) continue;\n // `store.apply` runs `applyPatch`, which THROWS on a patch it refuses (a `/__proto__` path a\n // legitimate state with an own `__proto__` key produces, say). Contain it per frame: heal from\n // a fresh seed — which re-seeds the state DIRECTLY, no patch to reject — instead of escaping this\n // callback and skipping every later frame.\n try {\n store.apply(parsed.data, reseed);\n } catch {\n reseed();\n }\n }\n },\n });\n try {\n const seed = opts.readSeed();\n const { signal } = opts;\n const aborted =\n signal &&\n new Promise<never>((_, reject) => {\n const abort = () =>\n reject(\n signal.reason ??\n new Error(\"connectLiveState: aborted while the first seed was pending\"),\n );\n if (signal.aborted) abort();\n else signal.addEventListener(\"abort\", abort, { once: true });\n });\n if (aborted) seed.catch(() => undefined); // the seed read may still settle after the abort — quietly\n store.seed(await (aborted ? Promise.race([seed, aborted]) : seed));\n } catch (error) {\n // The seed failed after the row was configured: recall it, or the server keeps delivering to a\n // callback no one holds (and the session's other rows wait behind it).\n disposed = true;\n try {\n subscription[Symbol.dispose]();\n } catch {\n // a dead session has already removed it\n }\n throw error;\n }\n return {\n store,\n async dispose() {\n if (disposed) return;\n disposed = true;\n try {\n subscription[Symbol.dispose](); // the server removes the row and recalls the lent callback\n } catch {\n // a dead session has already removed it — the socket close disposed every handle\n }\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { RpcTarget } from "capnweb";
|
|
2
|
+
/** One step: a property read (string) or a call (`[method, ...args]`). Args are plain JSON. The
|
|
3
|
+
* method `""` is the ANONYMOUS call — call the value itself: `itx.builtins.rpcStubs.get('cam')(1, 2)`
|
|
4
|
+
* is `["itx","builtins","rpcStubs",["get","cam"],["",1,2]]` — what a `provide(stub)` rule spells when
|
|
5
|
+
* the lent stub is called with args. */
|
|
6
|
+
export type ItxExpressionStep = string | [method: string, ...args: unknown[]];
|
|
7
|
+
/** An itx expression as data: the scope root (`itx`) then get/call steps. THE parsed form every
|
|
8
|
+
* dispatching method works on. */
|
|
9
|
+
export type ItxExpression = ItxExpressionStep[];
|
|
10
|
+
/** THE dispatch target, in EITHER codec half — a dotted string that starts with the scope root
|
|
11
|
+
* (`"itx.facets.get('core')"`) OR the parsed structured form (`["itx","facets",["get","core"]]`).
|
|
12
|
+
* Both carry call args (the string via `.method(args)`), and `normalizedItxExpression` normalizes
|
|
13
|
+
* either to the structured form — so either works wherever one works, in every method that dispatches. */
|
|
14
|
+
export type ItxExpressionInput = string | ItxExpression;
|
|
15
|
+
/** An itx-expression PREFIX — a rewrite rule's `match`: dotted names, any of which may be a call step
|
|
16
|
+
* PINNING literal args — `itx.ai.run` or `itx.ai.run('gpt-5')` or `itx.repo.get('main').files`. A
|
|
17
|
+
* pinned arg must equal the call's arg at that position for the rule to match, and is CONSUMED by
|
|
18
|
+
* the match (partial application): `itx.ai.run('gpt-5') ⇒ itx.openai.chat` makes
|
|
19
|
+
* `itx.ai.run('gpt-5', inputs)` into `itx.openai.chat(inputs)`. */
|
|
20
|
+
export type ItxExpressionPrefix = ItxExpression;
|
|
21
|
+
/** The name a step carries: the property itself, or a call step's method. */
|
|
22
|
+
export declare const itxExpressionStepName: (step: ItxExpressionStep | undefined) => string | undefined;
|
|
23
|
+
/** The merge entry's key — `...@` — read by apps/os `fillItxExpressionHoles`. */
|
|
24
|
+
export declare const ITX_EXPRESSION_MERGE_KEY = "...@";
|
|
25
|
+
/** Is `value` the marker literal `{ "@": true }`? */
|
|
26
|
+
export declare const isItxExpressionHole: (value: unknown) => boolean;
|
|
27
|
+
/** Does `value` (a step, an arg tree, a whole expression) hold the marker or a merge entry anywhere? */
|
|
28
|
+
export declare function containsItxExpressionHole(value: unknown): boolean;
|
|
29
|
+
/** Parse the STRING half: dotted names + `.method(args)` calls (args JSON5-parsed); rejects reserved
|
|
30
|
+
* names + bare scope calls. `holes: true` — a rewrite rule's TARGET only — lexes `@` / `...@` into
|
|
31
|
+
* the marker literals; anywhere else a bare `@` is refused. */
|
|
32
|
+
export declare function parse(source: string, options?: {
|
|
33
|
+
holes?: boolean;
|
|
34
|
+
}): ItxExpression;
|
|
35
|
+
/** THE ONE NORMALIZER: either half, normalized to the array half and checked — a string is
|
|
36
|
+
* parsed (short by rule), an array is shape-checked in place. Every function that takes an
|
|
37
|
+
* `ItxExpressionInput` (the edge `invoke`, the resolver, the event builders, the prefix parser
|
|
38
|
+
* below) enters through it. */
|
|
39
|
+
export declare function normalizedItxExpression(input: ItxExpressionInput, options?: {
|
|
40
|
+
holes?: boolean;
|
|
41
|
+
}): ItxExpression;
|
|
42
|
+
/** Object args print with their keys SORTED, so two spellings of one object are one canonical string
|
|
43
|
+
* — one rewrite-rule row, one facet memo, one library connection memo (library.ts) — the way
|
|
44
|
+
* `jsonEqual` already matches them. A `JSON.stringify` / `JSON5.stringify` replacer. */
|
|
45
|
+
export declare const keySortedForPrint: (_key: string, value: unknown) => unknown;
|
|
46
|
+
/** Canonical stored form: dotted path + `.method(args)` calls (args `JSON5.stringify`d, object keys
|
|
47
|
+
* sorted). `holes: true` — a rewrite rule's TARGET only — spells the marker literals back as `@` /
|
|
48
|
+
* `...@`, and `parse(print(e, { holes: true }), { holes: true })` round-trips; without it the
|
|
49
|
+
* reserved literals print as the plain JSON5 they are, so a CALL that happens to carry `{ "@": true }`
|
|
50
|
+
* as data round-trips through `parse` (no holes) unchanged — the resolve/invoke law holds for it. */
|
|
51
|
+
export declare function print(expr: ItxExpression, options?: {
|
|
52
|
+
holes?: boolean;
|
|
53
|
+
}): string;
|
|
54
|
+
/** Parse an itx-expression prefix (either codec half) — `normalizedItxExpression` (so every step is
|
|
55
|
+
* an identifier that is not reserved, in either half) plus the two refusals only a PREFIX has: the
|
|
56
|
+
* anonymous call step (`f(x)(y)` — a prefix cannot call a result), and a call step with NO args,
|
|
57
|
+
* which pins nothing and is the same prefix as the plain name: spell `itx.ai.run`. */
|
|
58
|
+
export declare function parseItxExpressionPrefix(source: ItxExpressionInput): ItxExpressionPrefix;
|
|
59
|
+
/** THE ONE canonical spelling of an itx-expression prefix — the rewrite-rule table's key, what a lent
|
|
60
|
+
* stub is keyed by through `provide`'s sugar: parsed, then printed (dotted names; pinned args as JSON5
|
|
61
|
+
* literals). */
|
|
62
|
+
export declare function canonicalItxExpressionPrefix(source: ItxExpressionInput): string;
|
|
63
|
+
/** Install the hop drawn in the header on a class's PROTOTYPE CHAIN, with the scope `root` (`["itx"]`
|
|
64
|
+
* for the edge context, `[]` for a handle). Call ONCE per class. Constructor inheritance is
|
|
65
|
+
* untouched — only `Class.prototype`'s parent link changes, and the hop forwards everything it does
|
|
66
|
+
* not intercept. */
|
|
67
|
+
export declare function installPrototypeInvokeFallback<T extends abstract new (...args: never[]) => object>(cls: T, root: readonly string[]): void;
|
|
68
|
+
/** A branded, pipelinable handle for a MID-CHAIN capability (`facets.get(name)`, `cd(path)`,
|
|
69
|
+
* `workers.get(spec)`, a lent stub) whose unknown dotted members reduce into ONE dispatch of the
|
|
70
|
+
* itx-expression STEPS relative to it; the constructor's `dispatch` routes those steps into the
|
|
71
|
+
* underlying object. Declared members (`invoke` / `applyRoot`) win over the fallback, so a
|
|
72
|
+
* capability cannot be named either — the two reserved words this wrapper adds. */
|
|
73
|
+
export declare class InvokeHandle extends RpcTarget {
|
|
74
|
+
#private;
|
|
75
|
+
constructor(dispatchItxExpressionSteps: (itxExpressionSteps: ItxExpression) => unknown);
|
|
76
|
+
/** THE dispatch method the prototype hop reduces onto; the expression is RELATIVE to this handle. */
|
|
77
|
+
invoke(itxExpressionSteps: ItxExpression): unknown;
|
|
78
|
+
/** Call the bare capability this handle fronts — the ANONYMOUS call step (how a rewritten call
|
|
79
|
+
* whose target IS a handle calls it: `handle(events, range)`). */
|
|
80
|
+
applyRoot(args: unknown[]): unknown;
|
|
81
|
+
}
|
|
82
|
+
/** Walk itx-expression steps off a capnweb stub; the ANONYMOUS call step (`""`) calls the value
|
|
83
|
+
* itself (a bare function lent as a capability). NO await inside the loop: on a capnweb stub every
|
|
84
|
+
* step is a PIPELINED path, so an n-step chain costs ONE round trip, flushed by the caller's single
|
|
85
|
+
* await. A DIRECT call on the stub, never `.apply`: reading `.apply` off a capnweb stub's method is
|
|
86
|
+
* itself a pipelined remote path, and calling it sends the stub as an argument, which a facet stub
|
|
87
|
+
* refuses with a DataCloneError. What a connector over a lent stub or a remote capnweb API walks. */
|
|
88
|
+
export declare function walkStepsOnRpcStub(stub: unknown, steps: ItxExpression): unknown;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { codedError, jsonEqual } from "./lib.mjs";
|
|
2
|
+
import { RpcTarget } from "capnweb";
|
|
3
|
+
import JSON5 from "json5";
|
|
4
|
+
//#region src/expression.ts
|
|
5
|
+
/** A STRING expression is for what a person types: short. Anything bigger — a worker's source, a large
|
|
6
|
+
* literal — rides the PARSED form (`["itx","workers",["get",{ source }]]`), which is plain data and never
|
|
7
|
+
* meets json5. The cap is O(1), before any parsing (stock json5 allocates per character and a
|
|
8
|
+
* multi-megabyte literal kills a 128 MiB isolate). */
|
|
9
|
+
const ITX_EXPRESSION_STRING_MAX_CHARS = 2048;
|
|
10
|
+
/** The name a step carries: the property itself, or a call step's method. */
|
|
11
|
+
const itxExpressionStepName = (step) => Array.isArray(step) ? step[0] : step;
|
|
12
|
+
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$-]*/;
|
|
13
|
+
const RESERVED = new Set([
|
|
14
|
+
"__proto__",
|
|
15
|
+
"constructor",
|
|
16
|
+
"prototype"
|
|
17
|
+
]);
|
|
18
|
+
/** The marker's array-half spelling, the one reserved literal. */
|
|
19
|
+
const ITX_EXPRESSION_HOLE = { "@": true };
|
|
20
|
+
/** The merge entry's key — `...@` — read by apps/os `fillItxExpressionHoles`. */
|
|
21
|
+
const ITX_EXPRESSION_MERGE_KEY = "...@";
|
|
22
|
+
/** A single- or double-quoted string literal (escapes honored) or a JSON5 comment (block or line):
|
|
23
|
+
* THE one pattern every walk that must skip what is inside them is built from — the marker lex, the
|
|
24
|
+
* marker print, the paren matcher. In an alternation a span is consumed whole, so nothing inside one
|
|
25
|
+
* (a quote in a comment, an `@` in a string) is ever seen by the other alternatives. */
|
|
26
|
+
const STRING_OR_COMMENT = String.raw`"(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*'|/\*[\s\S]*?\*/|//[^\n]*`;
|
|
27
|
+
const isStringOrComment = (match) => match[0] === "\"" || match[0] === "'" || match[0] === "/";
|
|
28
|
+
/** In call args: a literal (kept verbatim) or a marker — `...@` before `@`, so the merge form wins. */
|
|
29
|
+
const MARKERS_IN_ARGS = new RegExp(`${STRING_OR_COMMENT}|\\.\\.\\.@|@`, "g");
|
|
30
|
+
/** In JSON5's printed output: the marker literal `{'@':true}` and the merge entry `'...@':true` are
|
|
31
|
+
* spelled with a single-quoted key and matched on those exact boundaries — listed BEFORE the literal
|
|
32
|
+
* alternative so the entry's `'...@'` is read as the entry, not as a string. A user's string that
|
|
33
|
+
* merely contains those characters is emitted by JSON5 as a longer (double-quoted) literal and is
|
|
34
|
+
* consumed whole. */
|
|
35
|
+
const MARKERS_IN_PRINT = new RegExp(`\\{'@':true\\}|'\\.\\.\\.@':true|${STRING_OR_COMMENT}`, "g");
|
|
36
|
+
/** A bracket outside a literal. */
|
|
37
|
+
const BRACKETS = new RegExp(`${STRING_OR_COMMENT}|[()[\\]{}]`, "g");
|
|
38
|
+
/** Is `value` the marker literal `{ "@": true }`? */
|
|
39
|
+
const isItxExpressionHole = (value) => jsonEqual(value, ITX_EXPRESSION_HOLE);
|
|
40
|
+
/** Does `value` (a step, an arg tree, a whole expression) hold the marker or a merge entry anywhere? */
|
|
41
|
+
function containsItxExpressionHole(value) {
|
|
42
|
+
if (isItxExpressionHole(value)) return true;
|
|
43
|
+
if (Array.isArray(value)) return value.some(containsItxExpressionHole);
|
|
44
|
+
if (value !== null && typeof value === "object") return value["...@"] === true || Object.values(value).some(containsItxExpressionHole);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
/** Index of the `)` closing the `(` at `open`; tracks bracket depth, skipping quoted string args. */
|
|
48
|
+
function matchingParen(source, open) {
|
|
49
|
+
let depth = 0;
|
|
50
|
+
BRACKETS.lastIndex = open;
|
|
51
|
+
for (let bracket = BRACKETS.exec(source); bracket; bracket = BRACKETS.exec(source)) {
|
|
52
|
+
if (isStringOrComment(bracket[0])) continue;
|
|
53
|
+
if ("([{".includes(bracket[0])) depth++;
|
|
54
|
+
else if (--depth === 0) return bracket.index;
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`expression: unbalanced "(" in ${JSON.stringify(source)}`);
|
|
57
|
+
}
|
|
58
|
+
/** Parse the STRING half: dotted names + `.method(args)` calls (args JSON5-parsed); rejects reserved
|
|
59
|
+
* names + bare scope calls. `holes: true` — a rewrite rule's TARGET only — lexes `@` / `...@` into
|
|
60
|
+
* the marker literals; anywhere else a bare `@` is refused. */
|
|
61
|
+
function parse(source, options) {
|
|
62
|
+
if (source.length > ITX_EXPRESSION_STRING_MAX_CHARS) throw codedError("EXPRESSION_TOO_LONG", `itx expression: ${source.length} chars is over the ${ITX_EXPRESSION_STRING_MAX_CHARS}-char limit for the string form — a string expression is for what a person types; pass the parsed form instead: ["itx","workers",["get",{ source: … }]]`);
|
|
63
|
+
const s = source.trim();
|
|
64
|
+
const steps = [];
|
|
65
|
+
let i = 0;
|
|
66
|
+
function fail(m) {
|
|
67
|
+
throw new Error(`expression: ${m} in ${JSON.stringify(source)}`);
|
|
68
|
+
}
|
|
69
|
+
const readName = () => {
|
|
70
|
+
const m = IDENT.exec(s.slice(i));
|
|
71
|
+
if (!m) fail(`name expected at ${i}`);
|
|
72
|
+
if (RESERVED.has(m[0])) fail(`reserved name "${m[0]}"`);
|
|
73
|
+
i += m[0].length;
|
|
74
|
+
return m[0];
|
|
75
|
+
};
|
|
76
|
+
steps.push(readName());
|
|
77
|
+
while (i < s.length) {
|
|
78
|
+
const c = s[i];
|
|
79
|
+
if (/\s/.test(c)) i++;
|
|
80
|
+
else if (c === ".") steps.push((i++, readName()));
|
|
81
|
+
else if (c === "(") {
|
|
82
|
+
const end = matchingParen(s, i);
|
|
83
|
+
const inner = s.slice(i + 1, end).trim().replace(MARKERS_IN_ARGS, (match) => {
|
|
84
|
+
if (isStringOrComment(match)) return match;
|
|
85
|
+
if (!options?.holes) fail("`@` (the caller's input) is legal only in a rewrite rule's target");
|
|
86
|
+
return match === "@" ? JSON.stringify(ITX_EXPRESSION_HOLE) : `${JSON.stringify(ITX_EXPRESSION_MERGE_KEY)}:true`;
|
|
87
|
+
});
|
|
88
|
+
let args = [];
|
|
89
|
+
try {
|
|
90
|
+
if (inner !== "") args = JSON5.parse(`[${inner}]`);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
fail(`call args are not JSON5 (${e.message})`);
|
|
93
|
+
}
|
|
94
|
+
const previous = steps.at(-1);
|
|
95
|
+
if (Array.isArray(previous)) steps.push(["", ...args]);
|
|
96
|
+
else {
|
|
97
|
+
const name = steps.pop();
|
|
98
|
+
if (typeof name !== "string") fail("a call must follow a name");
|
|
99
|
+
if (steps.length === 0) fail("cannot call the scope symbol itself");
|
|
100
|
+
steps.push([name, ...args]);
|
|
101
|
+
}
|
|
102
|
+
i = end + 1;
|
|
103
|
+
} else fail(`unexpected ${JSON.stringify(c)} at ${i}`);
|
|
104
|
+
}
|
|
105
|
+
return steps;
|
|
106
|
+
}
|
|
107
|
+
/** The array half, checked the way the parser checks the string half — every name step an identifier
|
|
108
|
+
* that is not reserved, every call step `[method, ...args]` with an identifier method (or `""`, the
|
|
109
|
+
* anonymous call, only right after a call) — WITHOUT printing and re-parsing: a stored target carries a worker's whole source as
|
|
110
|
+
* data, and that data must never meet the string codec (the 2 KiB cap, json5). Throws in the
|
|
111
|
+
* parser's words. */
|
|
112
|
+
function assertItxExpressionShape(expression) {
|
|
113
|
+
const fail = (m) => {
|
|
114
|
+
throw new Error(`expression: ${m} in ${JSON.stringify(expression).slice(0, 200)}`);
|
|
115
|
+
};
|
|
116
|
+
if (!Array.isArray(expression) || expression.length === 0) fail("an expression is a non-empty array");
|
|
117
|
+
const name = (step, what) => {
|
|
118
|
+
if (!IDENT.test(step) || IDENT.exec(step)[0] !== step) fail(`${what} ${JSON.stringify(step)} is not an identifier`);
|
|
119
|
+
if (RESERVED.has(step)) fail(`reserved name "${step}"`);
|
|
120
|
+
};
|
|
121
|
+
expression.forEach((step, i) => {
|
|
122
|
+
if (typeof step === "string") {
|
|
123
|
+
name(step, i === 0 ? "the root" : "a name step");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(step) || typeof step[0] !== "string") fail(`step ${i} is neither a name nor [method, ...args]`);
|
|
127
|
+
const [method] = step;
|
|
128
|
+
if (method === "") {
|
|
129
|
+
if (i === 0 || !Array.isArray(expression[i - 1])) fail("the anonymous call `f(x)(y)` follows a call");
|
|
130
|
+
} else name(method, "a method");
|
|
131
|
+
if (i === 0) fail("a call on the root itself");
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** THE ONE NORMALIZER: either half, normalized to the array half and checked — a string is
|
|
135
|
+
* parsed (short by rule), an array is shape-checked in place. Every function that takes an
|
|
136
|
+
* `ItxExpressionInput` (the edge `invoke`, the resolver, the event builders, the prefix parser
|
|
137
|
+
* below) enters through it. */
|
|
138
|
+
function normalizedItxExpression(input, options) {
|
|
139
|
+
if (typeof input === "string") return parse(input, options);
|
|
140
|
+
assertItxExpressionShape(input);
|
|
141
|
+
return input;
|
|
142
|
+
}
|
|
143
|
+
/** Object args print with their keys SORTED, so two spellings of one object are one canonical string
|
|
144
|
+
* — one rewrite-rule row, one facet memo, one library connection memo (library.ts) — the way
|
|
145
|
+
* `jsonEqual` already matches them. A `JSON.stringify` / `JSON5.stringify` replacer. */
|
|
146
|
+
const keySortedForPrint = (_key, value) => value !== null && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.keys(value).sort().map((k) => [k, value[k]])) : value;
|
|
147
|
+
/** Canonical stored form: dotted path + `.method(args)` calls (args `JSON5.stringify`d, object keys
|
|
148
|
+
* sorted). `holes: true` — a rewrite rule's TARGET only — spells the marker literals back as `@` /
|
|
149
|
+
* `...@`, and `parse(print(e, { holes: true }), { holes: true })` round-trips; without it the
|
|
150
|
+
* reserved literals print as the plain JSON5 they are, so a CALL that happens to carry `{ "@": true }`
|
|
151
|
+
* as data round-trips through `parse` (no holes) unchanged — the resolve/invoke law holds for it. */
|
|
152
|
+
function print(expr, options) {
|
|
153
|
+
return expr.map((step, i) => {
|
|
154
|
+
const dot = i ? "." : "";
|
|
155
|
+
if (typeof step === "string") return dot + step;
|
|
156
|
+
const json = JSON5.stringify(step.slice(1), keySortedForPrint).slice(1, -1);
|
|
157
|
+
const args = options?.holes ? json.replace(MARKERS_IN_PRINT, (match) => match === "{'@':true}" ? "@" : match === "'...@':true" ? "...@" : match) : json;
|
|
158
|
+
return step[0] === "" ? `(${args})` : `${dot}${step[0]}(${args})`;
|
|
159
|
+
}).join("");
|
|
160
|
+
}
|
|
161
|
+
/** Parse an itx-expression prefix (either codec half) — `normalizedItxExpression` (so every step is
|
|
162
|
+
* an identifier that is not reserved, in either half) plus the two refusals only a PREFIX has: the
|
|
163
|
+
* anonymous call step (`f(x)(y)` — a prefix cannot call a result), and a call step with NO args,
|
|
164
|
+
* which pins nothing and is the same prefix as the plain name: spell `itx.ai.run`. */
|
|
165
|
+
function parseItxExpressionPrefix(source) {
|
|
166
|
+
const expr = normalizedItxExpression(source);
|
|
167
|
+
const spelled = typeof source === "string" ? source : print(expr);
|
|
168
|
+
for (const step of expr) {
|
|
169
|
+
if (!Array.isArray(step)) continue;
|
|
170
|
+
if (step[0] === "") throw new Error(`an itx-expression prefix cannot call a result — ${JSON.stringify(spelled)}`);
|
|
171
|
+
if (step.length === 1) throw new Error(`an itx-expression prefix pins literal args with a call step — ${JSON.stringify(spelled)} has "${step[0]}()" with none; spell "${step[0]}"`);
|
|
172
|
+
}
|
|
173
|
+
return expr;
|
|
174
|
+
}
|
|
175
|
+
/** THE ONE canonical spelling of an itx-expression prefix — the rewrite-rule table's key, what a lent
|
|
176
|
+
* stub is keyed by through `provide`'s sugar: parsed, then printed (dotted names; pinned args as JSON5
|
|
177
|
+
* literals). */
|
|
178
|
+
function canonicalItxExpressionPrefix(source) {
|
|
179
|
+
return print(parseItxExpressionPrefix(source));
|
|
180
|
+
}
|
|
181
|
+
/** Names that must NEVER become dynamic capability segments — a dispatcher answering them would turn
|
|
182
|
+
* a plain property probe into a live capability call. Enforced at the prototype-chain hop and at
|
|
183
|
+
* every depth of the path proxies it hands out. Two kinds, one set: */
|
|
184
|
+
const RESERVED_SEGMENT_NAMES = new Set([
|
|
185
|
+
"__defineGetter__",
|
|
186
|
+
"__defineSetter__",
|
|
187
|
+
"__lookupGetter__",
|
|
188
|
+
"__lookupSetter__",
|
|
189
|
+
"__proto__",
|
|
190
|
+
"apply",
|
|
191
|
+
"bind",
|
|
192
|
+
"call",
|
|
193
|
+
"catch",
|
|
194
|
+
"constructor",
|
|
195
|
+
"dup",
|
|
196
|
+
"finally",
|
|
197
|
+
"hasOwnProperty",
|
|
198
|
+
"isPrototypeOf",
|
|
199
|
+
"map",
|
|
200
|
+
"onRpcBroken",
|
|
201
|
+
"propertyIsEnumerable",
|
|
202
|
+
"prototype",
|
|
203
|
+
"then",
|
|
204
|
+
"toLocaleString",
|
|
205
|
+
"toString",
|
|
206
|
+
"valueOf",
|
|
207
|
+
"toJSON",
|
|
208
|
+
"asymmetricMatch"
|
|
209
|
+
]);
|
|
210
|
+
/** The path proxy: a function-backed Proxy (not an RpcTarget instance) — each missing property
|
|
211
|
+
* extends `path`, and applying the function reduces the whole accumulated access into ONE
|
|
212
|
+
* `invoke(expression)` call, `[...root, ...path.slice(0, -1), [path.at(-1), ...args]]`. */
|
|
213
|
+
function createItxExpressionPathProxy(invoker, root, path) {
|
|
214
|
+
const valueFor = (key) => createItxExpressionPathProxy(invoker, root, [...path, key]);
|
|
215
|
+
return new Proxy(function() {}, {
|
|
216
|
+
apply(_target, _thisArg, args) {
|
|
217
|
+
const method = path[path.length - 1];
|
|
218
|
+
const expr = [
|
|
219
|
+
...root,
|
|
220
|
+
...path.slice(0, -1),
|
|
221
|
+
[method, ...args]
|
|
222
|
+
];
|
|
223
|
+
return invoker.invoke(expr);
|
|
224
|
+
},
|
|
225
|
+
get(target, key, receiver) {
|
|
226
|
+
if (typeof key === "symbol") return Reflect.get(target, key, receiver);
|
|
227
|
+
if (RESERVED_SEGMENT_NAMES.has(key)) return void 0;
|
|
228
|
+
return valueFor(key);
|
|
229
|
+
},
|
|
230
|
+
getOwnPropertyDescriptor(target, key) {
|
|
231
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(target, key);
|
|
232
|
+
if (descriptor) return descriptor;
|
|
233
|
+
if (typeof key === "symbol" || RESERVED_SEGMENT_NAMES.has(key)) return void 0;
|
|
234
|
+
return {
|
|
235
|
+
configurable: true,
|
|
236
|
+
enumerable: true,
|
|
237
|
+
value: valueFor(key),
|
|
238
|
+
writable: false
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
has(target, key) {
|
|
242
|
+
if (typeof key === "symbol") return key in target;
|
|
243
|
+
return !RESERVED_SEGMENT_NAMES.has(key);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/** Install the hop drawn in the header on a class's PROTOTYPE CHAIN, with the scope `root` (`["itx"]`
|
|
248
|
+
* for the edge context, `[]` for a handle). Call ONCE per class. Constructor inheritance is
|
|
249
|
+
* untouched — only `Class.prototype`'s parent link changes, and the hop forwards everything it does
|
|
250
|
+
* not intercept. */
|
|
251
|
+
function installPrototypeInvokeFallback(cls, root) {
|
|
252
|
+
const parentPrototype = Object.getPrototypeOf(cls.prototype);
|
|
253
|
+
const hop = new Proxy(Object.create(parentPrototype), { get(hopTarget, key, receiver) {
|
|
254
|
+
if (typeof key === "symbol" || key in hopTarget) return Reflect.get(hopTarget, key, receiver);
|
|
255
|
+
if (RESERVED_SEGMENT_NAMES.has(key)) return void 0;
|
|
256
|
+
if (!(receiver instanceof cls)) return;
|
|
257
|
+
return createItxExpressionPathProxy(receiver, root, [key]);
|
|
258
|
+
} });
|
|
259
|
+
Object.setPrototypeOf(cls.prototype, hop);
|
|
260
|
+
}
|
|
261
|
+
/** A branded, pipelinable handle for a MID-CHAIN capability (`facets.get(name)`, `cd(path)`,
|
|
262
|
+
* `workers.get(spec)`, a lent stub) whose unknown dotted members reduce into ONE dispatch of the
|
|
263
|
+
* itx-expression STEPS relative to it; the constructor's `dispatch` routes those steps into the
|
|
264
|
+
* underlying object. Declared members (`invoke` / `applyRoot`) win over the fallback, so a
|
|
265
|
+
* capability cannot be named either — the two reserved words this wrapper adds. */
|
|
266
|
+
var InvokeHandle = class extends RpcTarget {
|
|
267
|
+
#dispatchItxExpressionSteps;
|
|
268
|
+
constructor(dispatchItxExpressionSteps) {
|
|
269
|
+
super();
|
|
270
|
+
this.#dispatchItxExpressionSteps = dispatchItxExpressionSteps;
|
|
271
|
+
}
|
|
272
|
+
/** THE dispatch method the prototype hop reduces onto; the expression is RELATIVE to this handle. */
|
|
273
|
+
invoke(itxExpressionSteps) {
|
|
274
|
+
return this.#dispatchItxExpressionSteps(itxExpressionSteps);
|
|
275
|
+
}
|
|
276
|
+
/** Call the bare capability this handle fronts — the ANONYMOUS call step (how a rewritten call
|
|
277
|
+
* whose target IS a handle calls it: `handle(events, range)`). */
|
|
278
|
+
applyRoot(args) {
|
|
279
|
+
return this.#dispatchItxExpressionSteps([["", ...args]]);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
installPrototypeInvokeFallback(InvokeHandle, []);
|
|
283
|
+
/** Walk itx-expression steps off a capnweb stub; the ANONYMOUS call step (`""`) calls the value
|
|
284
|
+
* itself (a bare function lent as a capability). NO await inside the loop: on a capnweb stub every
|
|
285
|
+
* step is a PIPELINED path, so an n-step chain costs ONE round trip, flushed by the caller's single
|
|
286
|
+
* await. A DIRECT call on the stub, never `.apply`: reading `.apply` off a capnweb stub's method is
|
|
287
|
+
* itself a pipelined remote path, and calling it sends the stub as an argument, which a facet stub
|
|
288
|
+
* refuses with a DataCloneError. What a connector over a lent stub or a remote capnweb API walks. */
|
|
289
|
+
function walkStepsOnRpcStub(stub, steps) {
|
|
290
|
+
let value = stub;
|
|
291
|
+
for (const step of steps) if (typeof step === "string") value = value[step];
|
|
292
|
+
else {
|
|
293
|
+
const [method, ...args] = step;
|
|
294
|
+
value = method === "" ? value(...args) : value[method](...args);
|
|
295
|
+
}
|
|
296
|
+
return value;
|
|
297
|
+
}
|
|
298
|
+
//#endregion
|
|
299
|
+
export { ITX_EXPRESSION_MERGE_KEY, InvokeHandle, canonicalItxExpressionPrefix, containsItxExpressionHole, installPrototypeInvokeFallback, isItxExpressionHole, itxExpressionStepName, keySortedForPrint, normalizedItxExpression, parse, parseItxExpressionPrefix, print, walkStepsOnRpcStub };
|
|
300
|
+
|
|
301
|
+
//# sourceMappingURL=expression.mjs.map
|