@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
package/src/storage.ts CHANGED
@@ -1,10 +1,9 @@
1
- import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { hostname } from 'node:os';
3
1
  import { dirname, join, resolve } from 'node:path';
4
2
  import {
5
3
  GenericWorldStateSchema,
6
4
  WorldServiceEventSchema,
7
5
  } from './schemas.ts';
6
+ import { getActiveWorldStore } from './world-store.ts';
8
7
  import type {
9
8
  AppendEventResult,
10
9
  CommitQueuedEventsResult,
@@ -45,8 +44,23 @@ function assertServiceName(service: string): string {
45
44
  return service;
46
45
  }
47
46
 
47
+ /**
48
+ * IDENTITY SEAM for the request journal (serve.ts). `worldPaths` is the single funnel every
49
+ * state read/write in every pack goes through, so it is also the one place a twin says, in its
50
+ * own words, WHO it is (`service`) and WHERE its state lives (`root`) — the two things an HTTP
51
+ * layer that only sees `Bun.serve({fetch})` cannot know. serve.ts installs an observer here so
52
+ * the journal it writes lands beside that twin's `actions.jsonl` instead of guessing a root.
53
+ * Nothing else may use this: it is a one-slot observability hook, never a control seam.
54
+ */
55
+ let worldPathsObserver: ((service: string, root: string | undefined) => void) | undefined;
56
+
57
+ export function observeWorldPaths(observer: (service: string, root: string | undefined) => void): void {
58
+ worldPathsObserver = observer;
59
+ }
60
+
48
61
  export function worldPaths(service: string, root?: string): WorldPaths {
49
62
  const safeService = assertServiceName(service);
63
+ worldPathsObserver?.(safeService, root);
50
64
  const resolvedRoot = projectRoot(root);
51
65
  const dir = join(worldStateRoot(root), safeService);
52
66
  return {
@@ -63,9 +77,8 @@ export function worldPaths(service: string, root?: string): WorldPaths {
63
77
  }
64
78
 
65
79
  function readJsonl<T>(path: string): T[] {
66
- if (!existsSync(path)) return [];
67
80
  const rows: T[] = [];
68
- for (const [index, line] of readFileSync(path, 'utf8').split('\n').entries()) {
81
+ for (const [index, line] of getActiveWorldStore().readLines(path).entries()) {
69
82
  if (!line.trim()) continue;
70
83
  try {
71
84
  rows.push(JSON.parse(line) as T);
@@ -81,7 +94,9 @@ function readJsonl<T>(path: string): T[] {
81
94
  * file that is expected to exist; guard with existsSync first when absence is allowed. */
82
95
  export function readJsonFile<T>(path: string): T {
83
96
  try {
84
- return JSON.parse(readFileSync(path, 'utf8')) as T;
97
+ const raw = getActiveWorldStore().read(path);
98
+ if (raw === null) throw new Error('no such file');
99
+ return JSON.parse(raw) as T;
85
100
  } catch (error) {
86
101
  throw new Error(`${path}: invalid JSON: ${(error as Error).message}`);
87
102
  }
@@ -100,17 +115,11 @@ export function readJsonFile<T>(path: string): T {
100
115
  * fsync per append. See ARCHITECTURE.md D1.
101
116
  */
102
117
  export function appendDurable(path: string, data: string): void {
103
- const fd = openSync(path, 'a');
104
- try {
105
- appendFileSync(fd, data);
106
- if (process.env.VOLTER_DURABLE === '1') fsyncSync(fd);
107
- } finally {
108
- closeSync(fd);
109
- }
118
+ getActiveWorldStore().append(path, data);
110
119
  }
111
120
 
112
121
  /**
113
- * Opt-in structured stderr logging for the audit trail (ARCHITECTURE-REVIEW-TODOS D3):
122
+ * Opt-in structured stderr logging for the audit trail (the July 2026 architecture review, row D3):
114
123
  * one line per action-log append and per push-ledger row, so "who reviewed the change
115
124
  * that caused this real write" is mechanically greppable from a single log stream via
116
125
  * `correlationId`. Off by default — mirrors the VOLTER_DURABLE opt-in policy above: no
@@ -122,121 +131,22 @@ export function twinLog(kind: string, details: Record<string, unknown>): void {
122
131
  }
123
132
 
124
133
  function appendJsonl(path: string, value: unknown): void {
125
- mkdirSync(dirname(path), { recursive: true });
126
- appendDurable(path, `${JSON.stringify(value)}\n`);
127
- }
128
-
129
- function sleepSync(ms: number): void {
130
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
131
- }
132
-
133
- /** How long a lock may live before an acquirer treats it as abandoned. Above the 10s
134
- * wait timeout so a live-but-slow holder is never reclaimed out from under itself. */
135
- const LOCK_STALE_MS = 60_000;
136
-
137
- type LockHolder = { pid: number; hostname: string; at: string };
138
-
139
- function readLockHolder(lockPath: string): LockHolder | null {
140
- try {
141
- return JSON.parse(readFileSync(lockPath, 'utf8')) as LockHolder;
142
- } catch {
143
- return null; // missing, empty (mid-write), or malformed
144
- }
145
- }
146
-
147
- function pidAlive(pid: number): boolean {
148
- try {
149
- process.kill(pid, 0);
150
- return true;
151
- } catch (error) {
152
- // ESRCH → no such process (dead); EPERM → exists but not ours to signal (alive)
153
- return (error as NodeJS.ErrnoException).code === 'EPERM';
154
- }
155
- }
156
-
157
- /** A lock is stale when its holder is provably gone (same host + dead pid) or it has
158
- * outlived LOCK_STALE_MS. The age falls back to the lockfile's own mtime when the
159
- * `at` record is unreadable — so a writer that crashed between creating the lock and
160
- * recording itself is still eventually reclaimed, while a freshly-created (recent
161
- * mtime) empty lock is left alone, avoiding a race with the live writer. */
162
- function lockIsStale(lockPath: string): boolean {
163
- const holder = readLockHolder(lockPath);
164
- if (holder && holder.hostname === hostname() && Number.isInteger(holder.pid) && !pidAlive(holder.pid)) {
165
- return true;
166
- }
167
- const recordedAt = holder && typeof holder.at === 'string' ? Date.parse(holder.at) : NaN;
168
- let stamp = recordedAt;
169
- if (!Number.isFinite(stamp)) {
170
- try {
171
- stamp = statSync(lockPath).mtimeMs;
172
- } catch {
173
- return false; // lock vanished — let the next openSync settle it
174
- }
175
- }
176
- return Date.now() - stamp > LOCK_STALE_MS;
134
+ const store = getActiveWorldStore();
135
+ store.mkdir(dirname(path));
136
+ store.append(path, `${JSON.stringify(value)}\n`);
177
137
  }
178
138
 
179
- /** Run `fn` holding an exclusive cross-process file lock (the same primitive the event log
180
- * uses). Used to make read-then-append critical sections atomic across processes. The
181
- * lockfile records `{pid, hostname, at}`; a contender that finds a stale lock (dead pid or
182
- * age > LOCK_STALE_MS) reclaims it by atomically renaming it aside — so a crashed holder
183
- * can't wedge the world forever. Reclaim is race-safe: only the process that wins the
184
- * rename clears the stale inode, and the exclusive `wx` create still decides the winner. */
139
+ /** Run `fn` holding an exclusive cross-process lock, via the active store. On the fs
140
+ * store this is the reclaimable file lock the event log uses; on an in-memory,
141
+ * single-instance store it is a synchronous pass-through. Used to make read-then-append
142
+ * critical sections atomic. Public so world-runtime can guard concurrent `upWorld`
143
+ * claims of one instance dir with the SAME lock semantics the event log uses (TWIN-36). */
185
144
  export function withFileLock<T>(lockPath: string, fn: () => T): T {
186
- mkdirSync(dirname(lockPath), { recursive: true });
187
- const started = Date.now();
188
- let fd: number | null = null;
189
- while (fd === null) {
190
- try {
191
- fd = openSync(lockPath, 'wx');
192
- } catch (error) {
193
- const code = (error as NodeJS.ErrnoException).code;
194
- if (code !== 'EEXIST') throw error;
195
- if (lockIsStale(lockPath)) {
196
- const holder = readLockHolder(lockPath);
197
- const salvage = `${lockPath}.stale.${process.pid}.${Date.now()}`;
198
- try {
199
- renameSync(lockPath, salvage);
200
- } catch (renameError) {
201
- if ((renameError as NodeJS.ErrnoException).code === 'ENOENT') continue; // another contender reclaimed it
202
- throw renameError;
203
- }
204
- console.warn(
205
- `[world] reclaiming stale storage lock ${lockPath} (held by pid ${holder?.pid ?? '?'} on ${holder?.hostname ?? '?'} since ${holder?.at ?? 'unknown'})`,
206
- );
207
- try {
208
- unlinkSync(salvage);
209
- } catch { /* the moved-aside stale inode; safe to leave if unlink fails */ }
210
- continue; // retry the exclusive create immediately
211
- }
212
- if (Date.now() - started > 10_000) {
213
- throw new Error(`Timed out waiting for world storage lock: ${lockPath}`);
214
- }
215
- sleepSync(25);
216
- }
217
- }
218
-
219
- // Record the holder so a later contender can detect staleness. Best-effort: a write
220
- // failure here doesn't weaken exclusivity, only staleness diagnostics.
221
- try {
222
- writeFileSync(fd, `${JSON.stringify({ pid: process.pid, hostname: hostname(), at: new Date().toISOString() } satisfies LockHolder)}\n`);
223
- } catch { /* ignore */ }
224
-
225
- try {
226
- return fn();
227
- } finally {
228
- closeSync(fd);
229
- try {
230
- unlinkSync(lockPath);
231
- } catch { /* already reclaimed by a stale-lock sweep */ }
232
- }
145
+ return getActiveWorldStore().withLock(lockPath, fn);
233
146
  }
234
147
 
235
148
  function writeJsonAtomic(path: string, value: unknown): void {
236
- mkdirSync(dirname(path), { recursive: true });
237
- const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
238
- writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
239
- renameSync(tmp, path);
149
+ getActiveWorldStore().writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
240
150
  }
241
151
 
242
152
  export function listEvents(service: string, root?: string): WorldServiceEvent[] {
@@ -264,8 +174,8 @@ type EventsIndex = { size: number; mtimeMs: number; ids: Set<string>; keys: Set<
264
174
  const eventsIndexCache = new Map<string, EventsIndex>();
265
175
 
266
176
  function eventsFileStamp(eventsPath: string): { size: number; mtimeMs: number } {
267
- if (!existsSync(eventsPath)) return { size: 0, mtimeMs: 0 };
268
- const stat = statSync(eventsPath);
177
+ const stat = getActiveWorldStore().stat(eventsPath);
178
+ if (!stat) return { size: 0, mtimeMs: 0 };
269
179
  return { size: stat.size, mtimeMs: stat.mtimeMs };
270
180
  }
271
181
 
@@ -293,10 +203,11 @@ function findEventRow(eventsPath: string, id: string, idempotencyKey: string): W
293
203
  }
294
204
 
295
205
  function ensureEventDirs(paths: WorldPaths): void {
296
- mkdirSync(paths.dir, { recursive: true });
297
- mkdirSync(paths.resources, { recursive: true });
298
- mkdirSync(paths.cursors, { recursive: true });
299
- mkdirSync(paths.ingests, { recursive: true });
206
+ const store = getActiveWorldStore();
207
+ store.mkdir(paths.dir);
208
+ store.mkdir(paths.resources);
209
+ store.mkdir(paths.cursors);
210
+ store.mkdir(paths.ingests);
300
211
  }
301
212
 
302
213
  /** Exported so other modules that share a service's events log (e.g. egress.ts,
@@ -306,6 +217,16 @@ export function eventsLockPath(paths: WorldPaths): string {
306
217
  return join(paths.dir, 'events.jsonl.lock');
307
218
  }
308
219
 
220
+ /**
221
+ * Serializes mutations that can change a service's projected state, regardless of whether the
222
+ * durable row lands in events.jsonl or actions.jsonl. The per-log locks still protect their own
223
+ * append/dedupe mechanics; this outer lock makes a projection-based decision linearizable against
224
+ * connector observations as well as local actions.
225
+ */
226
+ export function projectionLockPath(paths: WorldPaths): string {
227
+ return join(paths.dir, 'projection.lock');
228
+ }
229
+
309
230
  /**
310
231
  * The append body, assuming the caller already holds `eventsLockPath(paths)`. Split
311
232
  * out so `commitQueuedEvents` can run a whole batch of appends (and the rebuild that
@@ -355,7 +276,8 @@ export function appendEvent(event: WorldServiceEvent, root?: string): AppendEven
355
276
  const parsed = WorldServiceEventSchema.parse(event);
356
277
  const paths = worldPaths(parsed.service, root);
357
278
  ensureEventDirs(paths);
358
- return withFileLock(eventsLockPath(paths), () => appendEventLocked(parsed, paths));
279
+ return withFileLock(projectionLockPath(paths), () =>
280
+ withFileLock(eventsLockPath(paths), () => appendEventLocked(parsed, paths)));
359
281
  }
360
282
 
361
283
  function queuedEventId(event: WorldServiceEvent): string {
@@ -373,8 +295,9 @@ export function enqueueEvent(
373
295
  ): EnqueueEventResult {
374
296
  const parsed = WorldServiceEventSchema.parse(event);
375
297
  const paths = worldPaths(parsed.service, options.root);
376
- mkdirSync(paths.dir, { recursive: true });
377
- mkdirSync(paths.ingests, { recursive: true });
298
+ const store = getActiveWorldStore();
299
+ store.mkdir(paths.dir);
300
+ store.mkdir(paths.ingests);
378
301
  const queued: QueuedWorldServiceEvent = {
379
302
  id: options.id ?? queuedEventId(parsed),
380
303
  service: parsed.service,
@@ -417,7 +340,7 @@ export function commitQueuedEvents(
417
340
  const queued = listQueuedEvents(service, options.root).sort((a, b) => a.event.occurredAt.localeCompare(b.event.occurredAt) || a.receivedAt.localeCompare(b.receivedAt) || a.id.localeCompare(b.id));
418
341
  const limit = options.limit ?? queued.length;
419
342
 
420
- return withFileLock(eventsLockPath(paths), () => {
343
+ return withFileLock(projectionLockPath(paths), () => withFileLock(eventsLockPath(paths), () => {
421
344
  let committed = 0;
422
345
  let skipped = 0;
423
346
  const eventIds: string[] = [];
@@ -433,7 +356,7 @@ export function commitQueuedEvents(
433
356
  }
434
357
  if (committed > 0) rebuildGenericState(service, options.root);
435
358
  return { service, queued: Math.min(queued.length, limit), committed, skipped, eventIds };
436
- });
359
+ }));
437
360
  }
438
361
 
439
362
  // NOTE: annotation read/write (listAnnotations/addAnnotation/createAnnotation) moved
@@ -486,7 +409,7 @@ export function rebuildGenericState(service: string, root?: string): GenericWorl
486
409
 
487
410
  export function loadState<T = unknown>(service: string, root?: string): T | null {
488
411
  const paths = worldPaths(service, root);
489
- if (!existsSync(paths.state)) return null;
412
+ if (!getActiveWorldStore().exists(paths.state)) return null;
490
413
  return readJsonFile<T>(paths.state);
491
414
  }
492
415
 
@@ -528,7 +451,7 @@ export type ScrubResult = {
528
451
  * use. storage.ts itself only accounts for events.jsonl/event-queue.jsonl/
529
452
  * state.json/resources/cursors/ingests; the rest are written by other modules
530
453
  * that share the same service dir (actions.ts, pushLedger.ts, plan.ts, lease.ts,
531
- * refs.ts, fork.ts, queueLifecycle.ts) — this set MUST stay in sync with every
454
+ * refs.ts, fork.ts, queueLifecycle.ts, and connector-owned coordination) — this set MUST stay in sync with every
532
455
  * one of them so scrubService never demands --force for an ordinary twin. */
533
456
  const KNOWN_SERVICE_ENTRIES = new Set([
534
457
  'events.jsonl',
@@ -555,6 +478,9 @@ const KNOWN_SERVICE_ENTRIES = new Set([
555
478
  // refs.ts: remote/local checkpoint refs, under refs/remote/<provider>/<name>.json
556
479
  // and refs/local/<forkId>.json.
557
480
  'refs',
481
+ // Vendor connectors may keep private, non-secret workflow leases and monotone observation clocks
482
+ // here when an async provider span must be serialized across processes.
483
+ 'connector-workflows',
558
484
  ]);
559
485
 
560
486
  function isKnownServiceEntry(name: string): boolean {
@@ -565,29 +491,32 @@ function isKnownServiceEntry(name: string): boolean {
565
491
  * storage.ts is known to create. A directory that doesn't exist yet trivially
566
492
  * looks fine (scrub will just no-op on it). */
567
493
  function looksLikeServiceStateDir(dir: string): boolean {
568
- if (!existsSync(dir)) return true;
569
- return readdirSync(dir).every((entry) => isKnownServiceEntry(entry));
494
+ const store = getActiveWorldStore();
495
+ if (!store.exists(dir)) return true;
496
+ return store.list(dir).every((entry) => isKnownServiceEntry(entry));
570
497
  }
571
498
 
572
499
  /** The whole world dir "looks like" ours when every entry in it is itself a
573
500
  * directory that looks like a service state dir (each service gets one subdir
574
501
  * under worldStateRoot — see worldPaths). */
575
502
  function looksLikeWorldStateDir(dir: string): boolean {
576
- if (!existsSync(dir)) return true;
577
- return readdirSync(dir).every((entry) => {
503
+ const store = getActiveWorldStore();
504
+ if (!store.exists(dir)) return true;
505
+ return store.list(dir).every((entry) => {
578
506
  const full = join(dir, entry);
579
- return statSync(full).isDirectory() && looksLikeServiceStateDir(full);
507
+ return (store.stat(full)?.isDirectory ?? false) && looksLikeServiceStateDir(full);
580
508
  });
581
509
  }
582
510
 
583
511
  /** Every file under `dir`, path relative to `dir`, depth-first. Used to report
584
512
  * exactly what scrub is about to remove before it removes it. */
585
513
  function listFilesRecursive(dir: string, base: string = dir): string[] {
586
- if (!existsSync(dir)) return [];
514
+ const store = getActiveWorldStore();
515
+ if (!store.exists(dir)) return [];
587
516
  const out: string[] = [];
588
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
589
- const full = join(dir, entry.name);
590
- if (entry.isDirectory()) out.push(...listFilesRecursive(full, base));
517
+ for (const name of store.list(dir)) {
518
+ const full = join(dir, name);
519
+ if (store.stat(full)?.isDirectory) out.push(...listFilesRecursive(full, base));
591
520
  else out.push(full.slice(base.length + 1));
592
521
  }
593
522
  return out;
@@ -599,7 +528,8 @@ function scrubDir(
599
528
  looksLike: (d: string) => boolean,
600
529
  options: { force?: boolean },
601
530
  ): ScrubResult {
602
- if (!existsSync(dir)) {
531
+ const store = getActiveWorldStore();
532
+ if (!store.exists(dir)) {
603
533
  return { target: dir, removed: [], message: `nothing to scrub: ${label} (${dir}) does not exist` };
604
534
  }
605
535
  if (!options.force && !looksLike(dir)) {
@@ -609,7 +539,7 @@ function scrubDir(
609
539
  );
610
540
  }
611
541
  const removed = listFilesRecursive(dir);
612
- rmSync(dir, { recursive: true, force: true });
542
+ store.remove(dir);
613
543
  return { target: dir, removed, message: `scrubbed ${label}: removed ${removed.length} file(s) under ${dir}` };
614
544
  }
615
545
 
@@ -629,4 +559,3 @@ export function scrubWorld(options: { root?: string; force?: boolean } = {}): Sc
629
559
  const dir = worldStateRoot(options.root);
630
560
  return scrubDir(dir, 'entire world state dir', looksLikeWorldStateDir, options);
631
561
  }
632
-
package/src/sync.ts CHANGED
@@ -23,15 +23,26 @@ import { performExternalWrite } from './egress.ts';
23
23
  import type { EgressWriteResult } from './egress.ts';
24
24
  import { acquireLease, releaseLease } from './lease.ts';
25
25
  import type { WorldRemoteRef } from './refs.ts';
26
- import { buildShadowState, recordObservedDelta } from './shadow.ts';
26
+ import { NonFastForwardPushError } from './pushLedger.ts';
27
+ import { buildShadowState, recordObservedDeltaLocked, remoteRefs } from './shadow.ts';
27
28
  import type { SubjectFields } from './shadow.ts';
29
+ import { eventsLockPath, projectionLockPath, twinLog, withFileLock, worldPaths } from './storage.ts';
28
30
  import { twinResources } from './serve.ts';
29
31
  import type { TwinResource } from './serve.ts';
30
32
  import { reconcileRequiresApproval } from './reconcile.ts';
31
33
  import type { ReconcilePlan } from './reconcile.ts';
32
34
 
33
35
  // A resource observed from (or destined for) the real vendor.
34
- export type SyncResource = { type: string; id: string; fields: SubjectFields };
36
+ export type SyncResource = {
37
+ type: string;
38
+ id: string;
39
+ fields: SubjectFields;
40
+ /** When the PROVIDER says this resource last changed. One pull can carry subjects of
41
+ * different ages — a comment written an hour ago rides the same poll as the issue it
42
+ * hangs off — and the poll clock would date them all alike, so a resource that knows
43
+ * its own instant says so here and the pull's `occurredAt` covers only the rest. */
44
+ occurredAt?: string;
45
+ };
35
46
 
36
47
  export type PullResult = { observed: number; deltasAppended: number; unchanged: number };
37
48
 
@@ -41,6 +52,11 @@ export type PullResult = { observed: number; deltasAppended: number; unchanged:
41
52
  * is the pure fold. Only changed fields produce a delta (recordObservedDelta is
42
53
  * a no-op when nothing differs), so re-pulling identical state appends nothing.
43
54
  *
55
+ * `occurredAt` here is the POLL's instant — the fallback for resources that carry no
56
+ * provider timestamp of their own. A resource that does (`SyncResource.occurredAt`)
57
+ * dates its own delta, because a consumer ordering a feed wants the moment the vendor
58
+ * changed the thing, not the moment we happened to look.
59
+ *
44
60
  * `redact` (TWIN-45 dev/01 — implemented, not just documented) is an optional
45
61
  * transform applied to each resource BEFORE it is folded into the event log, so
46
62
  * a sensitive field never lands on disk in the first place — a redact-on-pull
@@ -60,22 +76,25 @@ export function syncPull(opts: {
60
76
  redact?: (resource: SyncResource) => SyncResource;
61
77
  }): PullResult {
62
78
  const { service, resources, occurredAt, root } = opts;
63
- let deltasAppended = 0;
64
- let unchanged = 0;
65
- // Rebuild the shadow once; recordObservedDelta appends to the log and the next
66
- // build reflects it, so process sequentially.
67
- for (const raw of resources) {
68
- const res = opts.redact ? opts.redact(raw) : raw;
79
+ const paths = worldPaths(service, root);
80
+ // One connector pull is one observed snapshot. Keep every resource delta under the same outer
81
+ // projection lock so a state-dependent local write cannot interleave halfway through it.
82
+ return withFileLock(projectionLockPath(paths), () => withFileLock(eventsLockPath(paths), () => {
83
+ let deltasAppended = 0;
84
+ let unchanged = 0;
69
85
  const shadow = buildShadowState(service, () => null, root);
70
- const result = recordObservedDelta(
71
- shadow,
72
- { service, subject: { type: res.type, id: res.id }, observed: res.fields, occurredAt, ...(opts.actor ? { actor: opts.actor } : {}) },
73
- root,
74
- );
75
- if (result.changed) deltasAppended += 1;
76
- else unchanged += 1;
77
- }
78
- return { observed: resources.length, deltasAppended, unchanged };
86
+ for (const raw of resources) {
87
+ const res = opts.redact ? opts.redact(raw) : raw;
88
+ const result = recordObservedDeltaLocked(
89
+ shadow,
90
+ { service, subject: { type: res.type, id: res.id }, observed: res.fields, occurredAt: res.occurredAt ?? occurredAt, ...(opts.actor ? { actor: opts.actor } : {}) },
91
+ paths,
92
+ );
93
+ if (result.changed) deltasAppended += 1;
94
+ else unchanged += 1;
95
+ }
96
+ return { observed: resources.length, deltasAppended, unchanged };
97
+ }));
79
98
  }
80
99
 
81
100
  export type PushItemResult = { id: string; type: string; status: EgressWriteResult['status']; externalId?: string };
@@ -117,11 +136,38 @@ export async function syncPush(opts: {
117
136
  approve: boolean;
118
137
  root?: string;
119
138
  actor?: { kind: 'agent' | 'human' | 'bot' | 'system'; id?: string };
139
+ /** R14 non-fast-forward gate. Capture with `remoteRefs(service, subjects, root, 'raw')`
140
+ * AT THE MOMENT the plan's `real` input was pulled, and pass the map here. Before any
141
+ * write, every toPush subject's ref is re-read in the SAME raw mode — a plan is stale
142
+ * the moment ANY mirror event lands after its pull, our own push confirmations
143
+ * included (a pushTransaction that confirmed between capture and push moved the
144
+ * remote; this plan's fields predate it). Any moved ref, or any toPush subject the
145
+ * capture did not cover (fail-closed: unknown base = no claim), refuses the WHOLE push
146
+ * before the lease is acquired or `write` is called — zero side effects.
147
+ * Omitted = ungated (pre-R14 callers), logged. */
148
+ expectedRefs?: Record<string, string | null>;
120
149
  }): Promise<PushResult> {
121
150
  const { service, plan, write, root } = opts;
122
151
  if (reconcileRequiresApproval(plan) && !opts.approve) {
123
152
  throw new Error(`sync push for ${service} requires approval (unresolved conflicts, or a twin-wins push overwriting a real-side change); pass approve:true to push`);
124
153
  }
154
+ if (opts.expectedRefs !== undefined) {
155
+ const uncovered = plan.toPush.map((item) => `${item.type}:${item.id}`).filter((key) => !(key in opts.expectedRefs!));
156
+ if (uncovered.length > 0) {
157
+ throw new Error(`sync push for ${service}: expectedRefs covers no base for ${uncovered.join(', ')} — re-capture refs (remoteRefs …, 'raw') for every toPush subject`);
158
+ }
159
+ const current = remoteRefs(service, plan.toPush.map((item) => ({ type: item.type, id: item.id })), root, 'raw');
160
+ const drifted: Record<string, { base: string | null; current: string | null }> = {};
161
+ for (const item of plan.toPush) {
162
+ const key = `${item.type}:${item.id}`;
163
+ if (current[key] !== opts.expectedRefs[key]) {
164
+ drifted[key] = { base: opts.expectedRefs[key] ?? null, current: current[key] ?? null };
165
+ }
166
+ }
167
+ if (Object.keys(drifted).length > 0) throw new NonFastForwardPushError(`sync-push:${service}`, drifted);
168
+ } else {
169
+ twinLog('sync.push.ungated', { service, items: plan.toPush.length });
170
+ }
125
171
  const lease = acquireLease({
126
172
  service, provider: opts.remoteRef.provider, remoteRef: opts.remoteRef, planId: `sync-push:${service}`,
127
173
  holder: opts.holder, id: opts.leaseId, acquiredAt: opts.acquiredAt, expiresAt: opts.expiresAt, root,
@@ -0,0 +1,115 @@
1
+ // THE ONE HTTP ADAPTATION (runtime contract R12b): most packs' serve path is the same
2
+ // ~20 lines of glue — the keyless GET /twin manifest door, the body read, a header map,
3
+ // the dispatch into `handle<Vendor>TwinRequest` with the WORLD instant, and a JSON
4
+ // response. That glue is a MECHANISM, so it lives here once; a pack's differences are
5
+ // VALUES (its manifest, its header threading, its static handler options, its extra
6
+ // response headers). A pack whose wire genuinely exceeds this shape (SSE streaming,
7
+ // multipart adaptation, raw-bytes responses) writes its own fetch instead — openai is
8
+ // that reference; this adapter is the reference for everyone else.
9
+ //
10
+ // Workerd-clean by construction: no Bun APIs, no fs, no clock but worldNow() — the same
11
+ // closure serves under Bun.serve locally and mounted in-process on Cloudflare.
12
+ import { worldNow } from './world-clock.ts';
13
+
14
+ export type TwinFetchHandlerResult = {
15
+ status: number;
16
+ body: unknown;
17
+ headers?: Record<string, string>;
18
+ };
19
+
20
+ /** The common handler request shape. Extra vendor fields ride `handlerOptions` (static,
21
+ * per instance) and `extras` (derived per request); handlers ignore keys they don't
22
+ * declare, so the union is safe to pass wholesale. */
23
+ export type TwinFetchHandlerRequest = {
24
+ method: string;
25
+ path: string;
26
+ body: string;
27
+ headers: Record<string, string>;
28
+ readOnly: boolean;
29
+ occurredAt: string;
30
+ root?: string;
31
+ } & Record<string, unknown>;
32
+
33
+ export interface TwinFetchAdapterConfig {
34
+ root?: string;
35
+ readOnly?: boolean;
36
+ /** The keyless GET /twin body — a value (stateful packs: statefulTwinManifest(...)) or
37
+ * a thunk (generative packs build theirs around the scenario engine). */
38
+ manifest: unknown | (() => unknown);
39
+ /** GET /twin/scenario body (thunk) — scenario-carrying packs only. */
40
+ scenarioStatus?: () => unknown;
41
+ /** THE STORE DOOR (R5c, mirror purity R3): named, deterministic projections over stored
42
+ * state, served at `GET /twin/store/<name>` and listed in the manifest as `stores`. A
43
+ * mirror reads twin state ONLY through this door — never by importing the handler or a
44
+ * `-twin-internal` module — so the vendor API lacking a listing endpoint (resend has no
45
+ * list-emails) no longer breeds a bespoke `/_twin/*` route per mirror. Keyed in the skin
46
+ * like every state read (only bare `GET /twin` is keyless). */
47
+ stores?: Record<string, () => unknown | Promise<unknown>>;
48
+ /** Per-request vendor extras (header threading: stripe-version, notion-version, …). */
49
+ extras?: (request: Request, url: URL) => Record<string, unknown>;
50
+ /** Static per-instance handler options (e.g. rateLimitPerSecond). */
51
+ handlerOptions?: Record<string, unknown>;
52
+ /** Extra response headers on every non-door reply (e.g. stripe's request-id, and
53
+ * postmark's `connection: close` Bun-socket workaround — inert under workerd). */
54
+ responseHeaders?: Record<string, string>;
55
+ }
56
+
57
+ export function createTwinFetchFromHandler(
58
+ handler: (request: TwinFetchHandlerRequest) => TwinFetchHandlerResult | Promise<TwinFetchHandlerResult>,
59
+ config: TwinFetchAdapterConfig,
60
+ ): (request: Request) => Promise<Response> {
61
+ const readOnly = config.readOnly ?? false;
62
+ return async function twinFetch(request: Request): Promise<Response> {
63
+ const url = new URL(request.url);
64
+ const cleanPath = url.pathname.replace(/\/+$/, '') || '/';
65
+ if (request.method === 'GET' && cleanPath === '/twin') {
66
+ const manifest = typeof config.manifest === 'function' ? (config.manifest as () => unknown)() : config.manifest;
67
+ const storeNames = Object.keys(config.stores ?? {}).sort();
68
+ if (storeNames.length > 0 && manifest !== null && typeof manifest === 'object') {
69
+ const m = manifest as Record<string, unknown>;
70
+ const doors = m.doors !== null && typeof m.doors === 'object' ? (m.doors as Record<string, unknown>) : {};
71
+ // The manifest EDUCATES: it lists the store names and names the door's path.
72
+ return Response.json({ ...m, stores: storeNames, doors: { ...doors, store: 'GET /twin/store/<name>' } });
73
+ }
74
+ return Response.json(manifest);
75
+ }
76
+ if (request.method === 'GET' && cleanPath === '/twin/scenario' && config.scenarioStatus !== undefined) {
77
+ return Response.json(config.scenarioStatus());
78
+ }
79
+ if (request.method === 'GET' && cleanPath.startsWith('/twin/store/')) {
80
+ const name = cleanPath.slice('/twin/store/'.length);
81
+ const store = config.stores?.[name];
82
+ if (store === undefined) return Response.json({ error: 'unknown store', store: name, stores: Object.keys(config.stores ?? {}).sort() }, { status: 404 });
83
+ return Response.json(await store()); // a projection may read through the handler (async) — the door awaits it
84
+ }
85
+ const body = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text();
86
+ const headers: Record<string, string> = {};
87
+ request.headers.forEach((value, key) => { headers[key] = value; });
88
+ // FIXED FIELDS WIN (review B1): handlerOptions/extras spread FIRST, so no config —
89
+ // and via extras, no WIRE INPUT — can override method/path/body/readOnly or the
90
+ // world instant. A client-settable occurredAt would be a direct R9 hole.
91
+ const result = await handler({
92
+ ...config.handlerOptions,
93
+ ...config.extras?.(request, url),
94
+ method: request.method,
95
+ path: url.pathname + (url.search || ''),
96
+ body,
97
+ headers,
98
+ readOnly,
99
+ occurredAt: worldNow(),
100
+ ...(config.root !== undefined ? { root: config.root } : {}),
101
+ });
102
+ // A null/undefined body is an EMPTY reply (`JSON.stringify(null)` would serve the
103
+ // four bytes "null") — and so is the estate's own 204 idiom, `body: ''` with a
104
+ // null-body status: workerd THROWS on any body with 204/205/304 (review B2), where
105
+ // Bun silently serves '""' — either way a wire break on exactly one lane.
106
+ const nullBodyStatus = result.status === 204 || result.status === 205 || result.status === 304;
107
+ if (result.body === null || result.body === undefined || (nullBodyStatus && result.body === '')) {
108
+ return new Response(null, { status: result.status, headers: { ...config.responseHeaders, ...result.headers } });
109
+ }
110
+ return new Response(JSON.stringify(result.body), {
111
+ status: result.status,
112
+ headers: { 'content-type': 'application/json', ...config.responseHeaders, ...result.headers },
113
+ });
114
+ };
115
+ }
package/src/validate.ts CHANGED
@@ -2,10 +2,10 @@
2
2
  // reconcile (no write left without a recorded result past the grace window). NOTE:
3
3
  // annotation integrity (quotes resolve, events annotated, …) moved to
4
4
  // @volter/tracker/world-annotations — that's a verification (tracker) concern.
5
- import { existsSync, readdirSync } from 'node:fs';
6
5
  import { join, resolve } from 'node:path';
7
6
  import { listEgressLedger } from './egress.ts';
8
7
  import { worldStateRoot } from './storage.ts';
8
+ import { getActiveWorldStore } from './world-store.ts';
9
9
 
10
10
  export type WorldValidationFinding = {
11
11
  level: 'error' | 'warning';
@@ -35,11 +35,12 @@ function stringValue(value: unknown): string {
35
35
  }
36
36
 
37
37
  export function discoverWorldServices(root: string): string[] {
38
+ const store = getActiveWorldStore();
38
39
  const worldRoot = worldStateRoot(root);
39
- if (!existsSync(worldRoot)) return [];
40
- return readdirSync(worldRoot, { withFileTypes: true })
41
- .filter((entry) => entry.isDirectory() && existsSync(join(worldRoot, entry.name, 'events.jsonl')))
42
- .map((entry) => entry.name)
40
+ if (!store.exists(worldRoot)) return [];
41
+ return store
42
+ .list(worldRoot)
43
+ .filter((name) => (store.stat(join(worldRoot, name))?.isDirectory ?? false) && store.exists(join(worldRoot, name, 'events.jsonl')))
43
44
  .sort();
44
45
  }
45
46