@volter/twin 0.1.0 → 0.1.1

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.
Files changed (84) hide show
  1. package/README.md +16 -2
  2. package/inject.cjs +453 -59
  3. package/package.json +12 -22
  4. package/src/actions.ts +234 -49
  5. package/src/blob-store.ts +136 -0
  6. package/src/changeset.ts +807 -0
  7. package/src/cli.ts +60 -10
  8. package/src/connector.ts +30 -7
  9. package/src/control-plane.ts +17 -1
  10. package/src/emit.ts +242 -0
  11. package/src/fork.ts +19 -7
  12. package/src/index.ts +139 -6
  13. package/src/lease.ts +4 -6
  14. package/src/lifecycle.ts +8 -0
  15. package/src/packRegistry.ts +248 -2
  16. package/src/plan.ts +131 -23
  17. package/src/proxy.ts +5 -2
  18. package/src/pushLedger.ts +116 -11
  19. package/src/queueLifecycle.ts +3 -4
  20. package/src/rateBudget.ts +1115 -0
  21. package/src/refs.ts +9 -10
  22. package/src/remote-execute.ts +16 -0
  23. package/src/scenario.ts +387 -0
  24. package/src/serve.ts +397 -15
  25. package/src/shadow.ts +86 -7
  26. package/src/storage.ts +76 -147
  27. package/src/sync.ts +63 -17
  28. package/src/twin-fetch.ts +115 -0
  29. package/src/validate.ts +6 -5
  30. package/src/world-clock.ts +33 -0
  31. package/src/world-store.ts +482 -0
  32. package/src/worldConfig.ts +4 -3
  33. package/dist/src/actions.d.ts +0 -138
  34. package/dist/src/actions.js +0 -201
  35. package/dist/src/args.d.ts +0 -3
  36. package/dist/src/args.js +0 -12
  37. package/dist/src/cli.d.ts +0 -2
  38. package/dist/src/cli.js +0 -425
  39. package/dist/src/connector.d.ts +0 -106
  40. package/dist/src/connector.js +0 -129
  41. package/dist/src/control-plane.d.ts +0 -21
  42. package/dist/src/control-plane.js +0 -40
  43. package/dist/src/egress.d.ts +0 -93
  44. package/dist/src/egress.js +0 -264
  45. package/dist/src/fork.d.ts +0 -126
  46. package/dist/src/fork.js +0 -206
  47. package/dist/src/index.d.ts +0 -42
  48. package/dist/src/index.js +0 -52
  49. package/dist/src/lease.d.ts +0 -50
  50. package/dist/src/lease.js +0 -80
  51. package/dist/src/packRegistry.d.ts +0 -34
  52. package/dist/src/packRegistry.js +0 -22
  53. package/dist/src/plan.d.ts +0 -97
  54. package/dist/src/plan.js +0 -151
  55. package/dist/src/proxy.d.ts +0 -25
  56. package/dist/src/proxy.js +0 -152
  57. package/dist/src/pushLedger.d.ts +0 -81
  58. package/dist/src/pushLedger.js +0 -130
  59. package/dist/src/queueLifecycle.d.ts +0 -62
  60. package/dist/src/queueLifecycle.js +0 -95
  61. package/dist/src/reconcile.d.ts +0 -58
  62. package/dist/src/reconcile.js +0 -137
  63. package/dist/src/refs.d.ts +0 -29
  64. package/dist/src/refs.js +0 -68
  65. package/dist/src/schemas.d.ts +0 -78
  66. package/dist/src/schemas.js +0 -50
  67. package/dist/src/serve.d.ts +0 -44
  68. package/dist/src/serve.js +0 -93
  69. package/dist/src/shadow.d.ts +0 -77
  70. package/dist/src/shadow.js +0 -138
  71. package/dist/src/status.d.ts +0 -31
  72. package/dist/src/status.js +0 -42
  73. package/dist/src/storage.d.ts +0 -119
  74. package/dist/src/storage.js +0 -535
  75. package/dist/src/sync.d.ts +0 -91
  76. package/dist/src/sync.js +0 -121
  77. package/dist/src/types.d.ts +0 -40
  78. package/dist/src/types.js +0 -1
  79. package/dist/src/validate.d.ts +0 -27
  80. package/dist/src/validate.js +0 -68
  81. package/dist/src/visualizer.d.ts +0 -13
  82. package/dist/src/visualizer.js +0 -133
  83. package/dist/src/worldConfig.d.ts +0 -9
  84. package/dist/src/worldConfig.js +0 -16
