@volter/twin 0.1.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.
Files changed (82) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +68 -0
  3. package/dist/src/actions.d.ts +138 -0
  4. package/dist/src/actions.js +201 -0
  5. package/dist/src/args.d.ts +3 -0
  6. package/dist/src/args.js +12 -0
  7. package/dist/src/cli.d.ts +2 -0
  8. package/dist/src/cli.js +425 -0
  9. package/dist/src/connector.d.ts +106 -0
  10. package/dist/src/connector.js +129 -0
  11. package/dist/src/control-plane.d.ts +21 -0
  12. package/dist/src/control-plane.js +40 -0
  13. package/dist/src/egress.d.ts +93 -0
  14. package/dist/src/egress.js +264 -0
  15. package/dist/src/fork.d.ts +126 -0
  16. package/dist/src/fork.js +206 -0
  17. package/dist/src/index.d.ts +42 -0
  18. package/dist/src/index.js +52 -0
  19. package/dist/src/lease.d.ts +50 -0
  20. package/dist/src/lease.js +80 -0
  21. package/dist/src/packRegistry.d.ts +34 -0
  22. package/dist/src/packRegistry.js +22 -0
  23. package/dist/src/plan.d.ts +97 -0
  24. package/dist/src/plan.js +151 -0
  25. package/dist/src/proxy.d.ts +25 -0
  26. package/dist/src/proxy.js +152 -0
  27. package/dist/src/pushLedger.d.ts +81 -0
  28. package/dist/src/pushLedger.js +130 -0
  29. package/dist/src/queueLifecycle.d.ts +62 -0
  30. package/dist/src/queueLifecycle.js +95 -0
  31. package/dist/src/reconcile.d.ts +58 -0
  32. package/dist/src/reconcile.js +137 -0
  33. package/dist/src/refs.d.ts +29 -0
  34. package/dist/src/refs.js +68 -0
  35. package/dist/src/schemas.d.ts +78 -0
  36. package/dist/src/schemas.js +50 -0
  37. package/dist/src/serve.d.ts +44 -0
  38. package/dist/src/serve.js +93 -0
  39. package/dist/src/shadow.d.ts +77 -0
  40. package/dist/src/shadow.js +138 -0
  41. package/dist/src/status.d.ts +31 -0
  42. package/dist/src/status.js +42 -0
  43. package/dist/src/storage.d.ts +119 -0
  44. package/dist/src/storage.js +535 -0
  45. package/dist/src/sync.d.ts +91 -0
  46. package/dist/src/sync.js +121 -0
  47. package/dist/src/types.d.ts +40 -0
  48. package/dist/src/types.js +1 -0
  49. package/dist/src/validate.d.ts +27 -0
  50. package/dist/src/validate.js +68 -0
  51. package/dist/src/visualizer.d.ts +13 -0
  52. package/dist/src/visualizer.js +133 -0
  53. package/dist/src/worldConfig.d.ts +9 -0
  54. package/dist/src/worldConfig.js +16 -0
  55. package/inject.cjs +429 -0
  56. package/package.json +81 -0
  57. package/src/actions.ts +285 -0
  58. package/src/args.ts +14 -0
  59. package/src/cli.ts +443 -0
  60. package/src/connector.ts +220 -0
  61. package/src/control-plane.ts +66 -0
  62. package/src/egress.ts +355 -0
  63. package/src/fork.ts +256 -0
  64. package/src/index.ts +222 -0
  65. package/src/lease.ts +97 -0
  66. package/src/packRegistry.ts +60 -0
  67. package/src/plan.ts +190 -0
  68. package/src/proxy.ts +180 -0
  69. package/src/pushLedger.ts +189 -0
  70. package/src/queueLifecycle.ts +130 -0
  71. package/src/reconcile.ts +192 -0
  72. package/src/refs.ts +91 -0
  73. package/src/schemas.ts +56 -0
  74. package/src/serve.ts +120 -0
  75. package/src/shadow.ts +192 -0
  76. package/src/status.ts +58 -0
  77. package/src/storage.ts +632 -0
  78. package/src/sync.ts +160 -0
  79. package/src/types.ts +50 -0
  80. package/src/validate.ts +95 -0
  81. package/src/visualizer.ts +142 -0
  82. package/src/worldConfig.ts +26 -0
