@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.
- package/README.md +16 -2
- package/inject.cjs +453 -59
- package/package.json +12 -22
- package/src/actions.ts +234 -49
- package/src/blob-store.ts +136 -0
- package/src/changeset.ts +807 -0
- package/src/cli.ts +60 -10
- package/src/connector.ts +30 -7
- package/src/control-plane.ts +17 -1
- package/src/emit.ts +242 -0
- package/src/fork.ts +19 -7
- package/src/index.ts +139 -6
- package/src/lease.ts +4 -6
- package/src/lifecycle.ts +8 -0
- package/src/packRegistry.ts +248 -2
- package/src/plan.ts +131 -23
- package/src/proxy.ts +5 -2
- package/src/pushLedger.ts +116 -11
- package/src/queueLifecycle.ts +3 -4
- package/src/rateBudget.ts +1115 -0
- package/src/refs.ts +9 -10
- package/src/remote-execute.ts +16 -0
- package/src/scenario.ts +387 -0
- package/src/serve.ts +397 -15
- package/src/shadow.ts +86 -7
- package/src/storage.ts +76 -147
- package/src/sync.ts +63 -17
- package/src/twin-fetch.ts +115 -0
- package/src/validate.ts +6 -5
- package/src/world-clock.ts +33 -0
- package/src/world-store.ts +482 -0
- package/src/worldConfig.ts +4 -3
- package/dist/src/actions.d.ts +0 -138
- package/dist/src/actions.js +0 -201
- package/dist/src/args.d.ts +0 -3
- package/dist/src/args.js +0 -12
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.js +0 -425
- package/dist/src/connector.d.ts +0 -106
- package/dist/src/connector.js +0 -129
- package/dist/src/control-plane.d.ts +0 -21
- package/dist/src/control-plane.js +0 -40
- package/dist/src/egress.d.ts +0 -93
- package/dist/src/egress.js +0 -264
- package/dist/src/fork.d.ts +0 -126
- package/dist/src/fork.js +0 -206
- package/dist/src/index.d.ts +0 -42
- package/dist/src/index.js +0 -52
- package/dist/src/lease.d.ts +0 -50
- package/dist/src/lease.js +0 -80
- package/dist/src/packRegistry.d.ts +0 -34
- package/dist/src/packRegistry.js +0 -22
- package/dist/src/plan.d.ts +0 -97
- package/dist/src/plan.js +0 -151
- package/dist/src/proxy.d.ts +0 -25
- package/dist/src/proxy.js +0 -152
- package/dist/src/pushLedger.d.ts +0 -81
- package/dist/src/pushLedger.js +0 -130
- package/dist/src/queueLifecycle.d.ts +0 -62
- package/dist/src/queueLifecycle.js +0 -95
- package/dist/src/reconcile.d.ts +0 -58
- package/dist/src/reconcile.js +0 -137
- package/dist/src/refs.d.ts +0 -29
- package/dist/src/refs.js +0 -68
- package/dist/src/schemas.d.ts +0 -78
- package/dist/src/schemas.js +0 -50
- package/dist/src/serve.d.ts +0 -44
- package/dist/src/serve.js +0 -93
- package/dist/src/shadow.d.ts +0 -77
- package/dist/src/shadow.js +0 -138
- package/dist/src/status.d.ts +0 -31
- package/dist/src/status.js +0 -42
- package/dist/src/storage.d.ts +0 -119
- package/dist/src/storage.js +0 -535
- package/dist/src/sync.d.ts +0 -91
- package/dist/src/sync.js +0 -121
- package/dist/src/types.d.ts +0 -40
- package/dist/src/types.js +0 -1
- package/dist/src/validate.d.ts +0 -27
- package/dist/src/validate.js +0 -68
- package/dist/src/visualizer.d.ts +0 -13
- package/dist/src/visualizer.js +0 -133
- package/dist/src/worldConfig.d.ts +0 -9
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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(
|
|
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
|
-
|
|
377
|
-
|
|
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 (!
|
|
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
|
-
|
|
569
|
-
|
|
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
|
-
|
|
577
|
-
|
|
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
|
|
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
|
-
|
|
514
|
+
const store = getActiveWorldStore();
|
|
515
|
+
if (!store.exists(dir)) return [];
|
|
587
516
|
const out: string[] = [];
|
|
588
|
-
for (const
|
|
589
|
-
const full = join(dir,
|
|
590
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 = {
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 (!
|
|
40
|
-
return
|
|
41
|
-
.
|
|
42
|
-
.
|
|
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
|
|