@@ -0,0 +1,33 @@
1
+ // THE WORLD CLOCK — the one piece of physics every twin consults (TWIN-PROGRAMMING-MODEL P3).
2
+ // Time is a GLOBAL fact requiring simultaneous agreement: TTL/expiry logic reads it at serve
3
+ // time, so no seed-side workaround exists. The clock is a FILE, not a service — every twin
4
+ // reads it per request (a tiny local read; the serve-path determinism tooth bans network
5
+ // egress, not filesystem reads), the world runtime owns writing it, and `volter-world clock`
6
+ // is the operator door (set / advance / show). No file, or no env → real wall-clock time,
7
+ // exactly the pre-clock behavior; a world only has scripted time when its operator says so.
8
+ //
9
+ // File format: a single ISO-8601 line (a FROZEN instant). Every read returns exactly that
10
+ // instant — deterministic by construction. Advancing time is an explicit operator act, never
11
+ // a background drift.
12
+ // Read through the ACTIVE WorldStore, not node:fs: the clock file is world state, and a
13
+ // hydrated serverless namespace must carry its frozen instant with it (runtime contract R11–R14
14
+ // — a MemoryWorldStore world that read the clock off the host filesystem would silently
15
+ // serve the HOST's time, the exact two-services-disagreeing-about-now defect below).
16
+ import { getActiveWorldStore } from './world-store.ts';
17
+
18
+ export const WORLD_CLOCK_ENV = 'TWIN_WORLD_CLOCK_FILE';
19
+
20
+ /** The world's "now": the frozen instant in the clock file when one is configured and
21
+ * parseable; real wall-clock time otherwise. */
22
+ export function worldNow(): string {
23
+ const file = process.env[WORLD_CLOCK_ENV];
24
+ if (file && getActiveWorldStore().exists(file)) {
25
+ const raw = (getActiveWorldStore().read(file) ?? '').trim();
26
+ const parsed = Date.parse(raw);
27
+ if (!Number.isNaN(parsed)) return new Date(parsed).toISOString();
28
+ // A configured-but-corrupt clock is a WORLD defect — fail loudly, never silently drift
29
+ // back to wall-clock (two services disagreeing about now breaks the world).
30
+ throw new Error(`world clock: ${file} does not contain a parseable ISO-8601 instant (got ${JSON.stringify(raw.slice(0, 40))})`);
31
+ }
32
+ return new Date().toISOString();
33
+ }
@@ -0,0 +1,482 @@
1
+ // WORLD STORE — the transport seam for the control-plane's persistence.
2
+ //
3
+ // The kernel's on-disk surface is a CLOSED SET of synchronous primitives: JSONL
4
+ // append-logs (read-all-rows / append-row), whole-file JSON sidecars
5
+ // (read/write/exists/remove), directory listing, and atomic-write + cross-process
6
+ // file-lock. `WorldStore` names exactly that set, and nothing more. Route the whole
7
+ // kernel through it and the twin persists through ANY backend — the local
8
+ // filesystem today (`FsWorldStore`, the default), an in-memory Map for tests and
9
+ // serverless proofs (`MemoryWorldStore`), a Durable Object / KV / redis tomorrow —
10
+ // without touching a line of twin business logic.
11
+ //
12
+ // The interface is SYNCHRONOUS on purpose. The kernel's internals (append-then-
13
+ // project, read-then-append critical sections) are synchronous, and `clerk-twin.ts`'s
14
+ // request handler is a pure sync function keyed by `root`. Making the store async
15
+ // would ripple `await` through every one of those call sites and change the handler's
16
+ // shape — precisely the blast radius this seam exists to avoid. An async backend is
17
+ // handled at a SEPARATE persistence boundary: `hydrate()` the durable source into a
18
+ // sync store, run the sync kernel, `flush()` back out. See `hydrateInto`/`flushFrom`.
19
+ //
20
+ // NOT in scope, by deliberate carve-out: `rateBudget.ts`'s ledger. That is a
21
+ // client-side guard on REAL vendor calls, keyed by CREDENTIAL and living in the user's
22
+ // home dir (NOT under the project state root) — it is not twin persistence and never
23
+ // runs on the serve path. Its bespoke inode-identity lock and reservation semantics
24
+ // cannot be expressed through this generic seam without weakening them, so it stays on
25
+ // `node:fs`. See its module header.
26
+ import {
27
+ appendFileSync,
28
+ closeSync,
29
+ existsSync,
30
+ fsyncSync,
31
+ mkdirSync,
32
+ openSync,
33
+ readdirSync,
34
+ readFileSync,
35
+ renameSync,
36
+ rmSync,
37
+ statSync,
38
+ unlinkSync,
39
+ writeFileSync,
40
+ } from 'node:fs';
41
+ import { hostname } from 'node:os';
42
+ import { dirname } from 'node:path';
43
+
44
+ /** Metadata a caller needs about a stored path. `isDirectory` distinguishes a JSON
45
+ * sidecar from a per-service subdir (validate/scrub walk on it); `size`+`mtimeMs`
46
+ * drive the append-dedupe index's cache-validity check in storage.ts. */
47
+ export type WorldStat = { size: number; mtimeMs: number; isDirectory: boolean };
48
+
49
+ /**
50
+ * The synchronous persistence seam. Every path is an ABSOLUTE key the caller already
51
+ * computed (via `worldPaths`/`worldStateRoot`); the store treats it as an opaque key,
52
+ * so a single active store partitions cleanly by `root` (the root is embedded in the
53
+ * key) and no per-root wiring is needed.
54
+ */
55
+ export interface WorldStore {
56
+ /** Whole-file read; `null` when the path does not exist. */
57
+ read(path: string): string | null;
58
+ /** File content split on `\n` (blank lines and the trailing empty included, so a
59
+ * caller can report 1-based row numbers); `[]` when the path does not exist. */
60
+ readLines(path: string): string[];
61
+ /** Raw append of exactly `data` (the caller owns any trailing newline). On the fs
62
+ * backend this is the VOLTER_DURABLE-aware durable append. Does NOT create parents —
63
+ * call `mkdir` first, matching the historical `appendDurable` contract. */
64
+ append(path: string, data: string): void;
65
+ /** Non-atomic whole-file write, creating parent dirs. Matches the plain
66
+ * `writeFileSync` sidecar writers (cursors, refs, leases, plans, fork-meta). */
67
+ write(path: string, data: string): void;
68
+ /** Atomic whole-file write (write-temp-then-rename on fs), creating parent dirs.
69
+ * Used where a reader must never observe a half-written document (state.json). */
70
+ writeAtomic(path: string, data: string): void;
71
+ /** Does the path exist? */
72
+ exists(path: string): boolean;
73
+ /** Recursively remove the path (force; a missing path is not an error). */
74
+ remove(path: string): void;
75
+ /** Ensure a directory (and parents) exists. A no-op on backends with implicit dirs. */
76
+ mkdir(dirPath: string): void;
77
+ /** Immediate child names of a directory; `[]` when it does not exist. */
78
+ list(dirPath: string): string[];
79
+ /** Metadata for a path, or `null` when it does not exist. */
80
+ stat(path: string): WorldStat | null;
81
+ /** Run `fn` holding an exclusive lock on `lockPath`, releasing it afterwards. The fs
82
+ * backend uses the historical cross-process reclaimable file lock; an in-memory,
83
+ * single-instance backend is a synchronous pass-through. */
84
+ withLock<T>(lockPath: string, fn: () => T): T;
85
+ }
86
+
87
+ // ── FsWorldStore — the DEFAULT, byte-identical to the historical node:fs behavior ────
88
+
89
+ /** How long a lock may live before an acquirer treats it as abandoned. Above the 10s
90
+ * wait timeout so a live-but-slow holder is never reclaimed out from under itself. */
91
+ const LOCK_STALE_MS = 60_000;
92
+
93
+ type LockHolder = { pid: number; hostname: string; at: string };
94
+
95
+ function readLockHolder(lockPath: string): LockHolder | null {
96
+ try {
97
+ return JSON.parse(readFileSync(lockPath, 'utf8')) as LockHolder;
98
+ } catch {
99
+ return null; // missing, empty (mid-write), or malformed
100
+ }
101
+ }
102
+
103
+ function pidAlive(pid: number): boolean {
104
+ try {
105
+ process.kill(pid, 0);
106
+ return true;
107
+ } catch (error) {
108
+ // ESRCH → no such process (dead); EPERM → exists but not ours to signal (alive)
109
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
110
+ }
111
+ }
112
+
113
+ /** A lock is stale when its holder is provably gone (same host + dead pid) or it has
114
+ * outlived LOCK_STALE_MS. The age falls back to the lockfile's own mtime when the
115
+ * `at` record is unreadable — so a writer that crashed between creating the lock and
116
+ * recording itself is still eventually reclaimed, while a freshly-created (recent
117
+ * mtime) empty lock is left alone, avoiding a race with the live writer. */
118
+ function lockIsStale(lockPath: string): boolean {
119
+ const holder = readLockHolder(lockPath);
120
+ if (holder && holder.hostname === hostname() && Number.isInteger(holder.pid) && !pidAlive(holder.pid)) {
121
+ return true;
122
+ }
123
+ const recordedAt = holder && typeof holder.at === 'string' ? Date.parse(holder.at) : NaN;
124
+ let stamp = recordedAt;
125
+ if (!Number.isFinite(stamp)) {
126
+ try {
127
+ stamp = statSync(lockPath).mtimeMs;
128
+ } catch {
129
+ return false; // lock vanished — let the next openSync settle it
130
+ }
131
+ }
132
+ return Date.now() - stamp > LOCK_STALE_MS;
133
+ }
134
+
135
+ function sleepSync(ms: number): void {
136
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
137
+ }
138
+
139
+ /**
140
+ * The default store: a thin, faithful pass-through to `node:fs`. Every method preserves
141
+ * the exact behavior the kernel had before the seam existed — the durable append
142
+ * (VOLTER_DURABLE), the atomic write (temp + rename), and the reclaimable cross-process
143
+ * file lock — so the existing twins and the conformance suite are unaffected.
144
+ */
145
+ export class FsWorldStore implements WorldStore {
146
+ read(path: string): string | null {
147
+ if (!existsSync(path)) return null;
148
+ return readFileSync(path, 'utf8');
149
+ }
150
+
151
+ readLines(path: string): string[] {
152
+ if (!existsSync(path)) return [];
153
+ return readFileSync(path, 'utf8').split('\n');
154
+ }
155
+
156
+ append(path: string, data: string): void {
157
+ // Historical appendDurable: a completed append syscall survives a process crash;
158
+ // fsync additionally survives kernel-panic/power-loss when VOLTER_DURABLE=1.
159
+ const fd = openSync(path, 'a');
160
+ try {
161
+ appendFileSync(fd, data);
162
+ if (process.env.VOLTER_DURABLE === '1') fsyncSync(fd);
163
+ } finally {
164
+ closeSync(fd);
165
+ }
166
+ }
167
+
168
+ write(path: string, data: string): void {
169
+ mkdirSync(dirname(path), { recursive: true });
170
+ writeFileSync(path, data);
171
+ }
172
+
173
+ writeAtomic(path: string, data: string): void {
174
+ mkdirSync(dirname(path), { recursive: true });
175
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
176
+ writeFileSync(tmp, data);
177
+ renameSync(tmp, path);
178
+ }
179
+
180
+ exists(path: string): boolean {
181
+ return existsSync(path);
182
+ }
183
+
184
+ remove(path: string): void {
185
+ rmSync(path, { recursive: true, force: true });
186
+ }
187
+
188
+ mkdir(dirPath: string): void {
189
+ mkdirSync(dirPath, { recursive: true });
190
+ }
191
+
192
+ list(dirPath: string): string[] {
193
+ if (!existsSync(dirPath)) return [];
194
+ return readdirSync(dirPath);
195
+ }
196
+
197
+ stat(path: string): WorldStat | null {
198
+ if (!existsSync(path)) return null;
199
+ const s = statSync(path);
200
+ return { size: s.size, mtimeMs: s.mtimeMs, isDirectory: s.isDirectory() };
201
+ }
202
+
203
+ /** Run `fn` holding an exclusive cross-process file lock. The lockfile records
204
+ * `{pid, hostname, at}`; a contender that finds a stale lock (dead pid or age >
205
+ * LOCK_STALE_MS) reclaims it by atomically renaming it aside — so a crashed holder
206
+ * can't wedge the world forever. Reclaim is race-safe: only the process that wins
207
+ * the rename clears the stale inode, and the exclusive `wx` create still decides the
208
+ * winner. (Moved verbatim from storage.ts `withFileLock`.) */
209
+ withLock<T>(lockPath: string, fn: () => T): T {
210
+ mkdirSync(dirname(lockPath), { recursive: true });
211
+ const started = Date.now();
212
+ let fd: number | null = null;
213
+ while (fd === null) {
214
+ try {
215
+ fd = openSync(lockPath, 'wx');
216
+ } catch (error) {
217
+ const code = (error as NodeJS.ErrnoException).code;
218
+ if (code !== 'EEXIST') throw error;
219
+ if (lockIsStale(lockPath)) {
220
+ const holder = readLockHolder(lockPath);
221
+ const salvage = `${lockPath}.stale.${process.pid}.${Date.now()}`;
222
+ try {
223
+ renameSync(lockPath, salvage);
224
+ } catch (renameError) {
225
+ if ((renameError as NodeJS.ErrnoException).code === 'ENOENT') continue; // another contender reclaimed it
226
+ throw renameError;
227
+ }
228
+ console.warn(
229
+ `[world] reclaiming stale storage lock ${lockPath} (held by pid ${holder?.pid ?? '?'} on ${holder?.hostname ?? '?'} since ${holder?.at ?? 'unknown'})`,
230
+ );
231
+ try {
232
+ unlinkSync(salvage);
233
+ } catch { /* the moved-aside stale inode; safe to leave if unlink fails */ }
234
+ continue; // retry the exclusive create immediately
235
+ }
236
+ if (Date.now() - started > 10_000) {
237
+ throw new Error(`Timed out waiting for world storage lock: ${lockPath}`);
238
+ }
239
+ sleepSync(25);
240
+ }
241
+ }
242
+
243
+ // Record the holder so a later contender can detect staleness. Best-effort: a write
244
+ // failure here doesn't weaken exclusivity, only staleness diagnostics.
245
+ try {
246
+ writeFileSync(fd, `${JSON.stringify({ pid: process.pid, hostname: hostname(), at: new Date().toISOString() } satisfies LockHolder)}\n`);
247
+ } catch { /* ignore */ }
248
+
249
+ try {
250
+ return fn();
251
+ } finally {
252
+ closeSync(fd);
253
+ try {
254
+ unlinkSync(lockPath);
255
+ } catch { /* already reclaimed by a stale-lock sweep */ }
256
+ }
257
+ }
258
+ }
259
+
260
+ // ── MemoryWorldStore — a Map-backed store; the proof a non-fs transport works ─────────
261
+
262
+ // A process-global, strictly-increasing stamp source shared by every MemoryWorldStore, so
263
+ // no two writes (across instances or requests) ever collide on `stat().mtimeMs`. See `bump`.
264
+ let memoryStoreClock = 0;
265
+
266
+ /**
267
+ * An in-memory store. Files are entries in a `Map<path, string>`; directories are
268
+ * synthesized from the key set (a path is a directory when it is a strict prefix of a
269
+ * file key). `withLock` is a synchronous pass-through — a single in-process instance has
270
+ * no concurrent writers to serialize, so the read-then-append critical sections the
271
+ * kernel guards are already atomic under the single-threaded event loop.
272
+ *
273
+ * This is the portability proof: point the active store here and the entire kernel — and
274
+ * the clerk twin on top of it — runs with the filesystem untouched.
275
+ */
276
+ export class MemoryWorldStore implements WorldStore {
277
+ /** path → content, and path → version stamp driving `stat().mtimeMs`. */
278
+ private readonly files = new Map<string, string>();
279
+ private readonly versions = new Map<string, number>();
280
+ /** The stamp of this store's most recent mutation (0 = never mutated). A serverless
281
+ * entry snapshots this before running the sync kernel and flushes ONLY when it moved —
282
+ * read-only requests (the common case) then skip the durable write entirely. */
283
+ private lastMutation = 0;
284
+
285
+ mutationStamp(): number {
286
+ return this.lastMutation;
287
+ }
288
+
289
+ /** Paths whose content changed strictly after `stamp` — the DELTA-FLUSH primitive
290
+ * (runtime contract R11b): a serverless entry records `mutationStamp()` at hydration,
291
+ * runs the sync kernel, then persists ONLY these paths (plus removals, which the caller
292
+ * detects by diffing its hydrated path set against `entries()` — a removed path has no
293
+ * version to report). O(paths) scan, O(delta) write. */
294
+ changedSince(stamp: number): string[] {
295
+ const changed: string[] = [];
296
+ for (const [path, version] of this.versions) {
297
+ if (version > stamp && this.files.has(path)) changed.push(path);
298
+ }
299
+ return changed.sort();
300
+ }
301
+
302
+ private bump(path: string): void {
303
+ // Draw the stamp from a PROCESS-GLOBAL monotonic counter, not a per-instance one.
304
+ // storage.ts's append-dedupe cache is keyed by path and validated by (size, mtimeMs),
305
+ // and it persists across requests in a Worker isolate while a fresh MemoryWorldStore is
306
+ // created per request (the hydrate→sync-kernel→flush shape). A per-instance counter
307
+ // resets to 0 each request, so two instances could present the SAME (size, mtimeMs) for
308
+ // one path with DIFFERENT content — serving a stale index (dedupe bypass) or a false
309
+ // conflict. A global counter makes every write across every instance a distinct,
310
+ // strictly-increasing stamp, so a cross-instance collision is impossible.
311
+ this.lastMutation = memoryStoreClock += 1;
312
+ this.versions.set(path, this.lastMutation);
313
+ }
314
+
315
+ read(path: string): string | null {
316
+ return this.files.has(path) ? this.files.get(path)! : null;
317
+ }
318
+
319
+ readLines(path: string): string[] {
320
+ const content = this.files.get(path);
321
+ return content === undefined ? [] : content.split('\n');
322
+ }
323
+
324
+ append(path: string, data: string): void {
325
+ this.files.set(path, (this.files.get(path) ?? '') + data);
326
+ this.bump(path);
327
+ }
328
+
329
+ write(path: string, data: string): void {
330
+ this.files.set(path, data);
331
+ this.bump(path);
332
+ }
333
+
334
+ writeAtomic(path: string, data: string): void {
335
+ // Atomic by nature in a single process: the assignment is indivisible, so no reader
336
+ // ever observes a partial document.
337
+ this.files.set(path, data);
338
+ this.bump(path);
339
+ }
340
+
341
+ exists(path: string): boolean {
342
+ if (this.files.has(path)) return true;
343
+ // A directory "exists" when some file lives under it.
344
+ const prefix = path.endsWith('/') ? path : `${path}/`;
345
+ for (const key of this.files.keys()) if (key.startsWith(prefix)) return true;
346
+ return false;
347
+ }
348
+
349
+ remove(path: string): void {
350
+ let removedAny = false;
351
+ if (this.files.delete(path)) {
352
+ this.versions.delete(path);
353
+ removedAny = true;
354
+ }
355
+ const prefix = path.endsWith('/') ? path : `${path}/`;
356
+ for (const key of [...this.files.keys()]) {
357
+ if (key.startsWith(prefix)) {
358
+ this.files.delete(key);
359
+ this.versions.delete(key);
360
+ removedAny = true;
361
+ }
362
+ }
363
+ // A pure removal is a mutation too: without this, a request that only deletes state
364
+ // would read as clean and the delta-flush would never persist the removal (R11b).
365
+ if (removedAny) this.lastMutation = memoryStoreClock += 1;
366
+ }
367
+
368
+ mkdir(_dirPath: string): void {
369
+ // Directories are implicit in the key set; nothing to do.
370
+ }
371
+
372
+ list(dirPath: string): string[] {
373
+ const prefix = dirPath.endsWith('/') ? dirPath : `${dirPath}/`;
374
+ const children = new Set<string>();
375
+ for (const key of this.files.keys()) {
376
+ if (!key.startsWith(prefix)) continue;
377
+ const rest = key.slice(prefix.length);
378
+ const slash = rest.indexOf('/');
379
+ children.add(slash === -1 ? rest : rest.slice(0, slash));
380
+ }
381
+ return [...children];
382
+ }
383
+
384
+ stat(path: string): WorldStat | null {
385
+ const content = this.files.get(path);
386
+ if (content !== undefined) {
387
+ return { size: Buffer.byteLength(content, 'utf8'), mtimeMs: this.versions.get(path) ?? 0, isDirectory: false };
388
+ }
389
+ if (this.exists(path)) {
390
+ return { size: 0, mtimeMs: 0, isDirectory: true };
391
+ }
392
+ return null;
393
+ }
394
+
395
+ withLock<T>(_lockPath: string, fn: () => T): T {
396
+ return fn();
397
+ }
398
+
399
+ /** Test/inspection helper: a plain snapshot of every stored file. Not part of the
400
+ * `WorldStore` contract — used by `flushFrom` and by tests asserting no fs was touched. */
401
+ entries(): Record<string, string> {
402
+ return Object.fromEntries(this.files);
403
+ }
404
+ }
405
+
406
+ // ── the active store (the injection point) ───────────────────────────────────────────
407
+
408
+ let activeStore: WorldStore = new FsWorldStore();
409
+
410
+ /** The store the kernel currently persists through. Defaults to `FsWorldStore`. */
411
+ export function getActiveWorldStore(): WorldStore {
412
+ return activeStore;
413
+ }
414
+
415
+ /** Swap the active store. A serverless/DO/test entry sets a `MemoryWorldStore` (or a
416
+ * backend-backed store) here before running the sync kernel, and restores the previous
417
+ * one afterwards. Returns the store that was active, so callers can restore it. */
418
+ export function setActiveWorldStore(store: WorldStore): WorldStore {
419
+ const previous = activeStore;
420
+ activeStore = store;
421
+ return previous;
422
+ }
423
+
424
+ /** Run `fn` with `store` active, restoring the previous store afterwards (even on
425
+ * throw). ASYNC-AWARE: when `fn` returns a promise, the restore happens after it
426
+ * SETTLES — the first version restored at `fn`'s return, i.e. at an async callback's
427
+ * FIRST await, un-scoping the rest of the request (both serverless pack lanes hit this
428
+ * independently and hand-rolled the same try/finally, 2026-09-01).
429
+ *
430
+ * CONCURRENCY CAVEAT, stated not hidden: the active store is a module global. Holding it
431
+ * across awaits is safe only when store-scoped work is SERIALIZED — one request at a time
432
+ * per isolate — which is exactly what the namespace engine provides (twins-host
433
+ * namespace-engine.ts, its interleave hammer is the pin). Two unserialized async scopes
434
+ * interleaving would bleed stores with or without this helper; use the engine. */
435
+ export function withWorldStore<T>(store: WorldStore, fn: () => Promise<T>): Promise<T>;
436
+ export function withWorldStore<T>(store: WorldStore, fn: () => T): T;
437
+ export function withWorldStore<T>(store: WorldStore, fn: () => T | Promise<T>): T | Promise<T> {
438
+ const previous = setActiveWorldStore(store);
439
+ let result: T | Promise<T>;
440
+ try {
441
+ result = fn();
442
+ } catch (error) {
443
+ setActiveWorldStore(previous);
444
+ throw error;
445
+ }
446
+ if (result instanceof Promise) {
447
+ return result.finally(() => setActiveWorldStore(previous));
448
+ }
449
+ setActiveWorldStore(previous);
450
+ return result;
451
+ }
452
+
453
+ // ── async persistence boundary ───────────────────────────────────────────────────────
454
+ //
455
+ // The kernel is synchronous; a durable backend (Durable Object storage, KV, redis, S3)
456
+ // is asynchronous. These two helpers are the ONLY async in the persistence story: a
457
+ // serverless entry `await hydrate()`s the durable snapshot into a sync store, runs the
458
+ // sync kernel (which never awaits), then `await flush()`es the mutated snapshot back.
459
+ // The shapes are proven against `MemoryWorldStore` (see world-store.test.ts); a real DO
460
+ // adapter implements `HydrationSource`/`HydrationSink` over its own async storage.
461
+
462
+ /** A durable, async source of a world snapshot (path → content). */
463
+ export interface HydrationSource {
464
+ load(): Promise<Record<string, string>>;
465
+ }
466
+
467
+ /** A durable, async sink for a world snapshot (path → content). */
468
+ export interface HydrationSink {
469
+ save(snapshot: Record<string, string>): Promise<void>;
470
+ }
471
+
472
+ /** Fill `store` from an async durable `source` before the sync kernel runs. */
473
+ export async function hydrateInto(store: MemoryWorldStore, source: HydrationSource): Promise<void> {
474
+ const snapshot = await source.load();
475
+ for (const [path, content] of Object.entries(snapshot)) store.write(path, content);
476
+ }
477
+
478
+ /** Persist `store`'s current contents back to an async durable `sink` after the sync
479
+ * kernel has run. */
480
+ export async function flushFrom(store: MemoryWorldStore, sink: HydrationSink): Promise<void> {
481
+ await sink.save(store.entries());
482
+ }
@@ -4,9 +4,9 @@
4
4
  // define. (The tracker's annotation adapter defines + reads `annotationPolicy` and