package/src/status.ts ADDED
@@ -0,0 +1,58 @@
1
+ // world status (the twins architecture notes → "Status"). The operator safety
2
+ // dashboard: it makes the four state classes impossible to confuse — queued
3
+ // deliveries, canonical observed events, local transaction commits, and push/apply
4
+ // records — plus drift, conflicts, and stale bases. Pure read over the kernel
5
+ // ledgers; deterministic.
6
+ import { listActions, pendingActions } from './actions.ts';
7
+ import { isFork, forkDivergence } from './fork.ts';
8
+ import { pendingConflicts } from './plan.ts';
9
+ import { unconfirmedPushes } from './pushLedger.ts';
10
+ import { queueCounts } from './queueLifecycle.ts';
11
+ import type { QueueCounts } from './queueLifecycle.ts';
12
+ import { isBaseStale, readLocalRef, readRemoteRef } from './refs.ts';
13
+ import type { WorldRemoteRef } from './refs.ts';
14
+
15
+ export type WorldStatus = {
16
+ service: string;
17
+ remote: WorldRemoteRef | null;
18
+ queue: QueueCounts;
19
+ local: { unpushed: number; reverted: number };
20
+ push: { unconfirmed: number };
21
+ drift: { created: number; changed: number } | null;
22
+ conflicts: Array<{ actionId: string; reason: string }>;
23
+ staleBase: boolean | null;
24
+ };
25
+
26
+ export function worldStatus(
27
+ service: string,
28
+ opts: { root?: string; forkId?: string; provider?: string; refName?: string } = {},
29
+ ): WorldStatus {
30
+ const { root, forkId, provider, refName = 'main' } = opts;
31
+ const actions = listActions(service, root);
32
+ const remote = provider ? readRemoteRef(service, provider, refName, root) : null;
33
+ const local = forkId ? readLocalRef(service, forkId, root) : null;
34
+ const drift = isFork(service, root ?? '') ? (() => { const d = forkDivergence(service, root ?? ''); return { created: d.created.length, changed: d.changed.length }; })() : null;
35
+ return {
36
+ service,
37
+ remote,
38
+ queue: queueCounts(service, root),
39
+ local: { unpushed: pendingActions(service, root).length, reverted: actions.filter((a) => a.op === 'revert').length },
40
+ push: { unconfirmed: unconfirmedPushes(service, root).length },
41
+ drift,
42
+ conflicts: pendingConflicts(service, root),
43
+ staleBase: local ? isBaseStale(local, root) : null,
44
+ };
45
+ }
46
+
47
+ /** Render the status as the operator text block the doc shows. */
48
+ export function formatStatus(s: WorldStatus): string {
49
+ const lines: string[] = [];
50
+ lines.push(s.remote ? `remote/${s.remote.provider}/${s.remote.name} at ${s.remote.cursor ?? s.remote.eventId ?? '(no checkpoint)'}` : 'remote: (no ref recorded)');
51
+ lines.push(`queue: ${s.queue.queued} queued, ${s.queue.committed} committed, ${s.queue.ignored} ignored, ${s.queue.superseded} superseded, ${s.queue.poisoned} poisoned`);
52
+ lines.push(`local: ${s.local.unpushed} unpushed transactions, ${s.local.reverted} reverted`);
53
+ lines.push(`push: ${s.push.unconfirmed} provider_accepted/attempted awaiting observed confirmation`);
54
+ if (s.drift) lines.push(`drift: ${s.drift.created} created, ${s.drift.changed} changed since fork base`);
55
+ lines.push(`conflicts: ${s.conflicts.length}${s.conflicts.length ? ` (${s.conflicts.map((c) => c.actionId).join(', ')})` : ''}`);
56
+ if (s.staleBase !== null) lines.push(`base: ${s.staleBase ? 'STALE — rebase before push' : 'current'}`);
57
+ return lines.join('\n');
58
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,632 @@
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
+ import { dirname, join, resolve } from 'node:path';
4
+ import {
5
+ GenericWorldStateSchema,
6
+ WorldServiceEventSchema,
7
+ } from './schemas.ts';
8
+ import type {
9
+ AppendEventResult,
10
+ CommitQueuedEventsResult,
11
+ EnqueueEventResult,
12
+ GenericWorldState,
13
+ QueuedWorldServiceEvent,
14
+ WorldPaths,
15
+ WorldReducer,
16
+ WorldServiceEvent,
17
+ } from './types.ts';
18
+
19
+ function nowIso(): string {
20
+ return new Date().toISOString();
21
+ }
22
+
23
+ function projectRoot(root?: string): string {
24
+ return resolve(root || process.env.PROJECT_ROOT || process.cwd());
25
+ }
26
+
27
+ /**
28
+ * Name of the per-project state directory holding world data
29
+ * (`<root>/<stateDir>/world/...`). Defaults to `.volter`; hosts that need a
30
+ * different directory set VOLTER_STATE_DIR. Every path in this package must
31
+ * go through worldStateRoot — never the literal.
32
+ */
33
+ export function stateDirName(): string {
34
+ return process.env.VOLTER_STATE_DIR || '.volter';
35
+ }
36
+
37
+ export function worldStateRoot(root?: string): string {
38
+ return join(projectRoot(root), stateDirName(), 'world');
39
+ }
40
+
41
+ function assertServiceName(service: string): string {
42
+ if (!/^[A-Za-z0-9_-]+$/.test(service)) {
43
+ throw new Error(`Invalid world service name: ${service}`);
44
+ }
45
+ return service;
46
+ }
47
+
48
+ export function worldPaths(service: string, root?: string): WorldPaths {
49
+ const safeService = assertServiceName(service);
50
+ const resolvedRoot = projectRoot(root);
51
+ const dir = join(worldStateRoot(root), safeService);
52
+ return {
53
+ root: resolvedRoot,
54
+ service: safeService,
55
+ dir,
56
+ events: join(dir, 'events.jsonl'),
57
+ state: join(dir, 'state.json'),
58
+ resources: join(dir, 'resources'),
59
+ cursors: join(dir, 'cursors'),
60
+ ingests: join(dir, 'ingests'),
61
+ eventQueue: join(dir, 'event-queue.jsonl'),
62
+ };
63
+ }
64
+
65
+ function readJsonl<T>(path: string): T[] {
66
+ if (!existsSync(path)) return [];
67
+ const rows: T[] = [];
68
+ for (const [index, line] of readFileSync(path, 'utf8').split('\n').entries()) {
69
+ if (!line.trim()) continue;
70
+ try {
71
+ rows.push(JSON.parse(line) as T);
72
+ } catch (error) {
73
+ throw new Error(`${path}:${index + 1}: invalid JSONL row: ${(error as Error).message}`);
74
+ }
75
+ }
76
+ return rows;
77
+ }
78
+
79
+ /** Read + parse a whole-file JSON sidecar, citing the path on a parse failure (mirrors
80
+ * readJsonl's `${path}:...` error shape). Callers own existence semantics — this parses a
81
+ * file that is expected to exist; guard with existsSync first when absence is allowed. */
82
+ export function readJsonFile<T>(path: string): T {
83
+ try {
84
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
85
+ } catch (error) {
86
+ throw new Error(`${path}: invalid JSON: ${(error as Error).message}`);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Append `data` to `path`. When VOLTER_DURABLE=1, `fsyncSync` the file after the write so
92
+ * the appended record survives an OS crash / power loss — not just a process crash.
93
+ *
94
+ * Default (VOLTER_DURABLE unset) is OFF: `appendFileSync` is a completed write syscall, so a
95
+ * *process* crash after it returns still leaves the record on the log (the OS owns the page
96
+ * cache). The window this leaves open is a **kernel-panic / power loss** between the write
97
+ * landing in the page cache and the fs flushing it to stable storage — a torn or lost tail
98
+ * record. That crash window matters more once real pulled staging data lives in these files
99
+ * (purpose 2), so hosts that need durability opt in with VOLTER_DURABLE=1 at the cost of an
100
+ * fsync per append. See ARCHITECTURE.md D1.
101
+ */
102
+ 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
+ }
110
+ }
111
+
112
+ /**
113
+ * Opt-in structured stderr logging for the audit trail (ARCHITECTURE-REVIEW-TODOS D3):
114
+ * one line per action-log append and per push-ledger row, so "who reviewed the change
115
+ * that caused this real write" is mechanically greppable from a single log stream via
116
+ * `correlationId`. Off by default — mirrors the VOLTER_DURABLE opt-in policy above: no
117
+ * always-on I/O, never on the default path. Set VOLTER_TWIN_LOG=1 to enable.
118
+ */
119
+ export function twinLog(kind: string, details: Record<string, unknown>): void {
120
+ if (process.env.VOLTER_TWIN_LOG !== '1') return;
121
+ console.error(`[twin:${kind}]`, JSON.stringify(details));
122
+ }
123
+
124
+ 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;
177
+ }
178
+
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. */
185
+ 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
+ }
233
+ }
234
+
235
+ 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);
240
+ }
241
+
242
+ export function listEvents(service: string, root?: string): WorldServiceEvent[] {
243
+ const paths = worldPaths(service, root);
244
+ return readJsonl<unknown>(paths.events).map((row) => WorldServiceEventSchema.parse(row));
245
+ }
246
+
247
+ // ── Append dedupe index ─────────────────────────────────────────────────────
248
+ // appendEvent must check id/idempotencyKey uniqueness on every append; a full
249
+ // re-read + zod parse of the log per append is O(N) and turns a relay run
250
+ // into O(N·M). The index caches the id/key sets per events file, validated by
251
+ // file size AND mtime: the log is append-only under the file lock, so a
252
+ // matching size+mtime means the cache is current. Size alone is not enough —
253
+ // an external rewrite that replaces the file with SAME-SIZE-but-different
254
+ // content (e.g. a same-length id/key swap) is invisible to a size-only check
255
+ // and would leave the cache silently wrong (stale ids "known", new ids not),
256
+ // letting a genuine duplicate slip past dedup. mtime changes on any write
257
+ // (rewrite or append), so size+mtime together catch both a length-changing
258
+ // AND a length-preserving external rewrite; any such change forces a rebuild.
259
+ // Rebuilds use plain JSON.parse of two fields — full schema validation stays
260
+ // where it belongs (listEvents, and the event being appended).
261
+
262
+ type EventsIndex = { size: number; mtimeMs: number; ids: Set<string>; keys: Set<string> };
263
+
264
+ const eventsIndexCache = new Map<string, EventsIndex>();
265
+
266
+ function eventsFileStamp(eventsPath: string): { size: number; mtimeMs: number } {
267
+ if (!existsSync(eventsPath)) return { size: 0, mtimeMs: 0 };
268
+ const stat = statSync(eventsPath);
269
+ return { size: stat.size, mtimeMs: stat.mtimeMs };
270
+ }
271
+
272
+ function eventsIndexFor(eventsPath: string): EventsIndex {
273
+ const { size, mtimeMs } = eventsFileStamp(eventsPath);
274
+ const cached = eventsIndexCache.get(eventsPath);
275
+ if (cached && cached.size === size && cached.mtimeMs === mtimeMs) return cached;
276
+ const index: EventsIndex = { size, mtimeMs, ids: new Set(), keys: new Set() };
277
+ for (const row of readJsonl<{ id?: unknown; idempotencyKey?: unknown }>(eventsPath)) {
278
+ if (typeof row.id === 'string') index.ids.add(row.id);
279
+ if (typeof row.idempotencyKey === 'string') index.keys.add(row.idempotencyKey);
280
+ }
281
+ eventsIndexCache.set(eventsPath, index);
282
+ return index;
283
+ }
284
+
285
+ /** Targeted fetch of one event row by id or idempotencyKey (rare duplicate path). */
286
+ function findEventRow(eventsPath: string, id: string, idempotencyKey: string): WorldServiceEvent | null {
287
+ for (const row of readJsonl<{ id?: unknown; idempotencyKey?: unknown }>(eventsPath)) {
288
+ if (row.id === id || row.idempotencyKey === idempotencyKey) {
289
+ return WorldServiceEventSchema.parse(row);
290
+ }
291
+ }
292
+ return null;
293
+ }
294
+
295
+ 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 });
300
+ }
301
+
302
+ /** Exported so other modules that share a service's events log (e.g. egress.ts,
303
+ * TWIN-58) can serialize their own critical sections on the SAME lock appendEvent
304
+ * itself uses, instead of inventing a second, uncoordinated lockfile. */
305
+ export function eventsLockPath(paths: WorldPaths): string {
306
+ return join(paths.dir, 'events.jsonl.lock');
307
+ }
308
+
309
+ /**
310
+ * The append body, assuming the caller already holds `eventsLockPath(paths)`. Split
311
+ * out so `commitQueuedEvents` can run a whole batch of appends (and the rebuild that
312
+ * follows them) under ONE lock acquisition instead of nesting a fresh `withFileLock`
313
+ * per row — `withFileLock` is not reentrant, so re-acquiring it from inside an
314
+ * already-held lock in the same process would just spin to its own timeout. Also
315
+ * exported for egress.ts (TWIN-58): performExternalWrite's ledger-check +
316
+ * intent-append span holds `eventsLockPath` itself, so it must append through this
317
+ * already-locked path rather than the public `appendEvent` (which would try to
318
+ * re-acquire the same lock and spin to its own timeout).
319
+ */
320
+ export function appendEventLocked(parsed: WorldServiceEvent, paths: WorldPaths): AppendEventResult {
321
+ const index = eventsIndexFor(paths.events);
322
+ if (index.ids.has(parsed.id) || index.keys.has(parsed.idempotencyKey)) {
323
+ const duplicate = findEventRow(paths.events, parsed.id, parsed.idempotencyKey);
324
+ if (!duplicate) throw new Error(`World events index out of sync for ${paths.events}; delete nothing — rerun to rebuild`);
325
+ // observedAt (delivery time), occurredAt (poll-based connectors use
326
+ // poll-cycle time, not the resource's own timestamp), and external.url
327
+ // (a convenience pointer — provider URLs embed mutable title slugs,
328
+ // e.g. Linear) are metadata, not event identity: re-observations
329
+ // differing only there are duplicates, not conflicts.
330
+ // external.provider/id ARE identity.
331
+ const stripMetadata = (event: WorldServiceEvent) => {
332
+ const { observedAt: _observedAt, occurredAt: _occurredAt, external, ...content } = event;
333
+ if (!external) return content;
334
+ const { url: _url, ...externalIdentity } = external;
335
+ return { ...content, external: externalIdentity };
336
+ };
337
+ if (JSON.stringify(stripMetadata(duplicate)) !== JSON.stringify(stripMetadata(parsed))) {
338
+ throw new Error(
339
+ `Conflicting duplicate ${parsed.service} event: ${parsed.id} / ${parsed.idempotencyKey} conflicts with ${duplicate.id}`,
340
+ );
341
+ }
342
+ return { event: duplicate, appended: false, duplicateOf: duplicate.id };
343
+ }
344
+
345
+ appendJsonl(paths.events, parsed);
346
+ const stamp = eventsFileStamp(paths.events);
347
+ index.ids.add(parsed.id);
348
+ index.keys.add(parsed.idempotencyKey);
349
+ index.size = stamp.size;
350
+ index.mtimeMs = stamp.mtimeMs;
351
+ return { event: parsed, appended: true };
352
+ }
353
+
354
+ export function appendEvent(event: WorldServiceEvent, root?: string): AppendEventResult {
355
+ const parsed = WorldServiceEventSchema.parse(event);
356
+ const paths = worldPaths(parsed.service, root);
357
+ ensureEventDirs(paths);
358
+ return withFileLock(eventsLockPath(paths), () => appendEventLocked(parsed, paths));
359
+ }
360
+
361
+ function queuedEventId(event: WorldServiceEvent): string {
362
+ return `queue:${event.service}:${event.idempotencyKey}`;
363
+ }
364
+
365
+ export function listQueuedEvents(service: string, root?: string): QueuedWorldServiceEvent[] {
366
+ const paths = worldPaths(service, root);
367
+ return readJsonl<QueuedWorldServiceEvent>(paths.eventQueue);
368
+ }
369
+
370
+ export function enqueueEvent(
371
+ event: WorldServiceEvent,
372
+ options: { root?: string; source?: QueuedWorldServiceEvent['source']; receivedAt?: string; id?: string } = {},
373
+ ): EnqueueEventResult {
374
+ const parsed = WorldServiceEventSchema.parse(event);
375
+ const paths = worldPaths(parsed.service, options.root);
376
+ mkdirSync(paths.dir, { recursive: true });
377
+ mkdirSync(paths.ingests, { recursive: true });
378
+ const queued: QueuedWorldServiceEvent = {
379
+ id: options.id ?? queuedEventId(parsed),
380
+ service: parsed.service,
381
+ receivedAt: options.receivedAt ?? nowIso(),
382
+ source: options.source ?? 'listener',
383
+ event: parsed,
384
+ };
385
+
386
+ return withFileLock(join(paths.dir, 'event-queue.jsonl.lock'), () => {
387
+ const existing = readJsonl<QueuedWorldServiceEvent>(paths.eventQueue);
388
+ const duplicate = existing.find((candidate) => candidate.id === queued.id || candidate.event.idempotencyKey === queued.event.idempotencyKey);
389
+ if (duplicate) return { queued: duplicate, appended: false, duplicateOf: duplicate.id };
390
+ appendJsonl(paths.eventQueue, queued);
391
+ return { queued, appended: true };
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Commit queued (webhook/listener) events into the canonical log, then rebuild the
397
+ * generic projection — spanning BOTH under the SAME events-lock acquisition (D7).
398
+ * Appending each event and rebuilding state.json are two separate durable writes;
399
+ * without a shared lock, a concurrent commit/append on another process could
400
+ * interleave between "this commit's last append" and "this commit's rebuild",
401
+ * so the rebuilt state.json would not correspond to exactly this commit's view of
402
+ * the log. Holding one lock across the whole sequence rules that out: no other
403
+ * appendEvent/commitQueuedEvents call for this service can run until both the
404
+ * appends AND the rebuild here have completed. (A single-process crash between the
405
+ * last durable append and the rebuild can still leave state.json one rebuild behind
406
+ * — that window is inherent to a two-file update with no WAL, and is unaffected by
407
+ * locking; it is closed by the next successful commit/rebuild, which always starts
408
+ * from the durably-appended log, so no event is ever lost, only state.json's
409
+ * projection is briefly stale.)
410
+ */
411
+ export function commitQueuedEvents(
412
+ service: string,
413
+ options: { root?: string; limit?: number } = {},
414
+ ): CommitQueuedEventsResult {
415
+ const paths = worldPaths(service, options.root);
416
+ ensureEventDirs(paths);
417
+ 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
+ const limit = options.limit ?? queued.length;
419
+
420
+ return withFileLock(eventsLockPath(paths), () => {
421
+ let committed = 0;
422
+ let skipped = 0;
423
+ const eventIds: string[] = [];
424
+ for (const row of queued.slice(0, limit)) {
425
+ const parsed = WorldServiceEventSchema.parse(row.event);
426
+ const result = appendEventLocked(parsed, paths);
427
+ if (result.appended) {
428
+ committed += 1;
429
+ eventIds.push(result.event.id);
430
+ } else {
431
+ skipped += 1;
432
+ }
433
+ }
434
+ if (committed > 0) rebuildGenericState(service, options.root);
435
+ return { service, queued: Math.min(queued.length, limit), committed, skipped, eventIds };
436
+ });
437
+ }
438
+
439
+ // NOTE: annotation read/write (listAnnotations/addAnnotation/createAnnotation) moved
440
+ // to @volter/tracker/world-annotations — annotations are a tracker concern.
441
+
442
+ export function genericWorldReducer(state: GenericWorldState, event: WorldServiceEvent): GenericWorldState {
443
+ const key = `${event.subject.type}:${event.subject.id}`;
444
+ return GenericWorldStateSchema.parse({
445
+ ...state,
446
+ eventCount: state.eventCount + 1,
447
+ latestEventId: event.id,
448
+ subjects: {
449
+ ...state.subjects,
450
+ [key]: {
451
+ type: event.subject.type,
452
+ id: event.subject.id,
453
+ latestEventId: event.id,
454
+ latestType: event.type,
455
+ updatedAt: event.occurredAt || event.observedAt,
456
+ },
457
+ },
458
+ });
459
+ }
460
+
461
+ export function emptyGenericState(service: string): GenericWorldState {
462
+ return {
463
+ version: 1,
464
+ service: assertServiceName(service),
465
+ rebuiltAt: nowIso(),
466
+ eventCount: 0,
467
+ subjects: {},
468
+ };
469
+ }
470
+
471
+ export function rebuildState<State>(
472
+ service: string,
473
+ initialState: State,
474
+ reducer: WorldReducer<State>,
475
+ root?: string,
476
+ ): State {
477
+ const paths = worldPaths(service, root);
478
+ const state = listEvents(service, root).reduce(reducer, initialState);
479
+ writeJsonAtomic(paths.state, state);
480
+ return state;
481
+ }
482
+
483
+ export function rebuildGenericState(service: string, root?: string): GenericWorldState {
484
+ return rebuildState(service, emptyGenericState(service), genericWorldReducer, root);
485
+ }
486
+
487
+ export function loadState<T = unknown>(service: string, root?: string): T | null {
488
+ const paths = worldPaths(service, root);
489
+ if (!existsSync(paths.state)) return null;
490
+ return readJsonFile<T>(paths.state);
491
+ }
492
+
493
+ export function createEvent(input: Omit<WorldServiceEvent, 'schemaVersion' | 'observedAt'> & {
494
+ schemaVersion?: number;
495
+ observedAt?: string;
496
+ }): WorldServiceEvent {
497
+ return WorldServiceEventSchema.parse({
498
+ schemaVersion: 1,
499
+ observedAt: nowIso(),
500
+ ...input,
501
+ });
502
+ }
503
+
504
+ // ── Scrub: delete pulled data at rest (TWIN-45 dev/02a) ─────────────────────
505
+ // `sync pull` (sync.ts) folds real, customer-shaped resources into a service's
506
+ // event log + rebuilt state.json (see worldPaths); `scrub` is the honest
507
+ // counterpart — plain `rm` of exactly that on-disk data, nothing cleverer (no
508
+ // crypto-shredding; see docs/DATA_AT_REST.md). Two grains, matching what's
509
+ // actually there: one service's dir, or the whole world dir. Both refuse to
510
+ // touch a directory whose contents don't look like our own event-log shape,
511
+ // unless the caller passes `force: true` — the directories scrub ever removes
512
+ // are ALWAYS ones derived from worldPaths()/worldStateRoot() (never a raw path
513
+ // from the caller), so this check is defense in depth against a wrong `root`
514
+ // resolving somewhere unexpected, not a general path-safety mechanism.
515
+
516
+ export type ScrubResult = {
517
+ /** The directory scrub targeted (may not have existed). */
518
+ target: string;
519
+ /** Every file actually removed, path relative to `target`, in no particular order. */
520
+ removed: string[];
521
+ /** One-line human summary, safe to print as-is. */
522
+ message: string;
523
+ };
524
+
525
+ /** Filenames/dirnames ANY control-plane src module ever writes inside a single
526
+ * service's state dir (see worldPaths) — plus the lockfiles withFileLock creates
527
+ * next to a log and the `.tmp`/`.stale.*` sidecars its atomic-write/reclaim paths
528
+ * use. storage.ts itself only accounts for events.jsonl/event-queue.jsonl/
529
+ * state.json/resources/cursors/ingests; the rest are written by other modules
530
+ * 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
532
+ * one of them so scrubService never demands --force for an ordinary twin. */
533
+ const KNOWN_SERVICE_ENTRIES = new Set([
534
+ 'events.jsonl',
535
+ 'events.jsonl.lock',
536
+ 'event-queue.jsonl',
537
+ 'event-queue.jsonl.lock',
538
+ 'state.json',
539
+ 'resources',
540
+ 'cursors',
541
+ 'ingests',
542
+ // actions.ts: the local transaction-commit log + its lock.
543
+ 'actions.jsonl',
544
+ 'actions.jsonl.lock',
545
+ // pushLedger.ts: the push-phase ledger for real-vendor replication.
546
+ 'push-ledger.jsonl',
547
+ // queueLifecycle.ts: the append-only status ledger over event-queue.jsonl rows.
548
+ 'event-queue-status.jsonl',
549
+ // fork.ts: fork metadata (base snapshot + divergence baseline) for a fork root.
550
+ 'fork-meta.json',
551
+ // plan.ts: one JSON file per apply plan, under plans/<planId>.json.
552
+ 'plans',
553
+ // lease.ts: one JSON file per apply lease, under leases/<leaseId>.json.
554
+ 'leases',
555
+ // refs.ts: remote/local checkpoint refs, under refs/remote/<provider>/<name>.json
556
+ // and refs/local/<forkId>.json.
557
+ 'refs',
558
+ ]);
559
+
560
+ function isKnownServiceEntry(name: string): boolean {
561
+ return KNOWN_SERVICE_ENTRIES.has(name) || name.endsWith('.tmp') || /\.stale\.\d+\.\d+$/.test(name);
562
+ }
563
+
564
+ /** A service dir "looks like" one of ours when every entry in it is something
565
+ * storage.ts is known to create. A directory that doesn't exist yet trivially
566
+ * looks fine (scrub will just no-op on it). */
567
+ function looksLikeServiceStateDir(dir: string): boolean {
568
+ if (!existsSync(dir)) return true;
569
+ return readdirSync(dir).every((entry) => isKnownServiceEntry(entry));
570
+ }
571
+
572
+ /** The whole world dir "looks like" ours when every entry in it is itself a
573
+ * directory that looks like a service state dir (each service gets one subdir
574
+ * under worldStateRoot — see worldPaths). */
575
+ function looksLikeWorldStateDir(dir: string): boolean {
576
+ if (!existsSync(dir)) return true;
577
+ return readdirSync(dir).every((entry) => {
578
+ const full = join(dir, entry);
579
+ return statSync(full).isDirectory() && looksLikeServiceStateDir(full);
580
+ });
581
+ }
582
+
583
+ /** Every file under `dir`, path relative to `dir`, depth-first. Used to report
584
+ * exactly what scrub is about to remove before it removes it. */
585
+ function listFilesRecursive(dir: string, base: string = dir): string[] {
586
+ if (!existsSync(dir)) return [];
587
+ 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));
591
+ else out.push(full.slice(base.length + 1));
592
+ }
593
+ return out;
594
+ }
595
+
596
+ function scrubDir(
597
+ dir: string,
598
+ label: string,
599
+ looksLike: (d: string) => boolean,
600
+ options: { force?: boolean },
601
+ ): ScrubResult {
602
+ if (!existsSync(dir)) {
603
+ return { target: dir, removed: [], message: `nothing to scrub: ${label} (${dir}) does not exist` };
604
+ }
605
+ if (!options.force && !looksLike(dir)) {
606
+ throw new Error(
607
+ `world scrub: refusing to remove "${dir}" — it does not look like a world state directory ` +
608
+ `(unexpected contents). Pass --force (or { force: true }) to scrub it anyway.`,
609
+ );
610
+ }
611
+ const removed = listFilesRecursive(dir);
612
+ rmSync(dir, { recursive: true, force: true });
613
+ return { target: dir, removed, message: `scrubbed ${label}: removed ${removed.length} file(s) under ${dir}` };
614
+ }
615
+
616
+ /** Delete one service's pulled data at rest: its event log, queued events,
617
+ * rebuilt state.json, and any resources/cursors/ingests sidecars — everything
618
+ * `worldPaths(service)` points at. Refuses (unless `force`) when the dir holds
619
+ * anything storage.ts didn't put there. */
620
+ export function scrubService(service: string, options: { root?: string; force?: boolean } = {}): ScrubResult {
621
+ const paths = worldPaths(service, options.root);
622
+ return scrubDir(paths.dir, `service "${service}"`, looksLikeServiceStateDir, options);
623
+ }
624
+
625
+ /** Delete the ENTIRE world state dir (`<root>/<stateDir>/world`) — every
626
+ * service's pulled data at once. Refuses (unless `force`) when any entry in it
627
+ * isn't itself a recognizable per-service state dir. */
628
+ export function scrubWorld(options: { root?: string; force?: boolean } = {}): ScrubResult {
629
+ const dir = worldStateRoot(options.root);
630
+ return scrubDir(dir, 'entire world state dir', looksLikeWorldStateDir, options);
631
+ }
632
+