@volter/world-core 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +29 -0
- package/app-route.cjs +154 -0
- package/app-route.d.cts +7 -0
- package/attach.cjs +80 -0
- package/dist/app-route.cjs +154 -0
- package/dist/app-route.d.cts +7 -0
- package/dist/attach.cjs +80 -0
- package/dist/generated/pack-facts.json +4306 -0
- package/dist/inject.cjs +1097 -0
- package/dist/network-policy.cjs +92 -0
- package/dist/network-policy.d.cts +10 -0
- package/dist/src/actions.d.ts +276 -0
- package/dist/src/actions.js +436 -0
- package/dist/src/ancestry.d.ts +22 -0
- package/dist/src/ancestry.js +238 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/blob-store.d.ts +55 -0
- package/dist/src/blob-store.js +186 -0
- package/dist/src/brand-tokens.d.ts +2 -0
- package/dist/src/brand-tokens.js +17 -0
- package/dist/src/changeset.d.ts +431 -0
- package/dist/src/changeset.js +0 -0
- package/dist/src/client-bundle.d.ts +1 -0
- package/dist/src/client-bundle.js +28 -0
- package/dist/src/credential.d.ts +38 -0
- package/dist/src/credential.js +114 -0
- package/dist/src/derived-core.d.ts +452 -0
- package/dist/src/derived-core.js +782 -0
- package/dist/src/derived.d.ts +84 -0
- package/dist/src/derived.js +122 -0
- package/dist/src/emit.d.ts +106 -0
- package/dist/src/emit.js +157 -0
- package/dist/src/executor.d.ts +120 -0
- package/dist/src/executor.js +387 -0
- package/dist/src/file-response.d.ts +3 -0
- package/dist/src/file-response.js +22 -0
- package/dist/src/fork.d.ts +26 -0
- package/dist/src/fork.js +68 -0
- package/dist/src/git/history.d.ts +36 -0
- package/dist/src/git/history.js +298 -0
- package/dist/src/git/index.d.ts +6 -0
- package/dist/src/git/index.js +6 -0
- package/dist/src/git/inflate.d.ts +11 -0
- package/dist/src/git/inflate.js +194 -0
- package/dist/src/git/objects.d.ts +64 -0
- package/dist/src/git/objects.js +161 -0
- package/dist/src/git/pack.d.ts +14 -0
- package/dist/src/git/pack.js +199 -0
- package/dist/src/git/refs.d.ts +19 -0
- package/dist/src/git/refs.js +35 -0
- package/dist/src/git/smart-http.d.ts +45 -0
- package/dist/src/git/smart-http.js +223 -0
- package/dist/src/hash.d.ts +38 -0
- package/dist/src/hash.js +48 -0
- package/dist/src/head.d.ts +140 -0
- package/dist/src/head.js +313 -0
- package/dist/src/history.d.ts +76 -0
- package/dist/src/history.js +322 -0
- package/dist/src/index.d.ts +73 -0
- package/dist/src/index.js +98 -0
- package/dist/src/lifecycle.d.ts +1 -0
- package/dist/src/lifecycle.js +8 -0
- package/dist/src/log.d.ts +254 -0
- package/dist/src/log.js +801 -0
- package/dist/src/mirror-shell.d.ts +2 -0
- package/dist/src/mirror-shell.js +13 -0
- package/dist/src/observe.d.ts +49 -0
- package/dist/src/observe.js +148 -0
- package/dist/src/pack-assets.d.ts +30 -0
- package/dist/src/pack-assets.js +88 -0
- package/dist/src/packRegistry.d.ts +374 -0
- package/dist/src/packRegistry.js +142 -0
- package/dist/src/placeholder-remote.d.ts +22 -0
- package/dist/src/placeholder-remote.js +86 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +155 -0
- package/dist/src/rateBudget.d.ts +367 -0
- package/dist/src/rateBudget.js +925 -0
- package/dist/src/references.d.ts +18 -0
- package/dist/src/references.js +27 -0
- package/dist/src/remote-execute.d.ts +22 -0
- package/dist/src/remote-execute.js +1 -0
- package/dist/src/resource-blob.d.ts +10 -0
- package/dist/src/resource-blob.js +56 -0
- package/dist/src/scenario.d.ts +197 -0
- package/dist/src/scenario.js +425 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve-http.d.ts +48 -0
- package/dist/src/serve-http.js +340 -0
- package/dist/src/serve.d.ts +147 -0
- package/dist/src/serve.js +507 -0
- package/dist/src/shared-blob-index.d.ts +4 -0
- package/dist/src/shared-blob-index.js +126 -0
- package/dist/src/state-system.d.ts +70 -0
- package/dist/src/state-system.js +90 -0
- package/dist/src/storage.d.ts +101 -0
- package/dist/src/storage.js +337 -0
- package/dist/src/twin-fetch.d.ts +64 -0
- package/dist/src/twin-fetch.js +91 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/v1-removed.d.ts +159 -0
- package/dist/src/v1-removed.js +124 -0
- package/dist/src/volter-home.d.ts +5 -0
- package/dist/src/volter-home.js +10 -0
- package/dist/src/world-clock.d.ts +4 -0
- package/dist/src/world-clock.js +32 -0
- package/dist/src/world-env.d.ts +3 -0
- package/dist/src/world-env.js +22 -0
- package/dist/src/world-store-sql.d.ts +27 -0
- package/dist/src/world-store-sql.js +86 -0
- package/dist/src/world-store.d.ts +168 -0
- package/dist/src/world-store.js +475 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +17 -0
- package/dist/stream-bridge.cjs +80 -0
- package/dist/vendor-hosts.cjs +200 -0
- package/generated/pack-facts.json +4306 -0
- package/inject.cjs +1097 -0
- package/network-policy.cjs +92 -0
- package/network-policy.d.cts +10 -0
- package/package.json +103 -0
- package/src/actions.ts +564 -0
- package/src/ancestry.ts +213 -0
- package/src/args.ts +14 -0
- package/src/blob-store.ts +185 -0
- package/src/brand-tokens.ts +17 -0
- package/src/changeset.ts +1032 -0
- package/src/client-bundle.ts +29 -0
- package/src/credential.ts +140 -0
- package/src/derived-core.ts +1004 -0
- package/src/derived.ts +176 -0
- package/src/emit.ts +242 -0
- package/src/executor.ts +431 -0
- package/src/file-response.ts +22 -0
- package/src/fork.ts +89 -0
- package/src/git/history.ts +177 -0
- package/src/git/index.ts +6 -0
- package/src/git/inflate.ts +125 -0
- package/src/git/objects.ts +110 -0
- package/src/git/pack.ts +105 -0
- package/src/git/refs.ts +25 -0
- package/src/git/smart-http.ts +149 -0
- package/src/hash.ts +66 -0
- package/src/head.ts +318 -0
- package/src/history.ts +246 -0
- package/src/index.ts +323 -0
- package/src/lifecycle.ts +8 -0
- package/src/log.ts +793 -0
- package/src/mirror-shell.ts +15 -0
- package/src/observe.ts +130 -0
- package/src/pack-assets.ts +81 -0
- package/src/packRegistry.ts +408 -0
- package/src/placeholder-remote.ts +81 -0
- package/src/proxy.ts +183 -0
- package/src/rateBudget.ts +1115 -0
- package/src/references.ts +46 -0
- package/src/remote-execute.ts +26 -0
- package/src/resource-blob.ts +57 -0
- package/src/scenario.ts +479 -0
- package/src/schemas.ts +56 -0
- package/src/serve-http.ts +299 -0
- package/src/serve.ts +618 -0
- package/src/shared-blob-index.ts +108 -0
- package/src/state-system.ts +115 -0
- package/src/storage.ts +407 -0
- package/src/twin-fetch.ts +147 -0
- package/src/types.ts +50 -0
- package/src/v1-removed.ts +172 -0
- package/src/volter-home.ts +11 -0
- package/src/world-clock.ts +33 -0
- package/src/world-env.ts +18 -0
- package/src/world-store-sql.ts +118 -0
- package/src/world-store.ts +572 -0
- package/src/worldConfig.ts +27 -0
- package/stream-bridge.cjs +80 -0
- package/vendor-hosts.cjs +200 -0
package/src/serve.ts
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
// Twin serve layer (the twins architecture notes, increment 1): answer vendor-shaped
|
|
2
|
+
// READS from the local event-sourced state — the "local twin" read path. An app
|
|
3
|
+
// or agent points at this instead of the real vendor; it responds from local
|
|
4
|
+
// state with the real service unreachable.
|
|
5
|
+
//
|
|
6
|
+
// There is ONE twin, not a set of "modes". Pulling reality, writing locally, and
|
|
7
|
+
// forking are operations on the same substrate (events + local actions + the
|
|
8
|
+
// projection over them), not exclusive modes — see the architecture doc, "a twin
|
|
9
|
+
// is a repo". The only serve-time policy here is `readOnly`: a twin accepts local
|
|
10
|
+
// writes (as actions) unless you start it read-only (a pure mirror of pulled
|
|
11
|
+
// reality). Forking is a separate data operation (fork.ts), never a serve mode.
|
|
12
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
13
|
+
import { serveHttp, setServeDecorator, WORLD_BOOT_PATH, type HttpHandler } from './serve-http.ts';
|
|
14
|
+
import { performAtHead } from './head.ts';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import type { SubjectFields } from './hash.ts';
|
|
18
|
+
import { hashFieldValue } from './hash.ts';
|
|
19
|
+
import { appendActionIfAbsent, appendActionOccurrence, decideAndAppendAction, projectResources } from './actions.ts';
|
|
20
|
+
import type { ActionProjection, TwinAction, TwinActionPrecondition } from './actions.ts';
|
|
21
|
+
import { observeWorldPaths, worldPaths } from './storage.ts';
|
|
22
|
+
import { getActiveWorldStore } from './world-store.ts';
|
|
23
|
+
|
|
24
|
+
// ── the request journal: the INSPECT half of the read path ─────────────────────────────────────
|
|
25
|
+
// `actions.jsonl` records MUTATIONS only, so a twin's READS are invisible to `volter-world tail`
|
|
26
|
+
// — an empty-catalog GET that 404s leaves no trace, and "did the app even ask?" is unanswerable.
|
|
27
|
+
// The journal is the minimal honest answer: an OPT-IN (VOLTER_TWIN_REQUEST_JOURNAL=1) per-twin
|
|
28
|
+
// `requests.jsonl` beside `actions.jsonl`, one line per served HTTP request. Size-capped: at ~1 MB
|
|
29
|
+
// the file rotates once to `requests.jsonl.1` (previous rotation overwritten), so an enabled
|
|
30
|
+
// journal can never grow unbounded. `volter-world tail --requests` merges these lines live.
|
|
31
|
+
//
|
|
32
|
+
// WHAT A LINE CARRIES. `{at, method, path, status}` — the shape — plus, when the request presented
|
|
33
|
+
// one, the CREDENTIAL SHAPE: which header/query-param NAMES carried a credential-looking value,
|
|
34
|
+
// each with a truncated sha256 FINGERPRINT of the value. Never the value, never the rest of the
|
|
35
|
+
// query string, never a body. That is what makes the journal answer the question a twin exists to
|
|
36
|
+
// answer and could not: *which credential arrived, in which header?* Two different fingerprints
|
|
37
|
+
// under `dd-api-key` and `dd-application-key` say "two distinct keys arrived, in their own named
|
|
38
|
+
// headers"; the same fingerprint on two rows says "the same value came back"; a missing name says
|
|
39
|
+
// the caller sent nothing. Reading a fingerprint back to its secret is not possible — it is
|
|
40
|
+
// sha256, truncated — so the file stays safe to paste into an issue.
|
|
41
|
+
//
|
|
42
|
+
// The query string used to be dropped whole (it carries tokens), which is precisely why a
|
|
43
|
+
// query-placed key was invisible: the journal now names the credential PARAMS and fingerprints
|
|
44
|
+
// them, and still records `path` without its query.
|
|
45
|
+
//
|
|
46
|
+
// A fingerprint is not a vault: it is sha256 of the value, so a LOW-entropy credential (`"test"`)
|
|
47
|
+
// is guessable from its fingerprint by anyone who guesses the value first. It is exactly the right
|
|
48
|
+
// strength for its job — telling two credentials apart, and recognising one across requests.
|
|
49
|
+
//
|
|
50
|
+
// WHERE IT IS WIRED. Not per-pack. Every vendor pack mounts its own `Bun.serve({fetch})` inside a
|
|
51
|
+
// `create<Vendor>TwinServer({port, root, readOnly})` factory — there is no shared middleware, but
|
|
52
|
+
// there IS a shared CONSTRUCTOR, so `installTwinRequestJournal()` (below, called once at module
|
|
53
|
+
// load) wraps `Bun.serve` and journals every twin's fetch handler. The pack writes no journaling
|
|
54
|
+
// line at all; the four packs that used to call `journalTwinRequest` themselves no longer do.
|
|
55
|
+
// Measured coverage: 66 of the 67 pack HTTP servers journal with zero pack-side code. The one that
|
|
56
|
+
// does not is STRUCTURAL, not missed — `smtp` is a raw-TCP `Bun.listen` mail listener with no HTTP
|
|
57
|
+
// surface. Anything that mounts an HTTP twin through `Bun.serve` is covered by
|
|
58
|
+
// construction; anything that does not is a second transport and would need its own seam.
|
|
59
|
+
|
|
60
|
+
export type TwinCredentialShape = {
|
|
61
|
+
/** header name (lowercased) or query-param name the credential arrived under. */
|
|
62
|
+
name: string;
|
|
63
|
+
/** where it sat on the request. */
|
|
64
|
+
in: 'header' | 'query';
|
|
65
|
+
/** the auth SCHEME when the value carried one (`Bearer`, `Basic`, `AWS4-HMAC-SHA256`). A scheme
|
|
66
|
+
* is not a secret, and it is what distinguishes "a bearer token arrived" from "a named vendor
|
|
67
|
+
* header arrived" — the exact confusion this journal was added to end. */
|
|
68
|
+
scheme?: string;
|
|
69
|
+
/** `sha256:<16 hex>` of the value (of the credential part when a scheme was present). Stable
|
|
70
|
+
* across processes and runs, so equal fingerprints mean equal values; non-reversible. */
|
|
71
|
+
fp?: string;
|
|
72
|
+
/** the name arrived with an EMPTY value — the "the SDK sent the header but no key" case. */
|
|
73
|
+
empty?: true;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type TwinRequestJournalEntry = {
|
|
77
|
+
at?: string;
|
|
78
|
+
method: string;
|
|
79
|
+
path: string;
|
|
80
|
+
status: number;
|
|
81
|
+
/** the serve's wall time in milliseconds (layer 8: a world's response-time report) */
|
|
82
|
+
ms?: number;
|
|
83
|
+
/** credential-looking headers/query params that arrived, named + fingerprinted, never quoted. */
|
|
84
|
+
credentials?: TwinCredentialShape[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The journal read back: every entry kept for a service, oldest first (an empty list when the journal is off or empty). */
|
|
88
|
+
export function readTwinRequestJournal(service: string, root?: string): TwinRequestJournalEntry[] {
|
|
89
|
+
const path = twinRequestJournalPath(service, root);
|
|
90
|
+
const out: TwinRequestJournalEntry[] = [];
|
|
91
|
+
for (const file of [`${path}.1`, path]) {
|
|
92
|
+
const text = getActiveWorldStore().read(file); if (!text) continue;
|
|
93
|
+
for (const line of text.split('\n')) { if (!line) continue; try { out.push(JSON.parse(line) as TwinRequestJournalEntry); } catch { /* a torn line at rotation */ } }
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** ~1 MB cap before a one-deep rotation — small enough to never matter on disk, large enough for
|
|
99
|
+
* tens of thousands of entries (a shape line is ~80 bytes). */
|
|
100
|
+
const REQUEST_JOURNAL_MAX_BYTES = 1_000_000;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Does this header/param NAME look like it carries a credential? Deliberately a generic word
|
|
104
|
+
* predicate, never a vendor table: the kernel must not learn that Datadog calls its keys
|
|
105
|
+
* `DD-API-KEY` or that Postmark calls its tokens `X-Postmark-Server-Token`. Both fall out of
|
|
106
|
+
* `key`/`token` as name segments, and so does every vendor that has not been written yet.
|
|
107
|
+
*/
|
|
108
|
+
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;
|
|
109
|
+
|
|
110
|
+
/** Auth schemes recognised in a value even when the NAME says nothing (`Bearer …` in `x-custom`). */
|
|
111
|
+
const CREDENTIAL_SCHEME = /^(Bearer|Basic|Digest|Token|Negotiate|NTLM|OAuth|Signature|Hawk|AWS4-HMAC-SHA256|SharedKey|SharedKeyLite|GoogleLogin)\s+(\S[\s\S]*)$/i;
|
|
112
|
+
|
|
113
|
+
/** Non-reversible, stable fingerprint. 64 bits of sha256 — enough that two distinct credentials
|
|
114
|
+
* never collide in a journal, far too little to walk back to a real key. */
|
|
115
|
+
export function credentialFingerprint(value: string): string {
|
|
116
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 16)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function credentialShapeFor(name: string, value: string, where: 'header' | 'query'): TwinCredentialShape | undefined {
|
|
120
|
+
const scheme = CREDENTIAL_SCHEME.exec(value);
|
|
121
|
+
if (!scheme && !CREDENTIAL_NAME.test(name)) return undefined;
|
|
122
|
+
const lower = name.toLowerCase();
|
|
123
|
+
if (value === '') return { name: lower, in: where, empty: true };
|
|
124
|
+
if (scheme) return { name: lower, in: where, scheme: scheme[1]!, fp: credentialFingerprint(scheme[2]!) };
|
|
125
|
+
return { name: lower, in: where, fp: credentialFingerprint(value) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The credential SHAPE of one request: every credential-looking header and query param, named and
|
|
130
|
+
* fingerprinted, sorted for stable diffing. Values never leave this function.
|
|
131
|
+
*/
|
|
132
|
+
export function twinRequestCredentials(headers: Headers | Record<string, string>, url?: string | URL): TwinCredentialShape[] {
|
|
133
|
+
const shapes: TwinCredentialShape[] = [];
|
|
134
|
+
const seen = new Set<string>();
|
|
135
|
+
const add = (name: string, value: string, where: 'header' | 'query') => {
|
|
136
|
+
const shape = credentialShapeFor(name, value, where);
|
|
137
|
+
if (!shape) return;
|
|
138
|
+
const key = `${shape.in}:${shape.name}`;
|
|
139
|
+
if (seen.has(key)) return;
|
|
140
|
+
seen.add(key);
|
|
141
|
+
shapes.push(shape);
|
|
142
|
+
};
|
|
143
|
+
try {
|
|
144
|
+
if (typeof (headers as Headers).forEach === 'function') {
|
|
145
|
+
(headers as Headers).forEach((value, name) => add(name, value, 'header'));
|
|
146
|
+
} else {
|
|
147
|
+
for (const [name, value] of Object.entries(headers as Record<string, string>)) add(name, String(value), 'header');
|
|
148
|
+
}
|
|
149
|
+
} catch { /* a malformed header bag must not fail a serve */ }
|
|
150
|
+
try {
|
|
151
|
+
if (url !== undefined) {
|
|
152
|
+
const search = typeof url === 'string' ? new URL(url, 'http://twin.invalid').search : url.search;
|
|
153
|
+
for (const [name, value] of new URLSearchParams(search)) add(name, value, 'query');
|
|
154
|
+
}
|
|
155
|
+
} catch { /* an unparseable URL must not fail a serve */ }
|
|
156
|
+
return shapes.sort((a, b) => (a.in === b.in ? a.name.localeCompare(b.name) : a.in.localeCompare(b.in)));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function twinRequestJournalEnabled(env: Record<string, string | undefined> = process.env): boolean {
|
|
160
|
+
return env.VOLTER_TWIN_REQUEST_JOURNAL === '1';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Where a service's request journal lives: beside its `actions.jsonl`. */
|
|
164
|
+
export function twinRequestJournalPath(service: string, root?: string): string {
|
|
165
|
+
return join(dirname(worldPaths(service, root).events), 'requests.jsonl');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Append one served request to the journal — a no-op unless VOLTER_TWIN_REQUEST_JOURNAL=1, so
|
|
170
|
+
* the default path does zero I/O. Never throws: an observability append must not fail a serve.
|
|
171
|
+
*/
|
|
172
|
+
export function journalTwinRequest(service: string, entry: TwinRequestJournalEntry, root?: string): void {
|
|
173
|
+
if (!twinRequestJournalEnabled()) return;
|
|
174
|
+
try {
|
|
175
|
+
const store = getActiveWorldStore();
|
|
176
|
+
const path = twinRequestJournalPath(service, root);
|
|
177
|
+
store.mkdir(dirname(path));
|
|
178
|
+
const size = store.stat(path)?.size ?? 0;
|
|
179
|
+
if (size >= REQUEST_JOURNAL_MAX_BYTES) {
|
|
180
|
+
// one-deep rotation: the previous overflow is overwritten, the live file starts fresh.
|
|
181
|
+
store.writeAtomic(`${path}.1`, store.read(path) ?? '');
|
|
182
|
+
store.write(path, '');
|
|
183
|
+
}
|
|
184
|
+
const line = JSON.stringify({
|
|
185
|
+
at: entry.at ?? new Date().toISOString(),
|
|
186
|
+
method: entry.method.toUpperCase(),
|
|
187
|
+
path: entry.path.split('?')[0], // the query STRING never lands; its credential params do, named + fingerprinted
|
|
188
|
+
status: entry.status,
|
|
189
|
+
...(typeof entry.ms === 'number' ? { ms: entry.ms } : {}),
|
|
190
|
+
...(entry.credentials?.length ? { credentials: entry.credentials } : {}),
|
|
191
|
+
});
|
|
192
|
+
store.append(path, `${line}\n`);
|
|
193
|
+
} catch {
|
|
194
|
+
// journaling is best-effort observability; the serve path must not care
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── how a wrapped `Bun.serve` learns which twin it is ──────────────────────────────────────────
|
|
199
|
+
// `Bun.serve({port, fetch})` carries neither the vendor id nor the state root — both live in the
|
|
200
|
+
// factory's closure. They are recoverable without touching a single pack, because handling a
|
|
201
|
+
// request makes the pack call the kernel, and every kernel state path funnels through
|
|
202
|
+
// `worldPaths(service, root)` (storage.ts). Observing the FIRST such call a server makes is the
|
|
203
|
+
// twin naming itself: `datadog` under `/tmp/world/data/datadog`. It is cached per server, so only
|
|
204
|
+
// the first request pays for it. A server whose first request touches no state falls back to the
|
|
205
|
+
// call site (`packages/twin/<vendor>/…` or `@volter/twin-<vendor>`) and the process's `--root`
|
|
206
|
+
// argument, which is how the world runtime launches every twin.
|
|
207
|
+
|
|
208
|
+
export const TWIN_JOURNAL_IDENTITY = Symbol.for('volter.twin.requestJournal.identity');
|
|
209
|
+
const JOURNAL_SHIM_INSTALLED = Symbol.for('volter.twin.requestJournal.installed');
|
|
210
|
+
|
|
211
|
+
export type TwinJournalIdentity = { service: string; root?: string };
|
|
212
|
+
|
|
213
|
+
/** One capture slot per in-flight request, so two twins co-located in ONE process (the shared
|
|
214
|
+
* host) can never read each other's identity off a racing sibling's first request.
|
|
215
|
+
*
|
|
216
|
+
* LAZY ON PURPOSE, and this is load-bearing rather than style. Mirror-UI packs import their own
|
|
217
|
+
* server module into the BROWSER bundle and rely on Bun tree-shaking the server-only half away
|
|
218
|
+
* (see any `*-mirror-ui.ts` header). A module-scope `new AsyncLocalStorage()` is a side effect,
|
|
219
|
+
* so it survives tree-shaking and lands in the client — where `node:async_hooks` does not
|
|
220
|
+
* resolve, `new` throws at bundle evaluation, and the React app never mounts. That took out
|
|
221
|
+
* every mirror UI in the estate at once. Nothing in this module may run at module scope. */
|
|
222
|
+
let identityCapture: AsyncLocalStorage<{ identity?: TwinJournalIdentity }> | undefined;
|
|
223
|
+
|
|
224
|
+
function identitySlot(): AsyncLocalStorage<{ identity?: TwinJournalIdentity }> {
|
|
225
|
+
identityCapture ??= new AsyncLocalStorage<{ identity?: TwinJournalIdentity }>();
|
|
226
|
+
return identityCapture;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function observeIdentityFromWorldPaths(): void {
|
|
230
|
+
observeWorldPaths((service, root) => {
|
|
231
|
+
const slot = identityCapture?.getStore();
|
|
232
|
+
if (!slot || slot.identity !== undefined) return;
|
|
233
|
+
slot.identity = root === undefined ? { service } : { service, root };
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The `--root DIR` a twin was launched with (the world runtime's spawn contract), if any. */
|
|
238
|
+
function rootFromArgv(argv: readonly string[] = process.argv): string | undefined {
|
|
239
|
+
const index = argv.indexOf('--root');
|
|
240
|
+
const value = index >= 0 ? argv[index + 1] : undefined;
|
|
241
|
+
return value && !value.startsWith('--') ? value : undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The vendor a `Bun.serve` call site belongs to, from its module path. Infra packages are not
|
|
245
|
+
* vendors, so they are skipped: whoever called THEM is the twin. */
|
|
246
|
+
const INFRA_PACKAGES = new Set(['world-runtime', 'world-tooling', 'world-attach', 'world-browser-assets', 'world-host']);
|
|
247
|
+
|
|
248
|
+
export function vendorFromStack(stack: string | undefined): string | undefined {
|
|
249
|
+
if (!stack) return undefined;
|
|
250
|
+
const pattern = /(?:packages[\\/]twin[\\/]|@volter[\\/]twin-)([A-Za-z0-9_-]+)/g;
|
|
251
|
+
for (const match of stack.matchAll(pattern)) {
|
|
252
|
+
const name = match[1]!;
|
|
253
|
+
if (!INFRA_PACKAGES.has(name)) return name;
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Wrap `Bun.serve` so EVERY twin's HTTP surface journals, with no per-pack line. Idempotent
|
|
262
|
+
* (a `Symbol.for` marker survives a second copy of this module) and a strict pass-through when
|
|
263
|
+
* VOLTER_TWIN_REQUEST_JOURNAL is not `1` — the default path adds one function call per request
|
|
264
|
+
* and does zero I/O. Called once at module load, below; exported so a test can assert it.
|
|
265
|
+
*
|
|
266
|
+
* A pack that knows its own identity may declare it by putting `{service, root}` on the serve
|
|
267
|
+
* options under `TWIN_JOURNAL_IDENTITY` (a symbol key Bun's option reader ignores). Nothing in
|
|
268
|
+
* the estate needs to: `createTwinServer` is the only caller, because it is the one server whose
|
|
269
|
+
* service is an argument rather than a fact about the pack.
|
|
270
|
+
*/
|
|
271
|
+
export function installTwinRequestJournal(): boolean {
|
|
272
|
+
// a browser bundle (a mirror UI carrying the kernel): nothing to wrap, nothing to touch
|
|
273
|
+
if (typeof window !== 'undefined' || typeof document !== 'undefined') return false;
|
|
274
|
+
const bun = (globalThis as { Bun?: { serve?: unknown } }).Bun;
|
|
275
|
+
const original = bun && typeof bun.serve === 'function' ? bun.serve as (...args: unknown[]) => { port?: number } : undefined;
|
|
276
|
+
if (original && (original as unknown as Record<symbol, unknown>)[JOURNAL_SHIM_INSTALLED]) return true;
|
|
277
|
+
// Registered HERE, not at module scope, for the same reason the capture slot is lazy: a
|
|
278
|
+
// module-scope registration is a side effect that survives tree-shaking into the client.
|
|
279
|
+
observeIdentityFromWorldPaths();
|
|
280
|
+
// every server made through the seam (serve-http.ts) journals — on Node this is the only wrap
|
|
281
|
+
setServeDecorator((options) => journalingFetch(options as unknown as Record<string | symbol, unknown> & { fetch: (...a: unknown[]) => unknown }, vendorFromStack(new Error().stack)) as HttpHandler);
|
|
282
|
+
if (!original) return false; // Node: no global to wrap; the seam carries the journal
|
|
283
|
+
|
|
284
|
+
const wrapped = function serve(this: unknown, options: unknown, ...rest: unknown[]) {
|
|
285
|
+
const config = options as (Record<string | symbol, unknown> & { fetch?: (...a: unknown[]) => unknown }) | undefined;
|
|
286
|
+
if (!config || typeof config.fetch !== 'function') return original.apply(this, [options, ...rest]);
|
|
287
|
+
// Captured HERE, at the `Bun.serve` call, because this is the only moment the pack's own
|
|
288
|
+
// module is on the stack — by the time a request is served, Bun is the caller.
|
|
289
|
+
const callSiteVendor = (config[TWIN_JOURNAL_IDENTITY] as TwinJournalIdentity | undefined) ? undefined : vendorFromStack(new Error().stack);
|
|
290
|
+
return original.apply(this, [{ ...config, fetch: journalingFetch(config as Record<string | symbol, unknown> & { fetch: (...a: unknown[]) => unknown }, callSiteVendor) }, ...rest]);
|
|
291
|
+
};
|
|
292
|
+
Object.defineProperty(wrapped, JOURNAL_SHIM_INSTALLED, { value: true });
|
|
293
|
+
(bun as { serve: unknown }).serve = wrapped;
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The journaling form of a server's fetch handler: identity resolved once per SERVER (declared
|
|
298
|
+
* under TWIN_JOURNAL_IDENTITY, else observed from the first state-touching request, else the
|
|
299
|
+
* call-site vendor + `--root`), every request journaled with its method, path and status. */
|
|
300
|
+
function journalingFetch(config: Record<string | symbol, unknown> & { fetch: (...a: unknown[]) => unknown }, callSiteVendor: string | undefined): (request: Request, server?: unknown) => unknown {
|
|
301
|
+
if (config.twinRequestJournal === false) return config.fetch;
|
|
302
|
+
{
|
|
303
|
+
const declared = config[TWIN_JOURNAL_IDENTITY] as TwinJournalIdentity | undefined;
|
|
304
|
+
// Resolved once per SERVER, not per request: a twin's identity is a constant of the server.
|
|
305
|
+
let identity: TwinJournalIdentity | undefined = declared;
|
|
306
|
+
const inner = config.fetch as (this: unknown, request: Request, server: unknown) => unknown;
|
|
307
|
+
const journaling = async function (this: unknown, request: Request, server: unknown) {
|
|
308
|
+
// The World's own boot probe (serve-http.ts, WORLD_BOOT_PATH) is not the app's traffic: never journaled.
|
|
309
|
+
if (!twinRequestJournalEnabled() || new URL(request.url).pathname === WORLD_BOOT_PATH) return inner.call(this, request, server);
|
|
310
|
+
const slot: { identity?: TwinJournalIdentity } = {};
|
|
311
|
+
let status = 500; const startedAt = Date.now();
|
|
312
|
+
try {
|
|
313
|
+
const response = (await identitySlot().run(slot, () => inner.call(this, request, server))) as Response | undefined;
|
|
314
|
+
if (response instanceof Response) status = response.status;
|
|
315
|
+
else if (response === undefined) return response; // upgraded (websocket) — nothing served
|
|
316
|
+
return response;
|
|
317
|
+
} finally {
|
|
318
|
+
// Only an OBSERVED identity is cached: it is the twin's own words. A fallback is
|
|
319
|
+
// per-request, so the first request that touches state upgrades every later one instead
|
|
320
|
+
// of locking a guessed root in for the life of the server.
|
|
321
|
+
identity ??= slot.identity;
|
|
322
|
+
const resolved = identity ?? fallbackIdentity(callSiteVendor);
|
|
323
|
+
if (resolved) {
|
|
324
|
+
const url = new URL(request.url);
|
|
325
|
+
journalTwinRequest(resolved.service, {
|
|
326
|
+
method: request.method,
|
|
327
|
+
path: url.pathname,
|
|
328
|
+
status,
|
|
329
|
+
ms: Date.now() - startedAt,
|
|
330
|
+
credentials: twinRequestCredentials(request.headers, url),
|
|
331
|
+
}, resolved.root);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
return journaling;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Identity for a server whose first request touched no state: the vendor that owns the calling
|
|
340
|
+
* module, plus the `--root` the world runtime launched this twin with. */
|
|
341
|
+
function fallbackIdentity(callSiteVendor: string | undefined): TwinJournalIdentity | undefined {
|
|
342
|
+
const service = process.env.VOLTER_TWIN_JOURNAL_SERVICE || callSiteVendor;
|
|
343
|
+
if (!service || !/^[A-Za-z0-9_-]+$/.test(service)) return undefined;
|
|
344
|
+
const root = rootFromArgv();
|
|
345
|
+
return root === undefined ? { service } : { service, root };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// One call, at module load. `@volter/world-core`'s entrypoint re-exports this module, and every pack
|
|
349
|
+
// that serves imports the kernel, so wrapping here is what makes the journal a property of the
|
|
350
|
+
// PRODUCT rather than of the four packs that once remembered to call it.
|
|
351
|
+
//
|
|
352
|
+
// It is a no-op wherever `Bun.serve` is absent, which is what keeps it safe in a browser bundle:
|
|
353
|
+
// off-Bun it returns before touching `node:async_hooks`, `process.argv`, or the storage hook.
|
|
354
|
+
installTwinRequestJournal();
|
|
355
|
+
|
|
356
|
+
// A subject rendered as a vendor resource: its folded current fields + identity.
|
|
357
|
+
export type TwinResource = { id: string; type: string; updatedAt: string } & Record<string, unknown>;
|
|
358
|
+
|
|
359
|
+
// The twin's current view = the OBSERVED mirror (events.jsonl) with the local
|
|
360
|
+
// ACTION log (actions.jsonl) projected over it (R18). Observed facts and local
|
|
361
|
+
// actions are kept separate; this is the single projected read model.
|
|
362
|
+
export function twinResources(service: string, root?: string): TwinResource[] {
|
|
363
|
+
return projectResources(service, root);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Result of a local twin write — it is an ACTION, not an egress/real write.
|
|
367
|
+
export type TwinWriteResult = { status: 'performed' | 'replayed'; actionId: string; /** the id the vendor minted when the head performed the write */ externalId?: string; /** what the vendor answered when the head performed the write, as the adapter returned it */ vendorData?: unknown };
|
|
368
|
+
|
|
369
|
+
export type TwinWriteInput = {
|
|
370
|
+
operation: string;
|
|
371
|
+
provider?: string;
|
|
372
|
+
subjectType: string;
|
|
373
|
+
subjectId: string;
|
|
374
|
+
fields: SubjectFields;
|
|
375
|
+
input?: Record<string, unknown>;
|
|
376
|
+
projection?: ActionProjection;
|
|
377
|
+
occurredAt?: string;
|
|
378
|
+
actor?: { kind: 'agent' | 'human' | 'bot' | 'system'; id?: string };
|
|
379
|
+
preconditions?: TwinActionPrecondition[];
|
|
380
|
+
correlationId?: string;
|
|
381
|
+
uniqueness?: string;
|
|
382
|
+
/** AT-MOST-ONCE, opt in. A local vendor write is an OCCURRENCE by default: two calls are two
|
|
383
|
+
* actions, even byte-identical in the same instant. Pass a caller-supplied key when a re-issue
|
|
384
|
+
* of the SAME request must collapse onto the first. Without a key the kernel cannot tell a retry
|
|
385
|
+
* from a repeat, and guessing from content is what silently dropped writes.
|
|
386
|
+
*
|
|
387
|
+
* SCOPE, stated plainly because an earlier version of this comment overclaimed: the key dedupes
|
|
388
|
+
* on (service, operation, subjectId, key). It therefore helps only when the CALLER supplies the
|
|
389
|
+
* subject id. A create whose id the twin mints gets a fresh id per call and is NOT deduped by
|
|
390
|
+
* this — which is the commonest shape a vendor's own idempotency header covers — so a pack
|
|
391
|
+
* modelling such a header for creates keeps doing it itself, by request signature and stored
|
|
392
|
+
* response (the payments pack in this catalog does). Reuse of a key with a DIFFERENT payload
|
|
393
|
+
* raises a conflict, which the generic twin server answers as a 400 (2026-09-04). */
|
|
394
|
+
idempotencyKey?: string;
|
|
395
|
+
/** The EXACT identity of an action being re-materialized, when the caller already has one —
|
|
396
|
+
* replay reproducing a source changeset's actions into a target world. Distinct from
|
|
397
|
+
* `idempotencyKey`, which is a REQUEST key the kernel derives an id from: this IS the id, so a
|
|
398
|
+
* replayed action keeps the identity it had in the source world. Implies at-most-once, because
|
|
399
|
+
* identity equality is what at-most-once means. Not for packs. */
|
|
400
|
+
actionId?: string;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
export type AtomicTwinWriteDecision<T> =
|
|
404
|
+
| { kind: 'skip'; value: T }
|
|
405
|
+
| { kind: 'write'; value: T; write: TwinWriteInput };
|
|
406
|
+
|
|
407
|
+
function actionForTwinWrite(service: string, write: TwinWriteInput): TwinAction {
|
|
408
|
+
const occurredAt = write.occurredAt ?? new Date().toISOString();
|
|
409
|
+
// Idempotency: dedup a RE-ISSUED IDENTICAL write only. The key includes a hash of the write
|
|
410
|
+
// CONTENT, not just the timestamp. `undefined` keys are omitted by canonicalJson, preserving
|
|
411
|
+
// every pre-input/projection action id for existing packs.
|
|
412
|
+
const contentHash = hashFieldValue({ operation: write.operation, subjectId: write.subjectId, fields: write.fields, input: write.input, projection: write.projection });
|
|
413
|
+
// A caller-supplied identity MAY carry an occurrence ordinal, and must: `actionId`'s one
|
|
414
|
+
// legitimate caller is replay, and a replayed changeset that contains a REPEAT carries the
|
|
415
|
+
// source's `<base>#1` verbatim. A guard here that rejected ordinal suffixes — added to stop a
|
|
416
|
+
// pack squatting slot #5 — threw on exactly that replay, and nothing had tested "replay a
|
|
417
|
+
// repeat" (found 2026-09-04 in review). The squat it guarded against is a misuse of a
|
|
418
|
+
// replay-only field, is LOUD on collision, and corrupts nothing; breaking the CI primitive to
|
|
419
|
+
// prevent it was the wrong trade.
|
|
420
|
+
const actionId = write.actionId
|
|
421
|
+
?? (write.idempotencyKey
|
|
422
|
+
? `twin:${service}:${write.operation}:${write.subjectId}:idem:${write.idempotencyKey}`
|
|
423
|
+
: `twin:${service}:${write.operation}:${write.subjectId}:${occurredAt}:${contentHash}${write.uniqueness ? `:${write.uniqueness}` : ''}`);
|
|
424
|
+
return {
|
|
425
|
+
id: actionId,
|
|
426
|
+
service,
|
|
427
|
+
op: 'set',
|
|
428
|
+
operation: write.operation,
|
|
429
|
+
subject: { type: write.subjectType, id: write.subjectId },
|
|
430
|
+
occurredAt,
|
|
431
|
+
...(write.actor ? { actor: write.actor } : {}),
|
|
432
|
+
...(write.preconditions?.length ? { preconditions: write.preconditions } : {}),
|
|
433
|
+
...(write.correlationId ? { correlationId: write.correlationId } : {}),
|
|
434
|
+
...(write.idempotencyKey ? { idempotencyKey: write.idempotencyKey } : {}),
|
|
435
|
+
...(write.input ? { input: write.input } : {}),
|
|
436
|
+
fields: write.fields,
|
|
437
|
+
...(write.projection ? { projection: write.projection } : {}),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Decide a state-dependent local write and append it under the same cross-process action lock.
|
|
443
|
+
* Use this when acceptance or the new fields depend on current projected state; a caller-side
|
|
444
|
+
* read followed by `applyTwinWrite` is not atomic across processes.
|
|
445
|
+
*/
|
|
446
|
+
export async function applyTwinWriteAtomic<T>(
|
|
447
|
+
service: string,
|
|
448
|
+
prepare: (resources: readonly TwinResource[]) => AtomicTwinWriteDecision<T>,
|
|
449
|
+
root?: string,
|
|
450
|
+
): Promise<{ value: T; result?: TwinWriteResult }> {
|
|
451
|
+
// Same ruling as applyTwinWrite: a local write is an OCCURRENCE unless the caller supplied
|
|
452
|
+
// identity. The mode rides on the decision because only the callback knows which write it chose.
|
|
453
|
+
const committed = decideAndAppendAction(service, (resources) => {
|
|
454
|
+
const decision = prepare(resources);
|
|
455
|
+
if (decision.kind === 'skip') return { kind: 'skip', value: decision.value };
|
|
456
|
+
return {
|
|
457
|
+
kind: 'append',
|
|
458
|
+
value: decision.value,
|
|
459
|
+
action: actionForTwinWrite(service, decision.write),
|
|
460
|
+
identity: decision.write.idempotencyKey || decision.write.actionId ? 'caller' : 'occurrence',
|
|
461
|
+
};
|
|
462
|
+
}, root);
|
|
463
|
+
// as applyTwinWrite: a seed's write is the placeholder's default data, never performed
|
|
464
|
+
const head = committed.action && committed.appended && !committed.placeholder ? await performAtHead(service, committed.action, root) : { performed: false };
|
|
465
|
+
return {
|
|
466
|
+
value: committed.value,
|
|
467
|
+
...(committed.action ? { result: { status: committed.appended ? 'performed' : 'replayed', actionId: committed.action.id, ...(head.externalId ? { externalId: head.externalId } : {}) } } : {}),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Resolve a read request against the twin's resources. Returns the matched
|
|
472
|
+
// resource(s) + an HTTP-ish status, deterministically from state.
|
|
473
|
+
// GET / -> { service, mode, resourceTypes, count }
|
|
474
|
+
// GET /<type> -> list of resources of that type
|
|
475
|
+
// GET /<type>/<id> -> one resource (id may be url-encoded)
|
|
476
|
+
export function resolveTwinRead(
|
|
477
|
+
service: string,
|
|
478
|
+
pathname: string,
|
|
479
|
+
opts: { root?: string } = {},
|
|
480
|
+
): { status: number; body: unknown } {
|
|
481
|
+
const resources = twinResources(service, opts.root);
|
|
482
|
+
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
483
|
+
|
|
484
|
+
if (parts.length === 0) {
|
|
485
|
+
const types = [...new Set(resources.map((r) => r.type))].sort();
|
|
486
|
+
return { status: 200, body: { service, resourceTypes: types, count: resources.length } };
|
|
487
|
+
}
|
|
488
|
+
const type = parts[0]!;
|
|
489
|
+
const ofType = resources.filter((r) => r.type === type);
|
|
490
|
+
if (parts.length === 1) {
|
|
491
|
+
return { status: 200, body: { type, count: ofType.length, items: ofType } };
|
|
492
|
+
}
|
|
493
|
+
const id = decodeURIComponent(parts.slice(1).join('/'));
|
|
494
|
+
const found = ofType.find((r) => r.id === id);
|
|
495
|
+
if (!found) return { status: 404, body: { error: 'not_found', type, id } };
|
|
496
|
+
return { status: 200, body: found };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Simulator/fork-mode write: the twin ACCEPTS a write as a LOCAL ACTION appended
|
|
500
|
+
// to the action log (R18) — NOT an observed event and NOT an egress/real write.
|
|
501
|
+
// State is the mirror with this action projected over it, so a subsequent read
|
|
502
|
+
// returns the change. The observed event log is untouched; the real service is
|
|
503
|
+
// provably untouched (no network I/O, no egress). Pushing the action to the real
|
|
504
|
+
// vendor is a separate, explicit step that records egress + confirms the action.
|
|
505
|
+
export async function applyTwinWrite(
|
|
506
|
+
service: string,
|
|
507
|
+
write: TwinWriteInput,
|
|
508
|
+
root?: string,
|
|
509
|
+
): Promise<{ result: TwinWriteResult; resource: TwinResource }> {
|
|
510
|
+
const action = actionForTwinWrite(service, write);
|
|
511
|
+
// A LOCAL VENDOR WRITE IS AN OCCURRENCE. Two calls are two actions, even byte-identical in the
|
|
512
|
+
// same instant, and the ordinal in the action id says which occurrence this is. The kernel used
|
|
513
|
+
// to derive identity from (content + occurredAt millisecond) and drop a repeat as `replayed`,
|
|
514
|
+
// which silently lost any write returning a subject to a value it previously held — permanently,
|
|
515
|
+
// under a pinned world clock, where every write in a world shares one instant. That default cost
|
|
516
|
+
// github, slack and jira live defects while the recipe told each pack to defend itself with a
|
|
517
|
+
// per-write ordinal and a fifth of the catalog opted out via `uniqueness`. A default every pack
|
|
518
|
+
// must remember to defend against is not a default. See docs/contributing/adding-a-twin.md
|
|
519
|
+
// #5-build-on-the-shared-kernel--dont-reinvent, "A local write is an occurrence".
|
|
520
|
+
//
|
|
521
|
+
// AT-MOST-ONCE is opt in, by KEY: `idempotencyKey` makes the id a function of the caller's own
|
|
522
|
+
// request identity, so a re-issue collapses onto the first and answers `replayed`. Content can
|
|
523
|
+
// never decide this — it cannot separate the same request delivered twice from the same change
|
|
524
|
+
// made twice — which is why the kernel no longer tries.
|
|
525
|
+
//
|
|
526
|
+
// `uniqueness` is subsumed and still honoured: it was the escape hatch packs reached for when
|
|
527
|
+
// the default was backwards (a billable AI completion that must ledger every call), and with
|
|
528
|
+
// occurrence semantics it is simply redundant rather than wrong.
|
|
529
|
+
//
|
|
530
|
+
// correlationId (D3): a caller-supplied request-scoped id lands on the action row; the appenders
|
|
531
|
+
// generate one when omitted, so it's never missing.
|
|
532
|
+
const committed = write.idempotencyKey || write.actionId
|
|
533
|
+
? appendActionIfAbsent(action, root)
|
|
534
|
+
: appendActionOccurrence(action, root);
|
|
535
|
+
// The COMMITTED action's id, not the one computed before the lock: an occurrence past the first
|
|
536
|
+
// carries an ordinal, and a caller told the base id would be naming an action that is not there.
|
|
537
|
+
const { appended, action: landed } = committed;
|
|
538
|
+
// THE HEAD (head.ts): a twin whose root says `auto` performs the entry now, before the app is
|
|
539
|
+
// answered; the vendor's id, when it minted one, is the id the answer carries.
|
|
540
|
+
// a write landed as the placeholder's default data (a seed) is a pull, never a commit: the head is not asked
|
|
541
|
+
const head = appended && !committed.placeholder ? await performAtHead(service, landed, root) : { performed: false };
|
|
542
|
+
const answerId = head.externalId ?? write.subjectId;
|
|
543
|
+
const result: TwinWriteResult = { status: appended ? 'performed' : 'replayed', actionId: landed.id, ...(head.externalId ? { externalId: head.externalId } : {}), ...(head.data !== undefined ? { vendorData: head.data } : {}) };
|
|
544
|
+
// THE WRITTEN RESOURCE, read when a caller asks for it. Projecting the whole tree after every write to answer it
|
|
545
|
+
// made each write O(tree) and a batch of N writes O(N x tree), whether or not the caller used the answer (a Timestream
|
|
546
|
+
// seed of a member's wearable history stalled its twin for minutes). Resolved by (type, id): an id alone is ambiguous
|
|
547
|
+
// when two resource TYPES share it.
|
|
548
|
+
let resolved: TwinResource | undefined;
|
|
549
|
+
return {
|
|
550
|
+
result,
|
|
551
|
+
get resource(): TwinResource {
|
|
552
|
+
return resolved ??= projectResources(service, root).find((r) => r.type === write.subjectType && r.id === answerId)
|
|
553
|
+
?? ({ id: answerId, type: write.subjectType, updatedAt: landed.occurredAt, ...write.fields } as TwinResource);
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export async function createTwinServer(options: { service: string; root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; stop: () => void }> {
|
|
559
|
+
const readOnly = options.readOnly ?? false;
|
|
560
|
+
const serveOptions = {
|
|
561
|
+
port: options.port ?? 0,
|
|
562
|
+
idleTimeout: 60,
|
|
563
|
+
async fetch(request: Request) {
|
|
564
|
+
const url = new URL(request.url);
|
|
565
|
+
const json = (status: number, body: unknown) =>
|
|
566
|
+
new Response(JSON.stringify(body, null, 2), { status, headers: { 'content-type': 'application/json' } });
|
|
567
|
+
if (request.method === 'GET') {
|
|
568
|
+
// Re-read per request so a concurrently-syncing twin serves fresh state.
|
|
569
|
+
const { status, body } = resolveTwinRead(options.service, url.pathname, { root: options.root });
|
|
570
|
+
return json(status, body);
|
|
571
|
+
}
|
|
572
|
+
// Writes are accepted as local actions unless this twin was started read-only.
|
|
573
|
+
if (readOnly) return json(405, { error: 'read_only', hint: 'this twin was started read-only; omit readOnly to accept writes' });
|
|
574
|
+
const parts = url.pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
575
|
+
if (parts.length < 2) return json(400, { error: 'write_needs_type_and_id', hint: 'POST /<type>/<id> with a JSON body of fields' });
|
|
576
|
+
let fields: SubjectFields;
|
|
577
|
+
try { fields = (await request.json()) as SubjectFields; } catch { return json(400, { error: 'invalid_json_body' }); }
|
|
578
|
+
// AT-MOST-ONCE OVER HTTP, the way vendors spell it. A local write is an occurrence, so an
|
|
579
|
+
// identical repeat POST is a second action and answers 201 — which is the truth, and a change
|
|
580
|
+
// from the old content-dedupe behaviour. A caller that means "the same request again" sends
|
|
581
|
+
// `Idempotency-Key`, exactly as Stripe and others define it, and gets 200 `replayed`. Without
|
|
582
|
+
// this the `replayed` arm below was unreachable on the kernel's own write endpoint.
|
|
583
|
+
const idempotencyKey = request.headers.get('idempotency-key') ?? undefined;
|
|
584
|
+
let committed: Awaited<ReturnType<typeof applyTwinWrite>>;
|
|
585
|
+
try {
|
|
586
|
+
committed = await applyTwinWrite(options.service, {
|
|
587
|
+
operation: `${request.method.toLowerCase()}.${parts[0]}`,
|
|
588
|
+
subjectType: parts[0]!,
|
|
589
|
+
subjectId: decodeURIComponent(parts.slice(1).join('/')),
|
|
590
|
+
fields,
|
|
591
|
+
actor: { kind: 'agent' },
|
|
592
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
593
|
+
}, options.root);
|
|
594
|
+
} catch (e) {
|
|
595
|
+
// A key REUSED WITH A DIFFERENT PAYLOAD is the caller's error, and the vendors that have
|
|
596
|
+
// this header say so with a 400 and an `idempotency_error` code. The kernel raises it as a
|
|
597
|
+
// conflicting-duplicate throw, which this server was letting escape as a 500 HTML page —
|
|
598
|
+
// found 2026-09-04 in review, one day after the header was wired. Anything else is still
|
|
599
|
+
// a genuine failure and still propagates.
|
|
600
|
+
if (idempotencyKey && e instanceof Error && e.message.startsWith('Conflicting duplicate twin action')) {
|
|
601
|
+
return json(400, { error: 'idempotency_error', message: `Keys for idempotent requests can only be used with the same parameters they were first used with. Try using a key other than '${idempotencyKey}' if you meant to execute a different request.` });
|
|
602
|
+
}
|
|
603
|
+
throw e;
|
|
604
|
+
}
|
|
605
|
+
const { result, resource } = committed;
|
|
606
|
+
return json(result.status === 'replayed' ? 200 : 201, { status: result.status, resource });
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
// This generic server's twin is an ARGUMENT, not a fact about a pack, so it NAMES ITSELF for the
|
|
610
|
+
// request journal rather than being recognised from its call site. Every vendor pack is journaled
|
|
611
|
+
// without declaring anything (installTwinRequestJournal, above); this is the lone declaration.
|
|
612
|
+
Object.defineProperty(serveOptions, TWIN_JOURNAL_IDENTITY, {
|
|
613
|
+
value: { service: options.service, ...(options.root !== undefined ? { root: options.root } : {}) },
|
|
614
|
+
enumerable: true,
|
|
615
|
+
});
|
|
616
|
+
const server = await serveHttp(serveOptions);
|
|
617
|
+
return { port: server.port, stop: () => { void server.stop(true); } };
|
|
618
|
+
}
|