5
5
  // `browseUrlTemplate` on top of this — those are verification concerns, not the
6
6
  // twin's, so they are NOT typed here.)
7
- import { existsSync, readFileSync } from 'node:fs';
8
7
  import { join } from 'node:path';
9
8
  import { worldStateRoot } from './storage.ts';
9
+ import { getActiveWorldStore } from './world-store.ts';
10
10
 
11
11
  export type WorldServiceConfig = {
12
12
  provider?: string;
@@ -20,7 +20,8 @@ export type WorldConfig = {
20
20
 
21
21
  export function loadWorldConfig(root?: string): WorldConfig {
22
22
  const path = join(worldStateRoot(root), 'config.json');
23
- if (!existsSync(path)) return { services: {} };
24
- const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<WorldConfig>;
23
+ const raw = getActiveWorldStore().read(path);
24
+ if (raw === null) return { services: {} };
25
+ const parsed = JSON.parse(raw) as Partial<WorldConfig>;
25
26
  return { ...parsed, services: parsed.services ?? {} };
26
27
  }
@@ -1,138 +0,0 @@
1
- import type { SubjectFields } from './shadow.js';
2
- import type { TwinResource } from './serve.js';
3
- export type TwinActionOp = 'set' | 'revert' | 'confirm';
4
- export type TwinActionPreconditionOp = 'exists' | 'not_exists' | 'eq' | 'neq' | 'version_eq';
5
- export type TwinActionPrecondition = {
6
- subject: {
7
- type: string;
8
- id: string;
9
- };
10
- field: string;
11
- op: TwinActionPreconditionOp;
12
- value?: unknown;
13
- };
14
- export type TwinActionRevertSpec = {
15
- strategy: 'inverse' | 'suppress' | 'compensating-action';
16
- operation?: string;
17
- fields?: SubjectFields;
18
- };
19
- export type ProjectedResource = {
20
- type: string;
21
- id: string;
22
- fields: SubjectFields;
23
- };
24
- export type ProjectedResourcePatch = {
25
- type: string;
26
- id: string;
27
- fields: SubjectFields;
28
- };
29
- export type ProjectedResourceRef = {
30
- type: string;
31
- id: string;
32
- };
33
- export type ProjectedDelivery = {
34
- kind: 'webhook' | 'event' | 'notification';
35
- target: string;
36
- payload: Record<string, unknown>;
37
- };
38
- export type ActionProjection = {
39
- creates?: ProjectedResource[];
40
- updates?: ProjectedResourcePatch[];
41
- deletes?: ProjectedResourceRef[];
42
- emits?: ProjectedDelivery[];
43
- };
44
- export type TwinAction = {
45
- id: string;
46
- service: string;
47
- op: TwinActionOp;
48
- subject: {
49
- type: string;
50
- id: string;
51
- };
52
- occurredAt: string;
53
- actor?: {
54
- kind: 'agent' | 'human' | 'bot' | 'system';
55
- id?: string;
56
- };
57
- /** Optional vendor operation name, e.g. `issue.update` or `message.send`. */
58
- operation?: string;
59
- /** Raw operation input (provenance); not projected — `fields`/`projection` carry the state change. */
60
- input?: Record<string, unknown>;
61
- /** Preconditions are evaluated against the current projected twin state before append. */
62
- preconditions?: TwinActionPrecondition[];
63
- fields?: SubjectFields;
64
- projection?: ActionProjection;
65
- revertsActionId?: string;
66
- confirmsActionId?: string;
67
- observedEventId?: string;
68
- /** Optional machine-readable hint for how a UI/pack should construct a revert. */
69
- revert?: TwinActionRevertSpec;
70
- /**
71
- * Request-scoped correlation id (D3 — the purpose-3 audit trail: "who reviewed the
72
- * change that caused this real write"). Always present on an appended action —
73
- * appendAction/appendActionIfAbsent generate one when the caller doesn't supply it —
74
- * and threaded through to the push-ledger row(s) a push against this action produces
75
- * (see pushLedger.ts), so an action row and its push-ledger row(s) join on this id
76
- * alone, with no dependence on actionId/pushId naming conventions.
77
- */
78
- correlationId?: string;
79
- };
80
- export type TwinTransactionCommit = TwinAction;
81
- export type TwinTransactionCommitOp = TwinActionOp;
82
- export type TwinTransactionPrecondition = TwinActionPrecondition;
83
- export type TwinTransactionRevertSpec = TwinActionRevertSpec;
84
- export declare class TwinActionPreconditionError extends Error {
85
- readonly actionId: string;
86
- readonly failed: TwinActionPrecondition;
87
- constructor(actionId: string, failed: TwinActionPrecondition);
88
- }
89
- export declare function appendAction(action: TwinAction, root?: string): TwinAction;
90
- /** Append `action` only if no action with the same id already exists — the whole
91
- * check-then-append runs under the actions lock, so it's atomic across processes (two
92
- * concurrent identical writes converge to ONE action; distinct writes both land). */
93
- export declare function appendActionIfAbsent(action: TwinAction, root?: string): {
94
- action: TwinAction;
95
- appended: boolean;
96
- };
97
- export declare function appendTransactionCommit(commit: TwinTransactionCommit, root?: string): TwinTransactionCommit;
98
- export declare function listActions(service: string, root?: string): TwinAction[];
99
- /**
100
- * Project the action log over the observed mirror → current twin resources.
101
- * `set` actions overlay fields (creating subjects that don't exist in the mirror);
102
- * reverted and confirmed actions are skipped (confirmed facts come from the
103
- * observed log instead, so they are not projected twice).
104
- */
105
- export declare function projectResources(service: string, root?: string): TwinResource[];
106
- /**
107
- * Confirm a local action after it was pushed to the real vendor (R18): record the
108
- * confirmed fields as an OBSERVED event (origin 'external' — it's now real) and
109
- * append a `confirm` action mapping the local action → that observed event id.
110
- * Projection then drops the local action (the fact lives in the observed log), so
111
- * the change is counted exactly once. Returns the observed event id.
112
- */
113
- export declare function confirmAction(opts: {
114
- service: string;
115
- actionId: string;
116
- subject: {
117
- type: string;
118
- id: string;
119
- };
120
- fields: SubjectFields;
121
- occurredAt: string;
122
- root?: string;
123
- }): {
124
- observedEventId: string;
125
- };
126
- /** Local pending actions (set, not reverted, not yet confirmed) — the divergence from the mirror. */
127
- export declare function pendingActions(service: string, root?: string): TwinAction[];
128
- /**
129
- * Deliveries (webhooks/events/notifications) the active transactions want fired —
130
- * the `projection.emits` of pending `set` actions, in order. A twin's event layer
131
- * reads these to know what to deliver; projecting state ignores emits (side effects).
132
- */
133
- export declare function pendingEmits(service: string, root?: string): Array<{
134
- actionId: string;
135
- delivery: ProjectedDelivery;
136
- }>;
137
- export declare const listTransactionCommits: typeof listActions;
138
- export declare const pendingTransactionCommits: typeof pendingActions;