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