@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/serve.ts
CHANGED
|
@@ -9,10 +9,318 @@
|
|
|
9
9
|
// is a repo". The only serve-time policy here is `readOnly`: a twin accepts local
|
|
10
10
|
// writes (as actions) unless you start it read-only (a pure mirror of pulled
|
|
11
11
|
// reality). Forking is a separate data operation (fork.ts), never a serve mode.
|
|
12
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
12
15
|
import type { SubjectFields } from './shadow.ts';
|
|
13
16
|
import { hashFieldValue } from './shadow.ts';
|
|
14
|
-
import { appendActionIfAbsent, projectResources } from './actions.ts';
|
|
15
|
-
import type { TwinActionPrecondition } from './actions.ts';
|
|
17
|
+
import { appendActionIfAbsent, decideAndAppendAction, projectResources } from './actions.ts';
|
|
18
|
+
import type { ActionProjection, TwinAction, TwinActionPrecondition } from './actions.ts';
|
|
19
|
+
import { observeWorldPaths, worldPaths } from './storage.ts';
|
|
20
|
+
import { getActiveWorldStore } from './world-store.ts';
|
|
21
|
+
|
|
22
|
+
// ── the request journal: the INSPECT half of the read path ─────────────────────────────────────
|
|
23
|
+
// `actions.jsonl` records MUTATIONS only, so a twin's READS are invisible to `volter-world tail`
|
|
24
|
+
// — an empty-catalog GET that 404s leaves no trace, and "did the app even ask?" is unanswerable.
|
|
25
|
+
// The journal is the minimal honest answer: an OPT-IN (VOLTER_TWIN_REQUEST_JOURNAL=1) per-twin
|
|
26
|
+
// `requests.jsonl` beside `actions.jsonl`, one line per served HTTP request. Size-capped: at ~1 MB
|
|
27
|
+
// the file rotates once to `requests.jsonl.1` (previous rotation overwritten), so an enabled
|
|
28
|
+
// journal can never grow unbounded. `volter-world tail --requests` merges these lines live.
|
|
29
|
+
//
|
|
30
|
+
// WHAT A LINE CARRIES. `{at, method, path, status}` — the shape — plus, when the request presented
|
|
31
|
+
// one, the CREDENTIAL SHAPE: which header/query-param NAMES carried a credential-looking value,
|
|
32
|
+
// each with a truncated sha256 FINGERPRINT of the value. Never the value, never the rest of the
|
|
33
|
+
// query string, never a body. That is what makes the journal answer the question a twin exists to
|
|
34
|
+
// answer and could not: *which credential arrived, in which header?* Two different fingerprints
|
|
35
|
+
// under `dd-api-key` and `dd-application-key` say "two distinct keys arrived, in their own named
|
|
36
|
+
// headers"; the same fingerprint on two rows says "the same value came back"; a missing name says
|
|
37
|
+
// the caller sent nothing. Reading a fingerprint back to its secret is not possible — it is
|
|
38
|
+
// sha256, truncated — so the file stays safe to paste into an issue.
|
|
39
|
+
//
|
|
40
|
+
// The query string used to be dropped whole (it carries tokens), which is precisely why a
|
|
41
|
+
// query-placed key was invisible: the journal now names the credential PARAMS and fingerprints
|
|
42
|
+
// them, and still records `path` without its query.
|
|
43
|
+
//
|
|
44
|
+
// A fingerprint is not a vault: it is sha256 of the value, so a LOW-entropy credential (`"test"`)
|
|
45
|
+
// is guessable from its fingerprint by anyone who guesses the value first. It is exactly the right
|
|
46
|
+
// strength for its job — telling two credentials apart, and recognising one across requests.
|
|
47
|
+
//
|
|
48
|
+
// WHERE IT IS WIRED. Not per-pack. Every vendor pack mounts its own `Bun.serve({fetch})` inside a
|
|
49
|
+
// `create<Vendor>TwinServer({port, root, readOnly})` factory — there is no shared middleware, but
|
|
50
|
+
// there IS a shared CONSTRUCTOR, so `installTwinRequestJournal()` (below, called once at module
|
|
51
|
+
// load) wraps `Bun.serve` and journals every twin's fetch handler. The pack writes no journaling
|
|
52
|
+
// line at all; the four packs that used to call `journalTwinRequest` themselves no longer do.
|
|
53
|
+
// Measured coverage: 67 of the 69 pack HTTP servers journal with zero pack-side code. The two that
|
|
54
|
+
// do not are STRUCTURAL, not missed — `smtp` is a raw-TCP `Bun.listen` mail listener with no HTTP
|
|
55
|
+
// surface, and termfleet's PROVIDER server is `node:http` + socket.io (its registry server, which
|
|
56
|
+
// is `Bun.serve`, is covered). Anything that mounts an HTTP twin through `Bun.serve` is covered by
|
|
57
|
+
// construction; anything that does not is a second transport and would need its own seam.
|
|
58
|
+
|
|
59
|
+
export type TwinCredentialShape = {
|
|
60
|
+
/** header name (lowercased) or query-param name the credential arrived under. */
|
|
61
|
+
name: string;
|
|
62
|
+
/** where it sat on the request. */
|
|
63
|
+
in: 'header' | 'query';
|
|
64
|
+
/** the auth SCHEME when the value carried one (`Bearer`, `Basic`, `AWS4-HMAC-SHA256`). A scheme
|
|
65
|
+
* is not a secret, and it is what distinguishes "a bearer token arrived" from "a named vendor
|
|
66
|
+
* header arrived" — the exact confusion this journal was added to end. */
|
|
67
|
+
scheme?: string;
|
|
68
|
+
/** `sha256:<16 hex>` of the value (of the credential part when a scheme was present). Stable
|
|
69
|
+
* across processes and runs, so equal fingerprints mean equal values; non-reversible. */
|
|
70
|
+
fp?: string;
|
|
71
|
+
/** the name arrived with an EMPTY value — the "the SDK sent the header but no key" case. */
|
|
72
|
+
empty?: true;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type TwinRequestJournalEntry = {
|
|
76
|
+
at?: string;
|
|
77
|
+
method: string;
|
|
78
|
+
path: string;
|
|
79
|
+
status: number;
|
|
80
|
+
/** credential-looking headers/query params that arrived, named + fingerprinted, never quoted. */
|
|
81
|
+
credentials?: TwinCredentialShape[];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** ~1 MB cap before a one-deep rotation — small enough to never matter on disk, large enough for
|
|
85
|
+
* tens of thousands of entries (a shape line is ~80 bytes). */
|
|
86
|
+
const REQUEST_JOURNAL_MAX_BYTES = 1_000_000;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Does this header/param NAME look like it carries a credential? Deliberately a generic word
|
|
90
|
+
* predicate, never a vendor table: the kernel must not learn that Datadog calls its keys
|
|
91
|
+
* `DD-API-KEY` or that Postmark calls its tokens `X-Postmark-Server-Token`. Both fall out of
|
|
92
|
+
* `key`/`token` as name segments, and so does every vendor that has not been written yet.
|
|
93
|
+
*/
|
|
94
|
+
const CREDENTIAL_NAME = /(?:^|[-_.])(?:auth|authorization|token|jwt|key|apikey|secret|credential|credentials|signature|sig|password|passwd|pwd|session|sessionid|cookie|access[-_]?token|refresh[-_]?token)(?:[-_.]|$)/i;
|
|
95
|
+
|
|
96
|
+
/** Auth schemes recognised in a value even when the NAME says nothing (`Bearer …` in `x-custom`). */
|
|
97
|
+
const CREDENTIAL_SCHEME = /^(Bearer|Basic|Digest|Token|Negotiate|NTLM|OAuth|Signature|Hawk|AWS4-HMAC-SHA256|SharedKey|SharedKeyLite|GoogleLogin)\s+(\S[\s\S]*)$/i;
|
|
98
|
+
|
|
99
|
+
/** Non-reversible, stable fingerprint. 64 bits of sha256 — enough that two distinct credentials
|
|
100
|
+
* never collide in a journal, far too little to walk back to a real key. */
|
|
101
|
+
export function credentialFingerprint(value: string): string {
|
|
102
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 16)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function credentialShapeFor(name: string, value: string, where: 'header' | 'query'): TwinCredentialShape | undefined {
|
|
106
|
+
const scheme = CREDENTIAL_SCHEME.exec(value);
|
|
107
|
+
if (!scheme && !CREDENTIAL_NAME.test(name)) return undefined;
|
|
108
|
+
const lower = name.toLowerCase();
|
|
109
|
+
if (value === '') return { name: lower, in: where, empty: true };
|
|
110
|
+
if (scheme) return { name: lower, in: where, scheme: scheme[1]!, fp: credentialFingerprint(scheme[2]!) };
|
|
111
|
+
return { name: lower, in: where, fp: credentialFingerprint(value) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The credential SHAPE of one request: every credential-looking header and query param, named and
|
|
116
|
+
* fingerprinted, sorted for stable diffing. Values never leave this function.
|
|
117
|
+
*/
|
|
118
|
+
export function twinRequestCredentials(headers: Headers | Record<string, string>, url?: string | URL): TwinCredentialShape[] {
|
|
119
|
+
const shapes: TwinCredentialShape[] = [];
|
|
120
|
+
const seen = new Set<string>();
|
|
121
|
+
const add = (name: string, value: string, where: 'header' | 'query') => {
|
|
122
|
+
const shape = credentialShapeFor(name, value, where);
|
|
123
|
+
if (!shape) return;
|
|
124
|
+
const key = `${shape.in}:${shape.name}`;
|
|
125
|
+
if (seen.has(key)) return;
|
|
126
|
+
seen.add(key);
|
|
127
|
+
shapes.push(shape);
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
if (typeof (headers as Headers).forEach === 'function') {
|
|
131
|
+
(headers as Headers).forEach((value, name) => add(name, value, 'header'));
|
|
132
|
+
} else {
|
|
133
|
+
for (const [name, value] of Object.entries(headers as Record<string, string>)) add(name, String(value), 'header');
|
|
134
|
+
}
|
|
135
|
+
} catch { /* a malformed header bag must not fail a serve */ }
|
|
136
|
+
try {
|
|
137
|
+
if (url !== undefined) {
|
|
138
|
+
const search = typeof url === 'string' ? new URL(url, 'http://twin.invalid').search : url.search;
|
|
139
|
+
for (const [name, value] of new URLSearchParams(search)) add(name, value, 'query');
|
|
140
|
+
}
|
|
141
|
+
} catch { /* an unparseable URL must not fail a serve */ }
|
|
142
|
+
return shapes.sort((a, b) => (a.in === b.in ? a.name.localeCompare(b.name) : a.in.localeCompare(b.in)));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function twinRequestJournalEnabled(env: Record<string, string | undefined> = process.env): boolean {
|
|
146
|
+
return env.VOLTER_TWIN_REQUEST_JOURNAL === '1';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Where a service's request journal lives: beside its `actions.jsonl`. */
|
|
150
|
+
export function twinRequestJournalPath(service: string, root?: string): string {
|
|
151
|
+
return join(dirname(worldPaths(service, root).events), 'requests.jsonl');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Append one served request to the journal — a no-op unless VOLTER_TWIN_REQUEST_JOURNAL=1, so
|
|
156
|
+
* the default path does zero I/O. Never throws: an observability append must not fail a serve.
|
|
157
|
+
*/
|
|
158
|
+
export function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): void {
|
|
159
|
+
if (!twinRequestJournalEnabled()) return;
|
|
160
|
+
try {
|
|
161
|
+
const store = getActiveWorldStore();
|
|
162
|
+
const path = twinRequestJournalPath(service, root);
|
|
163
|
+
store.mkdir(dirname(path));
|
|
164
|
+
const size = store.stat(path)?.size ?? 0;
|
|
165
|
+
if (size >= REQUEST_JOURNAL_MAX_BYTES) {
|
|
166
|
+
// one-deep rotation: the previous overflow is overwritten, the live file starts fresh.
|
|
167
|
+
store.writeAtomic(`${path}.1`, store.read(path) ?? '');
|
|
168
|
+
store.write(path, '');
|
|
169
|
+
}
|
|
170
|
+
const line = JSON.stringify({
|
|
171
|
+
at: entry.at ?? new Date().toISOString(),
|
|
172
|
+
method: entry.method.toUpperCase(),
|
|
173
|
+
path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
|
|
174
|
+
status: entry.status,
|
|
175
|
+
...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
|
|
176
|
+
});
|
|
177
|
+
store.append(path, `${line}\n`);
|
|
178
|
+
} catch {
|
|
179
|
+
// journaling is best-effort observability; the serve path must not care
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── how a wrapped `Bun.serve` learns which twin it is ──────────────────────────────────────────
|
|
184
|
+
// `Bun.serve({port, fetch})` carries neither the vendor id nor the state root — both live in the
|
|
185
|
+
// factory's closure. They are recoverable without touching a single pack, because handling a
|
|
186
|
+
// request makes the pack call the kernel, and every kernel state path funnels through
|
|
187
|
+
// `worldPaths(service, root)` (storage.ts). Observing the FIRST such call a server makes is the
|
|
188
|
+
// twin naming itself: `datadog` under `/tmp/world/data/datadog`. It is cached per server, so only
|
|
189
|
+
// the first request pays for it. A server whose first request touches no state falls back to the
|
|
190
|
+
// call site (`packages/twin/<vendor>/…` or `@volter/twin-<vendor>`) and the process's `--root`
|
|
191
|
+
// argument, which is how the world runtime launches every twin.
|
|
192
|
+
|
|
193
|
+
export const TWIN_JOURNAL_IDENTITY = Symbol.for('volter.twin.requestJournal.identity');
|
|
194
|
+
const JOURNAL_SHIM_INSTALLED = Symbol.for('volter.twin.requestJournal.installed');
|
|
195
|
+
|
|
196
|
+
export type TwinJournalIdentity = { service: string; root?: string };
|
|
197
|
+
|
|
198
|
+
/** One capture slot per in-flight request, so two twins co-located in ONE process (the shared
|
|
199
|
+
* host) can never read each other's identity off a racing sibling's first request.
|
|
200
|
+
*
|
|
201
|
+
* LAZY ON PURPOSE, and this is load-bearing rather than style. Mirror-UI packs import their own
|
|
202
|
+
* server module into the BROWSER bundle and rely on Bun tree-shaking the server-only half away
|
|
203
|
+
* (see any `*-mirror-ui.ts` header). A module-scope `new AsyncLocalStorage()` is a side effect,
|
|
204
|
+
* so it survives tree-shaking and lands in the client — where `node:async_hooks` does not
|
|
205
|
+
* resolve, `new` throws at bundle evaluation, and the React app never mounts. That took out
|
|
206
|
+
* every mirror UI in the estate at once. Nothing in this module may run at module scope. */
|
|
207
|
+
let identityCapture: AsyncLocalStorage<{ identity?: TwinJournalIdentity }> | undefined;
|
|
208
|
+
|
|
209
|
+
function identitySlot(): AsyncLocalStorage<{ identity?: TwinJournalIdentity }> {
|
|
210
|
+
identityCapture ??= new AsyncLocalStorage<{ identity?: TwinJournalIdentity }>();
|
|
211
|
+
return identityCapture;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function observeIdentityFromWorldPaths(): void {
|
|
215
|
+
observeWorldPaths((service, root) => {
|
|
216
|
+
const slot = identityCapture?.getStore();
|
|
217
|
+
if (!slot || slot.identity !== undefined) return;
|
|
218
|
+
slot.identity = root === undefined ? { service } : { service, root };
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The `--root DIR` a twin was launched with (the world runtime's spawn contract), if any. */
|
|
223
|
+
function rootFromArgv(argv: readonly string[] = process.argv): string | undefined {
|
|
224
|
+
const index = argv.indexOf('--root');
|
|
225
|
+
const value = index >= 0 ? argv[index + 1] : undefined;
|
|
226
|
+
return value && !value.startsWith('--') ? value : undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The vendor a `Bun.serve` call site belongs to, from its module path. Infra packages are not
|
|
230
|
+
* vendors, so they are skipped: whoever called THEM is the twin. */
|
|
231
|
+
const INFRA_PACKAGES = new Set(['control-plane', 'world-runtime', 'tooling', 'attach', 'browser-assets', 'twins-host']);
|
|
232
|
+
|
|
233
|
+
export function vendorFromStack(stack: string | undefined): string | undefined {
|
|
234
|
+
if (!stack) return undefined;
|
|
235
|
+
const pattern = /(?:packages[\\/]twin[\\/]|@volter[\\/]twin-)([A-Za-z0-9_-]+)/g;
|
|
236
|
+
for (const match of stack.matchAll(pattern)) {
|
|
237
|
+
const name = match[1]!;
|
|
238
|
+
if (!INFRA_PACKAGES.has(name)) return name;
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Wrap `Bun.serve` so EVERY twin's HTTP surface journals, with no per-pack line. Idempotent
|
|
247
|
+
* (a `Symbol.for` marker survives a second copy of this module) and a strict pass-through when
|
|
248
|
+
* VOLTER_TWIN_REQUEST_JOURNAL is not `1` — the default path adds one function call per request
|
|
249
|
+
* and does zero I/O. Called once at module load, below; exported so a test can assert it.
|
|
250
|
+
*
|
|
251
|
+
* A pack that knows its own identity may declare it by putting `{service, root}` on the serve
|
|
252
|
+
* options under `TWIN_JOURNAL_IDENTITY` (a symbol key Bun's option reader ignores). Nothing in
|
|
253
|
+
* the estate needs to: `createTwinServer` is the only caller, because it is the one server whose
|
|
254
|
+
* service is an argument rather than a fact about the pack.
|
|
255
|
+
*/
|
|
256
|
+
export function installTwinRequestJournal(): boolean {
|
|
257
|
+
const bun = (globalThis as { Bun?: { serve?: unknown } }).Bun;
|
|
258
|
+
if (!bun || typeof bun.serve !== 'function') return false; // not Bun (node tooling) — nothing to wrap
|
|
259
|
+
const original = bun.serve as (...args: unknown[]) => { port?: number };
|
|
260
|
+
if ((original as unknown as Record<symbol, unknown>)[JOURNAL_SHIM_INSTALLED]) return true;
|
|
261
|
+
// Registered HERE, not at module scope, for the same reason the capture slot is lazy: a
|
|
262
|
+
// module-scope registration is a side effect that survives tree-shaking into the client.
|
|
263
|
+
observeIdentityFromWorldPaths();
|
|
264
|
+
|
|
265
|
+
const wrapped = function serve(this: unknown, options: unknown, ...rest: unknown[]) {
|
|
266
|
+
const config = options as (Record<string | symbol, unknown> & { fetch?: (...a: unknown[]) => unknown }) | undefined;
|
|
267
|
+
if (!config || typeof config.fetch !== 'function') return original.apply(this, [options, ...rest]);
|
|
268
|
+
const declared = config[TWIN_JOURNAL_IDENTITY] as TwinJournalIdentity | undefined;
|
|
269
|
+
// Resolved once per SERVER, not per request: a twin's identity is a constant of the server.
|
|
270
|
+
let identity: TwinJournalIdentity | undefined = declared;
|
|
271
|
+
// Captured HERE, at the `Bun.serve` call, because this is the only moment the pack's own
|
|
272
|
+
// module is on the stack — by the time a request is served, Bun is the caller.
|
|
273
|
+
const callSiteVendor = declared ? undefined : vendorFromStack(new Error().stack);
|
|
274
|
+
const inner = config.fetch as (this: unknown, request: Request, server: unknown) => unknown;
|
|
275
|
+
const journaling = async function (this: unknown, request: Request, server: unknown) {
|
|
276
|
+
if (!twinRequestJournalEnabled()) return inner.call(this, request, server);
|
|
277
|
+
const slot: { identity?: TwinJournalIdentity } = {};
|
|
278
|
+
let status = 500;
|
|
279
|
+
try {
|
|
280
|
+
const response = (await identitySlot().run(slot, () => inner.call(this, request, server))) as Response | undefined;
|
|
281
|
+
if (response instanceof Response) status = response.status;
|
|
282
|
+
else if (response === undefined) return response; // upgraded (websocket) — nothing served
|
|
283
|
+
return response;
|
|
284
|
+
} finally {
|
|
285
|
+
// Only an OBSERVED identity is cached: it is the twin's own words. A fallback is
|
|
286
|
+
// per-request, so the first request that touches state upgrades every later one instead
|
|
287
|
+
// of locking a guessed root in for the life of the server.
|
|
288
|
+
identity ??= slot.identity;
|
|
289
|
+
const resolved = identity ?? fallbackIdentity(callSiteVendor);
|
|
290
|
+
if (resolved) {
|
|
291
|
+
const url = new URL(request.url);
|
|
292
|
+
journalTwinRequest(resolved.service, {
|
|
293
|
+
method: request.method,
|
|
294
|
+
path: url.pathname,
|
|
295
|
+
status,
|
|
296
|
+
credentials: twinRequestCredentials(request.headers, url),
|
|
297
|
+
}, resolved.root);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
return original.apply(this, [{ ...config, fetch: journaling }, ...rest]);
|
|
302
|
+
};
|
|
303
|
+
Object.defineProperty(wrapped, JOURNAL_SHIM_INSTALLED, { value: true });
|
|
304
|
+
(bun as { serve: unknown }).serve = wrapped;
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Identity for a server whose first request touched no state: the vendor that owns the calling
|
|
309
|
+
* module, plus the `--root` the world runtime launched this twin with. */
|
|
310
|
+
function fallbackIdentity(callSiteVendor: string | undefined): TwinJournalIdentity | undefined {
|
|
311
|
+
const service = process.env.VOLTER_TWIN_JOURNAL_SERVICE || callSiteVendor;
|
|
312
|
+
if (!service || !/^[A-Za-z0-9_-]+$/.test(service)) return undefined;
|
|
313
|
+
const root = rootFromArgv();
|
|
314
|
+
return root === undefined ? { service } : { service, root };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// One call, at module load. `@volter/twin`'s entrypoint re-exports this module, and every pack
|
|
318
|
+
// that serves imports the kernel, so wrapping here is what makes the journal a property of the
|
|
319
|
+
// PRODUCT rather than of the four packs that once remembered to call it.
|
|
320
|
+
//
|
|
321
|
+
// It is a no-op wherever `Bun.serve` is absent, which is what keeps it safe in a browser bundle:
|
|
322
|
+
// off-Bun it returns before touching `node:async_hooks`, `process.argv`, or the storage hook.
|
|
323
|
+
installTwinRequestJournal();
|
|
16
324
|
|
|
17
325
|
// A subject rendered as a vendor resource: its folded current fields + identity.
|
|
18
326
|
export type TwinResource = { id: string; type: string; updatedAt: string } & Record<string, unknown>;
|
|
@@ -27,6 +335,69 @@ export function twinResources(service: string, root?: string): TwinResource[] {
|
|
|
27
335
|
// Result of a local twin write — it is an ACTION, not an egress/real write.
|
|
28
336
|
export type TwinWriteResult = { status: 'performed' | 'replayed'; actionId: string };
|
|
29
337
|
|
|
338
|
+
export type TwinWriteInput = {
|
|
339
|
+
operation: string;
|
|
340
|
+
provider?: string;
|
|
341
|
+
subjectType: string;
|
|
342
|
+
subjectId: string;
|
|
343
|
+
fields: SubjectFields;
|
|
344
|
+
input?: Record<string, unknown>;
|
|
345
|
+
projection?: ActionProjection;
|
|
346
|
+
occurredAt?: string;
|
|
347
|
+
actor?: { kind: 'agent' | 'human' | 'bot' | 'system'; id?: string };
|
|
348
|
+
preconditions?: TwinActionPrecondition[];
|
|
349
|
+
correlationId?: string;
|
|
350
|
+
uniqueness?: string;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
export type AtomicTwinWriteDecision<T> =
|
|
354
|
+
| { kind: 'skip'; value: T }
|
|
355
|
+
| { kind: 'write'; value: T; write: TwinWriteInput };
|
|
356
|
+
|
|
357
|
+
function actionForTwinWrite(service: string, write: TwinWriteInput): TwinAction {
|
|
358
|
+
const occurredAt = write.occurredAt ?? new Date().toISOString();
|
|
359
|
+
// Idempotency: dedup a RE-ISSUED IDENTICAL write only. The key includes a hash of the write
|
|
360
|
+
// CONTENT, not just the timestamp. `undefined` keys are omitted by canonicalJson, preserving
|
|
361
|
+
// every pre-input/projection action id for existing packs.
|
|
362
|
+
const contentHash = hashFieldValue({ operation: write.operation, subjectId: write.subjectId, fields: write.fields, input: write.input, projection: write.projection });
|
|
363
|
+
const actionId = `twin:${service}:${write.operation}:${write.subjectId}:${occurredAt}:${contentHash}${write.uniqueness ? `:${write.uniqueness}` : ''}`;
|
|
364
|
+
return {
|
|
365
|
+
id: actionId,
|
|
366
|
+
service,
|
|
367
|
+
op: 'set',
|
|
368
|
+
operation: write.operation,
|
|
369
|
+
subject: { type: write.subjectType, id: write.subjectId },
|
|
370
|
+
occurredAt,
|
|
371
|
+
...(write.actor ? { actor: write.actor } : {}),
|
|
372
|
+
...(write.preconditions?.length ? { preconditions: write.preconditions } : {}),
|
|
373
|
+
...(write.correlationId ? { correlationId: write.correlationId } : {}),
|
|
374
|
+
...(write.input ? { input: write.input } : {}),
|
|
375
|
+
fields: write.fields,
|
|
376
|
+
...(write.projection ? { projection: write.projection } : {}),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Decide a state-dependent local write and append it under the same cross-process action lock.
|
|
382
|
+
* Use this when acceptance or the new fields depend on current projected state; a caller-side
|
|
383
|
+
* read followed by `applyTwinWrite` is not atomic across processes.
|
|
384
|
+
*/
|
|
385
|
+
export async function applyTwinWriteAtomic<T>(
|
|
386
|
+
service: string,
|
|
387
|
+
prepare: (resources: readonly TwinResource[]) => AtomicTwinWriteDecision<T>,
|
|
388
|
+
root?: string,
|
|
389
|
+
): Promise<{ value: T; result?: TwinWriteResult }> {
|
|
390
|
+
const committed = decideAndAppendAction(service, (resources) => {
|
|
391
|
+
const decision = prepare(resources);
|
|
392
|
+
if (decision.kind === 'skip') return { kind: 'skip', value: decision.value };
|
|
393
|
+
return { kind: 'append', value: decision.value, action: actionForTwinWrite(service, decision.write) };
|
|
394
|
+
}, root);
|
|
395
|
+
return {
|
|
396
|
+
value: committed.value,
|
|
397
|
+
...(committed.action ? { result: { status: committed.appended ? 'performed' : 'replayed', actionId: committed.action.id } } : {}),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
30
401
|
// Resolve a read request against the twin's resources. Returns the matched
|
|
31
402
|
// resource(s) + an HTTP-ish status, deterministically from state.
|
|
32
403
|
// GET / -> { service, mode, resourceTypes, count }
|
|
@@ -63,38 +434,41 @@ export function resolveTwinRead(
|
|
|
63
434
|
// vendor is a separate, explicit step that records egress + confirms the action.
|
|
64
435
|
export async function applyTwinWrite(
|
|
65
436
|
service: string,
|
|
66
|
-
write:
|
|
437
|
+
write: TwinWriteInput,
|
|
67
438
|
root?: string,
|
|
68
439
|
): Promise<{ result: TwinWriteResult; resource: TwinResource }> {
|
|
69
|
-
const
|
|
440
|
+
const action = actionForTwinWrite(service, write);
|
|
70
441
|
// Idempotency: dedup a RE-ISSUED IDENTICAL write only. The key includes a hash of the write
|
|
71
442
|
// CONTENT (operation + subject + fields), not just the timestamp — so two DIFFERENT writes to
|
|
72
443
|
// the same subject in the same millisecond are BOTH kept (previously the second silently
|
|
73
444
|
// no-opped: e.g. a sprint "start" then "close" in the same ms lost the close). Check + append
|
|
74
445
|
// run atomically under the actions lock, so concurrent processes can't double-apply either.
|
|
75
|
-
|
|
76
|
-
|
|
446
|
+
//
|
|
447
|
+
// The UNIQUENESS SEAM: some vendor operations are billable OCCURRENCES, not idempotent state
|
|
448
|
+
// writes — every call is a distinct action even when byte-identical in the same millisecond
|
|
449
|
+
// (an AI completion: the real vendor runs + bills each call; two identical rapid calls must
|
|
450
|
+
// ledger as TWO actions, never collapse). A pack passes a per-call `uniqueness` value (e.g.
|
|
451
|
+
// randomUUID()) and the content-dedupe stops applying to that action. Leave it unset for
|
|
452
|
+
// ordinary resource writes, where replay-dedupe is the correct semantics.
|
|
77
453
|
// correlationId (D3): pass a caller-supplied request-scoped id through (e.g. propagated
|
|
78
454
|
// from an inbound HTTP request id) so it lands on the action row; appendActionIfAbsent
|
|
79
455
|
// generates one when omitted, so it's never missing.
|
|
80
|
-
const { appended } = appendActionIfAbsent(
|
|
81
|
-
{ id: actionId, service, op: 'set', operation: write.operation, subject: { type: write.subjectType, id: write.subjectId }, occurredAt, ...(write.actor ? { actor: write.actor } : {}), ...(write.preconditions?.length ? { preconditions: write.preconditions } : {}), ...(write.correlationId ? { correlationId: write.correlationId } : {}), fields: write.fields },
|
|
82
|
-
root,
|
|
83
|
-
);
|
|
456
|
+
const { appended } = appendActionIfAbsent(action, root);
|
|
84
457
|
// Resolve by (type, id) — an id alone is ambiguous when two resource TYPES share it.
|
|
85
458
|
const resource = projectResources(service, root).find((r) => r.type === write.subjectType && r.id === write.subjectId)
|
|
86
|
-
?? ({ id: write.subjectId, type: write.subjectType, updatedAt: occurredAt, ...write.fields } as TwinResource);
|
|
87
|
-
return { result: { status: appended ? 'performed' : 'replayed', actionId }, resource };
|
|
459
|
+
?? ({ id: write.subjectId, type: write.subjectType, updatedAt: action.occurredAt, ...write.fields } as TwinResource);
|
|
460
|
+
return { result: { status: appended ? 'performed' : 'replayed', actionId: action.id }, resource };
|
|
88
461
|
}
|
|
89
462
|
|
|
90
463
|
export function createTwinServer(options: { service: string; root?: string; port?: number; readOnly?: boolean }): { port: number; stop: () => void } {
|
|
91
464
|
const readOnly = options.readOnly ?? false;
|
|
92
|
-
const
|
|
465
|
+
const serveOptions = {
|
|
93
466
|
port: options.port ?? 0,
|
|
94
467
|
idleTimeout: 60,
|
|
95
|
-
async fetch(request) {
|
|
468
|
+
async fetch(request: Request) {
|
|
96
469
|
const url = new URL(request.url);
|
|
97
|
-
const json = (status: number, body: unknown) =>
|
|
470
|
+
const json = (status: number, body: unknown) =>
|
|
471
|
+
new Response(JSON.stringify(body, null, 2), { status, headers: { 'content-type': 'application/json' } });
|
|
98
472
|
if (request.method === 'GET') {
|
|
99
473
|
// Re-read per request so a concurrently-syncing twin serves fresh state.
|
|
100
474
|
const { status, body } = resolveTwinRead(options.service, url.pathname, { root: options.root });
|
|
@@ -115,6 +489,14 @@ export function createTwinServer(options: { service: string; root?: string; port
|
|
|
115
489
|
}, options.root);
|
|
116
490
|
return json(result.status === 'replayed' ? 200 : 201, { status: result.status, resource });
|
|
117
491
|
},
|
|
492
|
+
};
|
|
493
|
+
// This generic server's twin is an ARGUMENT, not a fact about a pack, so it NAMES ITSELF for the
|
|
494
|
+
// request journal rather than being recognised from its call site. Every vendor pack is journaled
|
|
495
|
+
// without declaring anything (installTwinRequestJournal, above); this is the lone declaration.
|
|
496
|
+
Object.defineProperty(serveOptions, TWIN_JOURNAL_IDENTITY, {
|
|
497
|
+
value: { service: options.service, ...(options.root !== undefined ? { root: options.root } : {}) },
|
|
498
|
+
enumerable: true,
|
|
118
499
|
});
|
|
500
|
+
const server = Bun.serve(serveOptions);
|
|
119
501
|
return { port: server.port ?? 0, stop: () => server.stop(true) };
|
|
120
502
|
}
|
package/src/shadow.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { isEgressEventType } from './egress.ts';
|
|
3
|
-
import { appendEvent, createEvent, listEvents } from './storage.ts';
|
|
4
|
-
import
|
|
3
|
+
import { appendEvent, appendEventLocked, createEvent, listEvents, worldPaths } from './storage.ts';
|
|
4
|
+
import { getActiveWorldStore } from './world-store.ts';
|
|
5
|
+
import type { AppendEventResult, WorldPaths, WorldServiceEvent } from './types.ts';
|
|
5
6
|
|
|
6
7
|
export const DELTA_TYPE_SUFFIX = '.delta';
|
|
7
8
|
|
|
@@ -31,7 +32,11 @@ export type SubjectFieldExtractor = (event: WorldServiceEvent) => SubjectFields
|
|
|
31
32
|
|
|
32
33
|
export type FieldChange = { before: unknown; after: unknown };
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
/** Deterministic JSON: object keys sorted, `undefined` entries dropped, arrays in order.
|
|
36
|
+
* The one canonicalizer in this package — `hashFieldValue` (action-id content hashing) and
|
|
37
|
+
* `changesetContentHash` (approval binds to exact bytes) must agree on what "same content"
|
|
38
|
+
* means, so they share this function rather than each rolling their own. */
|
|
39
|
+
export function canonicalJson(value: unknown): string {
|
|
35
40
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
36
41
|
if (value && typeof value === 'object') {
|
|
37
42
|
const entries = Object.entries(value as Record<string, unknown>)
|
|
@@ -47,10 +52,67 @@ export function hashFieldValue(value: unknown): string {
|
|
|
47
52
|
return createHash('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
function subjectKey(subject: { type: string; id: string }): string {
|
|
55
|
+
export function subjectKey(subject: { type: string; id: string }): string {
|
|
51
56
|
return `${subject.type}:${subject.id}`;
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The per-subject REMOTE REF (runtime contract R14): the id of the last event in the
|
|
61
|
+
* observed mirror that moved the remote from THIS action's point of view. Null = the
|
|
62
|
+
* mirror has never seen the subject.
|
|
63
|
+
*
|
|
64
|
+
* This is the git remote-tracking ref of the model: an action stamps these at authoring
|
|
65
|
+
* time (its merge base), and push compares them again — a moved ref means someone else
|
|
66
|
+
* changed the remote since we last looked, and the push must refuse (non-fast-forward).
|
|
67
|
+
*
|
|
68
|
+
* What "someone else" means differs by moment, so the reader has three MODES:
|
|
69
|
+
* - AUTHORING (mode omitted): every push confirmation this world has ever written
|
|
70
|
+
* (`confirmed:{service}:…` ids) is excluded — everything already confirmed at
|
|
71
|
+
* authoring time was, by construction, pushed before this action existed.
|
|
72
|
+
* - PUSH (mode `{countAsDrift}`): self-confirmations are excluded EXCEPT the listed
|
|
73
|
+
* event ids — pushLedger.ts lists the confirmations of the pushed action's
|
|
74
|
+
* DESCENDANTS (actions authored after it), which count as drift: pushing a stale
|
|
75
|
+
* action once a newer one already landed remotely is the out-of-order lost update.
|
|
76
|
+
* Orphaned self-confirm events (a crash between confirmAction's event append and its
|
|
77
|
+
* confirm row — referenced by no row) stay excluded in this mode, matching what the
|
|
78
|
+
* authoring stamp saw; counting them would wedge the subject behind a permanent
|
|
79
|
+
* phantom drift.
|
|
80
|
+
* - RAW (mode 'raw'): nothing is excluded — the last mirror event, period. The sync
|
|
81
|
+
* plane's capture/compare mode: a reconcile plan is stale the moment ANY event lands
|
|
82
|
+
* after its `real` pull, our own confirmations included (syncPush, sync.ts).
|
|
83
|
+
*
|
|
84
|
+
* Reads the event log raw (JSON.parse of id/type/subject only, no schema validation) —
|
|
85
|
+
* this runs on the applyTwinWrite hot path, where a full zod parse per append is the
|
|
86
|
+
* exact O(N·M) cost storage.ts already had to engineer out of appendEvent.
|
|
87
|
+
*/
|
|
88
|
+
export type RemoteRefsMode = 'raw' | { countAsDrift: ReadonlySet<string> };
|
|
89
|
+
|
|
90
|
+
export function remoteRefs(
|
|
91
|
+
service: string,
|
|
92
|
+
subjects: Array<{ type: string; id: string }>,
|
|
93
|
+
root?: string,
|
|
94
|
+
mode?: RemoteRefsMode,
|
|
95
|
+
): Record<string, string | null> {
|
|
96
|
+
const wanted = new Map(subjects.map((s) => [subjectKey(s), null as string | null]));
|
|
97
|
+
if (wanted.size === 0) return {};
|
|
98
|
+
const selfConfirmed = `confirmed:${service}:`;
|
|
99
|
+
const path = worldPaths(service, root).events;
|
|
100
|
+
for (const line of getActiveWorldStore().readLines(path)) {
|
|
101
|
+
if (!line.trim()) continue;
|
|
102
|
+
let event: { id?: string; type?: string; subject?: { type: string; id: string } };
|
|
103
|
+
try { event = JSON.parse(line) as typeof event; } catch { continue; }
|
|
104
|
+
if (!event.id || !event.type || !event.subject) continue;
|
|
105
|
+
if (isEgressEventType(event.type)) continue;
|
|
106
|
+
if (mode !== 'raw' && event.id.startsWith(selfConfirmed)) {
|
|
107
|
+
const countAsDrift = mode !== undefined && mode.countAsDrift.has(event.id);
|
|
108
|
+
if (!countAsDrift) continue;
|
|
109
|
+
}
|
|
110
|
+
const key = subjectKey(event.subject);
|
|
111
|
+
if (wanted.has(key)) wanted.set(key, event.id);
|
|
112
|
+
}
|
|
113
|
+
return Object.fromEntries(wanted);
|
|
114
|
+
}
|
|
115
|
+
|
|
54
116
|
function isDeltaEvent(event: WorldServiceEvent): boolean {
|
|
55
117
|
return event.type.endsWith(DELTA_TYPE_SUFFIX) && typeof event.data.changed === 'object';
|
|
56
118
|
}
|
|
@@ -150,10 +212,10 @@ export type DeltaResult =
|
|
|
150
212
|
* The delta idempotencyKey is derived from the after-state hashes, so the
|
|
151
213
|
* same observed change never appends twice.
|
|
152
214
|
*/
|
|
153
|
-
|
|
215
|
+
function recordObservedDeltaWith(
|
|
154
216
|
state: ShadowState,
|
|
155
217
|
observation: DeltaObservation,
|
|
156
|
-
|
|
218
|
+
appendFn: (event: WorldServiceEvent) => AppendEventResult,
|
|
157
219
|
): DeltaResult {
|
|
158
220
|
const shadow = state.subjects[subjectKey(observation.subject)];
|
|
159
221
|
const changes = diffSubjectFields(shadow, observation.observed);
|
|
@@ -186,7 +248,24 @@ export function recordObservedDelta(
|
|
|
186
248
|
changed: changes,
|
|
187
249
|
},
|
|
188
250
|
});
|
|
189
|
-
const append =
|
|
251
|
+
const append = appendFn(event);
|
|
190
252
|
if (append.appended) applyFields(state, append.event, deltaAfterFields(append.event));
|
|
191
253
|
return { changed: true, event: append.event, changes, append };
|
|
192
254
|
}
|
|
255
|
+
|
|
256
|
+
export function recordObservedDelta(
|
|
257
|
+
state: ShadowState,
|
|
258
|
+
observation: DeltaObservation,
|
|
259
|
+
root?: string,
|
|
260
|
+
): DeltaResult {
|
|
261
|
+
return recordObservedDeltaWith(state, observation, (event) => appendEvent(event, root));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Internal batch seam: caller already owns the service projection + events locks. */
|
|
265
|
+
export function recordObservedDeltaLocked(
|
|
266
|
+
state: ShadowState,
|
|
267
|
+
observation: DeltaObservation,
|
|
268
|
+
paths: WorldPaths,
|
|
269
|
+
): DeltaResult {
|
|
270
|
+
return recordObservedDeltaWith(state, observation, (event) => appendEventLocked(event, paths));
|
|
271
|
+
}
|