@volter/twin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +68 -0
- package/dist/src/actions.d.ts +138 -0
- package/dist/src/actions.js +201 -0
- package/dist/src/args.d.ts +3 -0
- package/dist/src/args.js +12 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +425 -0
- package/dist/src/connector.d.ts +106 -0
- package/dist/src/connector.js +129 -0
- package/dist/src/control-plane.d.ts +21 -0
- package/dist/src/control-plane.js +40 -0
- package/dist/src/egress.d.ts +93 -0
- package/dist/src/egress.js +264 -0
- package/dist/src/fork.d.ts +126 -0
- package/dist/src/fork.js +206 -0
- package/dist/src/index.d.ts +42 -0
- package/dist/src/index.js +52 -0
- package/dist/src/lease.d.ts +50 -0
- package/dist/src/lease.js +80 -0
- package/dist/src/packRegistry.d.ts +34 -0
- package/dist/src/packRegistry.js +22 -0
- package/dist/src/plan.d.ts +97 -0
- package/dist/src/plan.js +151 -0
- package/dist/src/proxy.d.ts +25 -0
- package/dist/src/proxy.js +152 -0
- package/dist/src/pushLedger.d.ts +81 -0
- package/dist/src/pushLedger.js +130 -0
- package/dist/src/queueLifecycle.d.ts +62 -0
- package/dist/src/queueLifecycle.js +95 -0
- package/dist/src/reconcile.d.ts +58 -0
- package/dist/src/reconcile.js +137 -0
- package/dist/src/refs.d.ts +29 -0
- package/dist/src/refs.js +68 -0
- package/dist/src/schemas.d.ts +78 -0
- package/dist/src/schemas.js +50 -0
- package/dist/src/serve.d.ts +44 -0
- package/dist/src/serve.js +93 -0
- package/dist/src/shadow.d.ts +77 -0
- package/dist/src/shadow.js +138 -0
- package/dist/src/status.d.ts +31 -0
- package/dist/src/status.js +42 -0
- package/dist/src/storage.d.ts +119 -0
- package/dist/src/storage.js +535 -0
- package/dist/src/sync.d.ts +91 -0
- package/dist/src/sync.js +121 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validate.d.ts +27 -0
- package/dist/src/validate.js +68 -0
- package/dist/src/visualizer.d.ts +13 -0
- package/dist/src/visualizer.js +133 -0
- package/dist/src/worldConfig.d.ts +9 -0
- package/dist/src/worldConfig.js +16 -0
- package/inject.cjs +429 -0
- package/package.json +81 -0
- package/src/actions.ts +285 -0
- package/src/args.ts +14 -0
- package/src/cli.ts +443 -0
- package/src/connector.ts +220 -0
- package/src/control-plane.ts +66 -0
- package/src/egress.ts +355 -0
- package/src/fork.ts +256 -0
- package/src/index.ts +222 -0
- package/src/lease.ts +97 -0
- package/src/packRegistry.ts +60 -0
- package/src/plan.ts +190 -0
- package/src/proxy.ts +180 -0
- package/src/pushLedger.ts +189 -0
- package/src/queueLifecycle.ts +130 -0
- package/src/reconcile.ts +192 -0
- package/src/refs.ts +91 -0
- package/src/schemas.ts +56 -0
- package/src/serve.ts +120 -0
- package/src/shadow.ts +192 -0
- package/src/status.ts +58 -0
- package/src/storage.ts +632 -0
- package/src/sync.ts +160 -0
- package/src/types.ts +50 -0
- package/src/validate.ts +95 -0
- package/src/visualizer.ts +142 -0
- package/src/worldConfig.ts +26 -0
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Zero-edit browser injection: a dev proxy you put in front of your app so the
|
|
2
|
+
// browser's vendor SDK calls share the SAME twin the backend uses — no app edits.
|
|
3
|
+
//
|
|
4
|
+
// volter-twin proxy --target http://localhost:3000 \
|
|
5
|
+
// --map stripe=http://127.0.0.1:12111 --route stripe=/v1/ --loader-host stripe=https://api.stripe.com
|
|
6
|
+
//
|
|
7
|
+
// The browser loads your app through the proxy. Requests to a configured vendor's
|
|
8
|
+
// browser API path (its `apiPathPrefix`, e.g. Stripe's /v1/…) are forwarded to that
|
|
9
|
+
// vendor's twin; everything else passes through to your app. For vendors whose browser
|
|
10
|
+
// SDK loads from a CDN and calls an absolute host (its `loaderHost`, e.g. Stripe.js →
|
|
11
|
+
// api.stripe.com), the proxy rewrites the loader so its calls become same-origin and get
|
|
12
|
+
// forwarded too. This kernel module is VENDOR-AGNOSTIC: the per-vendor routing values are
|
|
13
|
+
// DATA supplied by the caller (sourced from each pack's `TwinPack.browserRouting`), never a
|
|
14
|
+
// hardcoded vendor table.
|
|
15
|
+
import http from 'node:http';
|
|
16
|
+
import net from 'node:net';
|
|
17
|
+
|
|
18
|
+
/** How one vendor's browser SDK is routed to its twin. Supplied by the caller. */
|
|
19
|
+
export interface VendorRoute {
|
|
20
|
+
/** The vendor's twin origin, e.g. http://127.0.0.1:12111 */
|
|
21
|
+
origin: string;
|
|
22
|
+
/** Same-origin browser API path that belongs to this vendor, e.g. '/v1/' */
|
|
23
|
+
apiPathPrefix: string;
|
|
24
|
+
/** Absolute API host to strip from the vendor's browser loader so calls become
|
|
25
|
+
* same-origin (e.g. 'https://api.stripe.com'). Omit if the SDK already calls same-origin. */
|
|
26
|
+
loaderHost?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TwinProxyOptions {
|
|
30
|
+
/** Your app's origin, e.g. http://localhost:3000 */
|
|
31
|
+
target: string;
|
|
32
|
+
/** vendor → routing. */
|
|
33
|
+
map: Record<string, VendorRoute>;
|
|
34
|
+
/** Extra local service origins whose redirects should be rewritten back to this proxy origin. */
|
|
35
|
+
redirectOrigins?: string[];
|
|
36
|
+
/** Listen port (0 = ephemeral). */
|
|
37
|
+
port?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TwinProxy {
|
|
41
|
+
port: number;
|
|
42
|
+
stop(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function vendorForPath(path: string, map: Record<string, VendorRoute>): string | null {
|
|
46
|
+
for (const [vendor, route] of Object.entries(map)) {
|
|
47
|
+
if (path.startsWith(route.apiPathPrefix)) return vendor;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function escapeRegExp(value: string): string {
|
|
53
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Strip `loaderHost` (a full origin, e.g. 'https://api.stripe.com') from `text` so its
|
|
58
|
+
* calls become same-origin — but ONLY where it appears as a complete host, not as a
|
|
59
|
+
* blind substring. A plain `split(loaderHost).join('')` (the prior implementation) is
|
|
60
|
+
* NOT position-aware: it removes the substring wherever it occurs, including as a
|
|
61
|
+
* PREFIX of a longer, different host — e.g. 'https://api.stripe.com.evil.example/x'
|
|
62
|
+
* contains 'https://api.stripe.com' as a literal substring, and a naive strip corrupts
|
|
63
|
+
* it into '.evil.example/x' (the true host is stripe.com.evil.example, not stripe.com;
|
|
64
|
+
* same substring bug would also mangle a subdomain like sandbox.api.stripe.com if the
|
|
65
|
+
* scheme weren't part of the match). Position-aware fix: only strip a match that is NOT
|
|
66
|
+
* immediately followed by a domain-continuing character (letter/digit/dot/hyphen) —
|
|
67
|
+
* that character means the matched text is a prefix of a longer host, not the whole
|
|
68
|
+
* host, so it is left untouched.
|
|
69
|
+
*/
|
|
70
|
+
function stripLoaderHost(text: string, loaderHost: string): string {
|
|
71
|
+
const pattern = new RegExp(`${escapeRegExp(loaderHost)}(?![A-Za-z0-9.-])`, 'g');
|
|
72
|
+
return text.replace(pattern, '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rewriteLocation(location: string | string[] | undefined, req: http.IncomingMessage, origins: string[]): string | string[] | undefined {
|
|
76
|
+
if (!location || Array.isArray(location)) return location;
|
|
77
|
+
const host = req.headers.host;
|
|
78
|
+
if (!host) return location;
|
|
79
|
+
const proxyOrigin = `http://${host}`;
|
|
80
|
+
for (const origin of origins) {
|
|
81
|
+
const normalized = origin.replace(/\/$/, '');
|
|
82
|
+
if (location === normalized) return proxyOrigin;
|
|
83
|
+
if (location.startsWith(`${normalized}/`)) return `${proxyOrigin}${location.slice(normalized.length)}`;
|
|
84
|
+
}
|
|
85
|
+
return location;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function forwardTo(
|
|
89
|
+
origin: string,
|
|
90
|
+
req: http.IncomingMessage,
|
|
91
|
+
res: http.ServerResponse,
|
|
92
|
+
rewrite?: (body: Buffer, headers: http.IncomingHttpHeaders) => Buffer,
|
|
93
|
+
redirectOrigins: string[] = [],
|
|
94
|
+
): void {
|
|
95
|
+
const target = new URL(req.url || '/', origin);
|
|
96
|
+
const headers = { ...req.headers, host: target.host };
|
|
97
|
+
delete headers['accept-encoding']; // so we can rewrite uncompressed bodies
|
|
98
|
+
const upstream = http.request(target, { method: req.method, headers }, (up) => {
|
|
99
|
+
if ((up.statusCode ?? 0) >= 300 && (up.statusCode ?? 0) < 400) {
|
|
100
|
+
const responseHeaders = { ...up.headers };
|
|
101
|
+
// `location` is single-valued, so rewriteLocation returns string|undefined here (never an array).
|
|
102
|
+
responseHeaders.location = rewriteLocation(responseHeaders.location, req, [origin, ...redirectOrigins]) as string | undefined;
|
|
103
|
+
delete responseHeaders['transfer-encoding'];
|
|
104
|
+
delete responseHeaders['content-length'];
|
|
105
|
+
res.writeHead(up.statusCode || 302, responseHeaders);
|
|
106
|
+
res.end();
|
|
107
|
+
up.resume();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const chunks: Buffer[] = [];
|
|
111
|
+
up.on('data', (c) => chunks.push(c as Buffer));
|
|
112
|
+
up.on('error', () => res.destroyed || res.end());
|
|
113
|
+
up.on('end', () => {
|
|
114
|
+
const body = Buffer.concat(chunks);
|
|
115
|
+
const out = rewrite ? rewrite(body, up.headers) : body;
|
|
116
|
+
const responseHeaders = { ...up.headers };
|
|
117
|
+
delete responseHeaders['content-length'];
|
|
118
|
+
delete responseHeaders['content-encoding'];
|
|
119
|
+
delete responseHeaders['transfer-encoding'];
|
|
120
|
+
responseHeaders['content-length'] = String(Buffer.byteLength(out));
|
|
121
|
+
res.writeHead(up.statusCode || 502, responseHeaders);
|
|
122
|
+
res.end(out);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
upstream.on('error', (error) => {
|
|
126
|
+
if (res.headersSent) { res.destroy(error); return; }
|
|
127
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
128
|
+
res.end(`twin proxy upstream error: ${error.message}`);
|
|
129
|
+
});
|
|
130
|
+
req.pipe(upstream);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createTwinProxy(opts: TwinProxyOptions): TwinProxy {
|
|
134
|
+
const map: Record<string, VendorRoute> = {};
|
|
135
|
+
for (const [vendor, route] of Object.entries(opts.map)) map[vendor] = { ...route, origin: route.origin.replace(/\/$/, '') };
|
|
136
|
+
const redirectOrigins = (opts.redirectOrigins ?? []).map((origin) => origin.replace(/\/$/, ''));
|
|
137
|
+
|
|
138
|
+
const server = http.createServer((req, res) => {
|
|
139
|
+
const path = req.url || '/';
|
|
140
|
+
const vendor = vendorForPath(path, map);
|
|
141
|
+
if (vendor) {
|
|
142
|
+
forwardTo(map[vendor].origin, req, res, undefined, redirectOrigins); // browser vendor API call → the twin
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Otherwise pass through to the app, rewriting vendor loaders so their calls come back here.
|
|
146
|
+
forwardTo(opts.target, req, res, (body, headers) => {
|
|
147
|
+
const contentType = String(headers['content-type'] || '');
|
|
148
|
+
if (!contentType.includes('javascript') && !contentType.includes('html')) return body;
|
|
149
|
+
let text = body.toString('utf8');
|
|
150
|
+
for (const route of Object.values(map)) {
|
|
151
|
+
if (route.loaderHost) text = stripLoaderHost(text, route.loaderHost);
|
|
152
|
+
}
|
|
153
|
+
return Buffer.from(text, 'utf8');
|
|
154
|
+
}, redirectOrigins);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// WebSocket / HTTP upgrade passthrough to the app.
|
|
158
|
+
server.on('upgrade', (req, socket, head) => {
|
|
159
|
+
const target = new URL(req.url || '/', opts.target);
|
|
160
|
+
const upstream = net.connect(Number(target.port || 80), target.hostname, () => {
|
|
161
|
+
upstream.write(`${req.method} ${target.pathname}${target.search} HTTP/${req.httpVersion}\r\n`);
|
|
162
|
+
for (const [name, value] of Object.entries(req.headers)) upstream.write(`${name}: ${value}\r\n`);
|
|
163
|
+
upstream.write('\r\n');
|
|
164
|
+
upstream.write(head);
|
|
165
|
+
upstream.pipe(socket);
|
|
166
|
+
socket.pipe(upstream);
|
|
167
|
+
});
|
|
168
|
+
const killSocket = () => { if (!socket.destroyed) socket.destroy(); };
|
|
169
|
+
const killUpstream = () => { if (!upstream.destroyed) upstream.destroy(); };
|
|
170
|
+
upstream.on('error', killSocket);
|
|
171
|
+
socket.on('error', killUpstream);
|
|
172
|
+
socket.on('close', killUpstream);
|
|
173
|
+
upstream.on('close', killSocket);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
server.listen(opts.port ?? 0, '127.0.0.1');
|
|
177
|
+
const address = server.address();
|
|
178
|
+
const port = address && typeof address === 'object' ? address.port : (opts.port ?? 0);
|
|
179
|
+
return { port, stop: () => server.close() };
|
|
180
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Push ledger + phases (the twins architecture notes → "Push Ledger Packet").
|
|
2
|
+
// Records replication of a local transaction commit to the real provider, separate
|
|
3
|
+
// from observed events and the action log. A push walks explicit phases —
|
|
4
|
+
// intent → attempted → provider_accepted → observed_confirmed
|
|
5
|
+
// → projection_suppressed → confirmed (or failed / abandoned)
|
|
6
|
+
// `provider_accepted` (the API call returned) is NOT confirmation; confirmation is
|
|
7
|
+
// when the real provider fact is committed as an observed event and linked back to
|
|
8
|
+
// the action (via confirmAction), at which point the local projection is suppressed.
|
|
9
|
+
// Real I/O enters only through the injected writeFn (auth boundary). Append-only;
|
|
10
|
+
// current status = the latest row per push id. Idempotent: a confirmed push replays.
|
|
11
|
+
//
|
|
12
|
+
// Crash parity with egress.ts: a crash mid-flight (process dies while writeFn is in
|
|
13
|
+
// flight) leaves the latest row `attempted` with nothing after it — we don't know
|
|
14
|
+
// whether the provider call landed. Re-sending blind risks a silent double-apply
|
|
15
|
+
// against a non-idempotent vendor operation, so pushTransaction refuses
|
|
16
|
+
// (UnreconciledPushError) unless the caller verifies external state and passes
|
|
17
|
+
// onUnreconciled: 'retry' (mirrors egress.ts's UnreconciledWriteIntentError).
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { confirmAction } from './actions.ts';
|
|
22
|
+
import type { SubjectFields } from './shadow.ts';
|
|
23
|
+
import { appendDurable, twinLog, withFileLock, worldPaths } from './storage.ts';
|
|
24
|
+
|
|
25
|
+
export type PushStatus =
|
|
26
|
+
| 'intent'
|
|
27
|
+
| 'attempted'
|
|
28
|
+
| 'provider_accepted'
|
|
29
|
+
| 'succeeded'
|
|
30
|
+
| 'failed'
|
|
31
|
+
| 'observed_confirmed'
|
|
32
|
+
| 'projection_suppressed'
|
|
33
|
+
| 'confirmed'
|
|
34
|
+
| 'abandoned';
|
|
35
|
+
|
|
36
|
+
export type WorldPushRecord = {
|
|
37
|
+
id: string;
|
|
38
|
+
service: string;
|
|
39
|
+
actionId: string;
|
|
40
|
+
provider: string;
|
|
41
|
+
operation: string;
|
|
42
|
+
status: PushStatus;
|
|
43
|
+
idempotencyKey: string;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
external?: { id?: string; url?: string; rawRef?: string };
|
|
46
|
+
confirmedByEventId?: string;
|
|
47
|
+
error?: string;
|
|
48
|
+
data?: Record<string, unknown>;
|
|
49
|
+
/** Request-scoped correlation id (D3), inherited from the action being pushed —
|
|
50
|
+
* every phase row for one push carries the SAME id as the action row it drives,
|
|
51
|
+
* so an action row and its push-ledger rows join on this id alone (no reliance
|
|
52
|
+
* on actionId, which is a different, id-shaped field with its own naming
|
|
53
|
+
* convention). Falls back to a fresh id only if the action predates D3. */
|
|
54
|
+
correlationId?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type PushOutcome = { externalId: string; url?: string; data?: Record<string, unknown> };
|
|
58
|
+
|
|
59
|
+
function ledgerPath(service: string, root?: string): string {
|
|
60
|
+
return join(worldPaths(service, root).dir, 'push-ledger.jsonl');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Lock guarding the push ledger's read-then-append critical sections (TWIN-56):
|
|
64
|
+
* the same file-lock primitive events/actions use, so two callers racing to push
|
|
65
|
+
* the same action can't both read "no prior attempt" and both append duplicate
|
|
66
|
+
* attempt rows. */
|
|
67
|
+
function ledgerLockPath(service: string, root?: string): string {
|
|
68
|
+
return `${ledgerPath(service, root)}.lock`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function appendPushRecord(record: WorldPushRecord, root?: string): WorldPushRecord {
|
|
72
|
+
const path = ledgerPath(record.service, root);
|
|
73
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
74
|
+
appendDurable(path, `${JSON.stringify(record)}\n`);
|
|
75
|
+
twinLog('push.append', { service: record.service, id: record.id, actionId: record.actionId, status: record.status, correlationId: record.correlationId });
|
|
76
|
+
return record;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function listPushLedger(service: string, root?: string): WorldPushRecord[] {
|
|
80
|
+
const path = ledgerPath(service, root);
|
|
81
|
+
if (!existsSync(path)) return [];
|
|
82
|
+
return readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l) as WorldPushRecord);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Latest phase per push id (append order is the tiebreak). */
|
|
86
|
+
export function latestPushByActionId(service: string, root?: string): Map<string, WorldPushRecord> {
|
|
87
|
+
const latest = new Map<string, WorldPushRecord>();
|
|
88
|
+
for (const r of listPushLedger(service, root)) latest.set(r.actionId, r); // later rows overwrite
|
|
89
|
+
return latest;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Pushes that returned provider_accepted but have not yet been observed_confirmed. */
|
|
93
|
+
export function unconfirmedPushes(service: string, root?: string): WorldPushRecord[] {
|
|
94
|
+
return [...latestPushByActionId(service, root).values()].filter((r) => r.status === 'provider_accepted' || r.status === 'attempted');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const TERMINAL: PushStatus[] = ['confirmed', 'projection_suppressed', 'succeeded'];
|
|
98
|
+
|
|
99
|
+
export class UnreconciledPushError extends Error {
|
|
100
|
+
readonly pushId: string;
|
|
101
|
+
|
|
102
|
+
constructor(pushId: string, message: string) {
|
|
103
|
+
super(message);
|
|
104
|
+
this.name = 'UnreconciledPushError';
|
|
105
|
+
this.pushId = pushId;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Drive one local transaction commit through the push phases against the real
|
|
111
|
+
* provider via the injected writeFn (the ONLY real I/O — kernel holds no creds).
|
|
112
|
+
* On success it confirms the action into an observed event (confirmAction), which
|
|
113
|
+
* suppresses the local projection so state isn't double-counted. Idempotent: a
|
|
114
|
+
* previously-confirmed push for the same id replays without calling writeFn.
|
|
115
|
+
*
|
|
116
|
+
* Crash parity with egress.ts (performExternalWrite): the "is there a prior row,
|
|
117
|
+
* and is it terminal / unreconciled" read plus the fresh intent+attempted append
|
|
118
|
+
* run under ONE file-lock acquisition (TWIN-56), so two callers racing to push the
|
|
119
|
+
* same action id can't both observe "no attempt yet" and both append attempt rows.
|
|
120
|
+
* By default (onUnreconciled: 'fail') a latest row of `attempted` with nothing
|
|
121
|
+
* after it — a crash mid-flight, or a racing caller that lost the lock — refuses
|
|
122
|
+
* with UnreconciledPushError instead of blindly re-invoking writeFn. Pass
|
|
123
|
+
* onUnreconciled: 'retry' only after verifying external state.
|
|
124
|
+
*/
|
|
125
|
+
export async function pushTransaction(
|
|
126
|
+
opts: {
|
|
127
|
+
service: string;
|
|
128
|
+
action: { id: string; subject: { type: string; id: string }; fields: SubjectFields; correlationId?: string };
|
|
129
|
+
provider: string;
|
|
130
|
+
operation: string;
|
|
131
|
+
idempotencyKey: string;
|
|
132
|
+
occurredAt: string;
|
|
133
|
+
root?: string;
|
|
134
|
+
onUnreconciled?: 'fail' | 'retry';
|
|
135
|
+
},
|
|
136
|
+
writeFn: () => Promise<PushOutcome>,
|
|
137
|
+
): Promise<WorldPushRecord> {
|
|
138
|
+
const { service, action, provider, operation, idempotencyKey, occurredAt, root, onUnreconciled = 'fail' } = opts;
|
|
139
|
+
const pushId = `push:${service}:${action.id}`;
|
|
140
|
+
// Inherit the action's correlationId (D3) so every phase row this push writes joins
|
|
141
|
+
// back to the action row on that id alone. Actions appended after D3 always carry
|
|
142
|
+
// one (appendAction/appendActionIfAbsent stamp it); the fallback only covers an
|
|
143
|
+
// action object built by hand without going through those (e.g. an older ledger).
|
|
144
|
+
const correlationId = action.correlationId ?? randomUUID();
|
|
145
|
+
const base = { id: pushId, service, actionId: action.id, provider, operation, idempotencyKey, createdAt: occurredAt, correlationId };
|
|
146
|
+
|
|
147
|
+
// Read (prior status) + decide (terminal replay / unreconciled refusal) + the
|
|
148
|
+
// fresh intent/attempted append, all under one lock — the atomic critical
|
|
149
|
+
// section a cross-process race would otherwise be able to interleave (TWIN-56).
|
|
150
|
+
const early = withFileLock(ledgerLockPath(service, root), (): WorldPushRecord | null => {
|
|
151
|
+
const prior = latestPushByActionId(service, root).get(action.id);
|
|
152
|
+
if (prior && TERMINAL.includes(prior.status)) return prior; // idempotent replay
|
|
153
|
+
if (prior && prior.status === 'attempted' && onUnreconciled === 'fail') {
|
|
154
|
+
throw new UnreconciledPushError(
|
|
155
|
+
pushId,
|
|
156
|
+
`Push ${pushId} has an 'attempted' row with no result after it — the provider call may or may not have happened. ` +
|
|
157
|
+
`Verify external state, then retry with onUnreconciled: 'retry'.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
appendPushRecord({ ...base, status: 'intent' }, root);
|
|
161
|
+
appendPushRecord({ ...base, status: 'attempted' }, root);
|
|
162
|
+
return null;
|
|
163
|
+
});
|
|
164
|
+
if (early) return early;
|
|
165
|
+
|
|
166
|
+
let outcome: PushOutcome;
|
|
167
|
+
try {
|
|
168
|
+
outcome = await writeFn();
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return appendPushRecord({ ...base, status: 'failed', error: error instanceof Error ? error.message : String(error) }, root);
|
|
171
|
+
}
|
|
172
|
+
appendPushRecord({ ...base, status: 'provider_accepted', external: { id: outcome.externalId, ...(outcome.url ? { url: outcome.url } : {}) }, ...(outcome.data ? { data: outcome.data } : {}) }, root);
|
|
173
|
+
|
|
174
|
+
// Confirm: append the observed provider fact + map it back, suppressing the local projection.
|
|
175
|
+
const { observedEventId } = confirmAction({ service, actionId: action.id, subject: action.subject, fields: action.fields, occurredAt, root });
|
|
176
|
+
appendPushRecord({ ...base, status: 'observed_confirmed', external: { id: outcome.externalId }, confirmedByEventId: observedEventId }, root);
|
|
177
|
+
appendPushRecord({ ...base, status: 'projection_suppressed', confirmedByEventId: observedEventId }, root);
|
|
178
|
+
return appendPushRecord({ ...base, status: 'confirmed', external: { id: outcome.externalId }, confirmedByEventId: observedEventId }, root);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Mark a push intent abandoned (e.g. operator decided not to push). */
|
|
182
|
+
export function abandonPush(service: string, actionId: string, opts: { root?: string; at?: string; reason?: string } = {}): WorldPushRecord {
|
|
183
|
+
const prior = latestPushByActionId(service, opts.root).get(actionId);
|
|
184
|
+
return appendPushRecord({
|
|
185
|
+
id: `push:${service}:${actionId}`, service, actionId, provider: prior?.provider ?? '', operation: prior?.operation ?? '',
|
|
186
|
+
idempotencyKey: prior?.idempotencyKey ?? `abandon:${actionId}`, createdAt: opts.at ?? new Date().toISOString(),
|
|
187
|
+
status: 'abandoned', ...(opts.reason ? { error: opts.reason } : {}),
|
|
188
|
+
}, opts.root);
|
|
189
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Event queue lifecycle (the twins architecture notes → "Event Intake Queue").
|
|
2
|
+
// Listener/webhook/broker deliveries land in the durable queue (events stay
|
|
3
|
+
// NON-canonical until committed). This module gives queue rows the named
|
|
4
|
+
// lifecycle the doc specifies — queued | committed | ignored | superseded |
|
|
5
|
+
// poisoned — as an append-only status ledger over the existing queue, so the
|
|
6
|
+
// queue file itself stays immutable delivery history. Current status = the latest
|
|
7
|
+
// transition (default `queued`). Deterministic: caller may supply `at`.
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { appendDurable, appendEvent, listQueuedEvents, rebuildGenericState, worldPaths } from './storage.ts';
|
|
11
|
+
import type { AppendEventResult, QueuedWorldServiceEvent } from './types.ts';
|
|
12
|
+
|
|
13
|
+
export type QueueRowStatus = 'queued' | 'committed' | 'ignored' | 'superseded' | 'poisoned';
|
|
14
|
+
|
|
15
|
+
export type QueueStatusTransition = {
|
|
16
|
+
queueId: string;
|
|
17
|
+
status: QueueRowStatus;
|
|
18
|
+
at: string;
|
|
19
|
+
reason?: string;
|
|
20
|
+
/** for committed rows: the canonical event id it produced. */
|
|
21
|
+
eventId?: string;
|
|
22
|
+
/** for superseded rows: the row that replaced it. */
|
|
23
|
+
supersededBy?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type QueueRowWithStatus = {
|
|
27
|
+
row: QueuedWorldServiceEvent;
|
|
28
|
+
status: QueueRowStatus;
|
|
29
|
+
transition?: QueueStatusTransition;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function statusPath(service: string, root?: string): string {
|
|
33
|
+
return join(worldPaths(service, root).dir, 'event-queue-status.jsonl');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readTransitions(service: string, root?: string): QueueStatusTransition[] {
|
|
37
|
+
const path = statusPath(service, root);
|
|
38
|
+
if (!existsSync(path)) return [];
|
|
39
|
+
return readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l) as QueueStatusTransition);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Latest transition per queue row (append order is the tiebreak — last wins). */
|
|
43
|
+
function latestByRow(service: string, root?: string): Map<string, QueueStatusTransition> {
|
|
44
|
+
const latest = new Map<string, QueueStatusTransition>();
|
|
45
|
+
for (const t of readTransitions(service, root)) latest.set(t.queueId, t); // later lines overwrite
|
|
46
|
+
return latest;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function setQueueRowStatus(
|
|
50
|
+
service: string,
|
|
51
|
+
queueId: string,
|
|
52
|
+
status: QueueRowStatus,
|
|
53
|
+
opts: { root?: string; at?: string; reason?: string; eventId?: string; supersededBy?: string } = {},
|
|
54
|
+
): QueueStatusTransition {
|
|
55
|
+
const path = statusPath(service, opts.root);
|
|
56
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
57
|
+
const transition: QueueStatusTransition = {
|
|
58
|
+
queueId,
|
|
59
|
+
status,
|
|
60
|
+
at: opts.at ?? new Date().toISOString(),
|
|
61
|
+
...(opts.reason ? { reason: opts.reason } : {}),
|
|
62
|
+
...(opts.eventId ? { eventId: opts.eventId } : {}),
|
|
63
|
+
...(opts.supersededBy ? { supersededBy: opts.supersededBy } : {}),
|
|
64
|
+
};
|
|
65
|
+
appendDurable(path, `${JSON.stringify(transition)}\n`);
|
|
66
|
+
return transition;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function queueRowStatus(service: string, queueId: string, root?: string): QueueRowStatus {
|
|
70
|
+
return latestByRow(service, root).get(queueId)?.status ?? 'queued';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every queue row with its current lifecycle status. */
|
|
74
|
+
export function listQueueWithStatus(service: string, root?: string): QueueRowWithStatus[] {
|
|
75
|
+
const latest = latestByRow(service, root);
|
|
76
|
+
return listQueuedEvents(service, root).map((row) => {
|
|
77
|
+
const transition = latest.get(row.id);
|
|
78
|
+
return { row, status: transition?.status ?? 'queued', ...(transition ? { transition } : {}) };
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Rows still awaiting a decision (status === 'queued'). */
|
|
83
|
+
export function pendingQueueRows(service: string, root?: string): QueuedWorldServiceEvent[] {
|
|
84
|
+
return listQueueWithStatus(service, root).filter((r) => r.status === 'queued').map((r) => r.row);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type QueueCounts = Record<QueueRowStatus, number>;
|
|
88
|
+
export function queueCounts(service: string, root?: string): QueueCounts {
|
|
89
|
+
const counts: QueueCounts = { queued: 0, committed: 0, ignored: 0, superseded: 0, poisoned: 0 };
|
|
90
|
+
for (const r of listQueueWithStatus(service, root)) counts[r.status] += 1;
|
|
91
|
+
return counts;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Commit a single queued row into the canonical event log + stamp `committed`. */
|
|
95
|
+
export function commitQueueRow(
|
|
96
|
+
service: string,
|
|
97
|
+
queueId: string,
|
|
98
|
+
opts: { root?: string; at?: string } = {},
|
|
99
|
+
): { appended: boolean; eventId?: string } {
|
|
100
|
+
const status = queueRowStatus(service, queueId, opts.root);
|
|
101
|
+
if (status !== 'queued') throw new Error(`queue row ${queueId} is ${status}, not committable`);
|
|
102
|
+
const row = listQueuedEvents(service, opts.root).find((r) => r.id === queueId);
|
|
103
|
+
if (!row) throw new Error(`queue row not found: ${queueId}`);
|
|
104
|
+
const result: AppendEventResult = appendEvent(row.event, opts.root);
|
|
105
|
+
rebuildGenericState(service, opts.root);
|
|
106
|
+
setQueueRowStatus(service, queueId, 'committed', { ...opts, eventId: result.event.id });
|
|
107
|
+
return { appended: result.appended, eventId: result.event.id };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function ignoreQueueRow(service: string, queueId: string, opts: { root?: string; at?: string; reason?: string } = {}): QueueStatusTransition {
|
|
111
|
+
return setQueueRowStatus(service, queueId, 'ignored', opts);
|
|
112
|
+
}
|
|
113
|
+
export function supersedeQueueRow(service: string, queueId: string, supersededBy: string, opts: { root?: string; at?: string; reason?: string } = {}): QueueStatusTransition {
|
|
114
|
+
return setQueueRowStatus(service, queueId, 'superseded', { ...opts, supersededBy });
|
|
115
|
+
}
|
|
116
|
+
export function poisonQueueRow(service: string, queueId: string, opts: { root?: string; at?: string; reason?: string } = {}): QueueStatusTransition {
|
|
117
|
+
return setQueueRowStatus(service, queueId, 'poisoned', opts);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Commit all rows currently `queued`, in deterministic provider-time order; stamps committed. */
|
|
121
|
+
export function commitPendingQueue(service: string, opts: { root?: string; at?: string } = {}): { committed: number; eventIds: string[] } {
|
|
122
|
+
const pending = pendingQueueRows(service, opts.root)
|
|
123
|
+
.sort((a, b) => a.event.occurredAt.localeCompare(b.event.occurredAt) || a.receivedAt.localeCompare(b.receivedAt) || a.id.localeCompare(b.id));
|
|
124
|
+
const eventIds: string[] = [];
|
|
125
|
+
for (const row of pending) {
|
|
126
|
+
const { eventId } = commitQueueRow(service, row.id, opts);
|
|
127
|
+
if (eventId) eventIds.push(eventId);
|
|
128
|
+
}
|
|
129
|
+
return { committed: eventIds.length, eventIds };
|
|
130
|
+
}
|