@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/dist/src/plan.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Apply plan (the twins architecture notes → "Plan"). The Terraform-plan /
|
|
2
|
+
// pull-request object for non-code systems: a reviewable, replayable proposal of
|
|
3
|
+
// exactly what a push/apply will do — which local transactions, the provider
|
|
4
|
+
// calls + idempotency keys, destructive flags, expected confirmations, conflicts
|
|
5
|
+
// (failed preconditions), and whether approval is required. No real push happens
|
|
6
|
+
// from an implicit action list; it executes a plan. `applyPlan` is the gated
|
|
7
|
+
// orchestrator: recompute requiresApproval from the plan's own data (never trust
|
|
8
|
+
// the stored bit) → refuse a stale base → acquire lease → push each call through
|
|
9
|
+
// phases → release.
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { pendingActions, projectResources } from "./actions.js";
|
|
13
|
+
import { isBaseStale } from "./refs.js";
|
|
14
|
+
import { acquireLease, releaseLease } from "./lease.js";
|
|
15
|
+
import { pushTransaction } from "./pushLedger.js";
|
|
16
|
+
import { readJsonFile, worldPaths } from "./storage.js";
|
|
17
|
+
function resourcesById(service, root) {
|
|
18
|
+
return new Map(projectResources(service, root).map((r) => [`${r.type}:${r.id}`, r]));
|
|
19
|
+
}
|
|
20
|
+
function preconditionFailure(action, byId) {
|
|
21
|
+
for (const p of action.preconditions ?? []) {
|
|
22
|
+
const resource = byId.get(`${p.subject.type}:${p.subject.id}`);
|
|
23
|
+
const actual = resource ? resource[p.field] : undefined;
|
|
24
|
+
const ok = evalPrecondition(p, actual);
|
|
25
|
+
if (!ok)
|
|
26
|
+
return `${p.subject.type}:${p.subject.id}.${p.field} ${p.op}${p.value !== undefined ? ` ${JSON.stringify(p.value)}` : ''}`;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function evalPrecondition(p, actual) {
|
|
31
|
+
switch (p.op) {
|
|
32
|
+
case 'exists': return actual !== undefined;
|
|
33
|
+
case 'not_exists': return actual === undefined;
|
|
34
|
+
case 'eq':
|
|
35
|
+
case 'version_eq': return Object.is(actual, p.value);
|
|
36
|
+
case 'neq': return !Object.is(actual, p.value);
|
|
37
|
+
default: return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Pending transactions whose preconditions would fail against current projected state. */
|
|
41
|
+
export function pendingConflicts(service, root) {
|
|
42
|
+
const byId = resourcesById(service, root);
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const action of pendingActions(service, root)) {
|
|
45
|
+
const failure = preconditionFailure(action, byId);
|
|
46
|
+
if (failure)
|
|
47
|
+
out.push({ actionId: action.id, reason: `precondition would fail: ${failure}` });
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build a reviewable apply plan from a fork's pending transactions. `mapper` is the
|
|
53
|
+
* vendor-specific action→provider-call mapping; actions it returns null for are
|
|
54
|
+
* omitted. Conflicts are pending actions whose preconditions fail against current
|
|
55
|
+
* projected state. Approval is required if any call is destructive or any conflict exists.
|
|
56
|
+
*/
|
|
57
|
+
export function buildApplyPlan(opts) {
|
|
58
|
+
const pending = pendingActions(opts.service, opts.root);
|
|
59
|
+
const byId = resourcesById(opts.service, opts.root);
|
|
60
|
+
const providerCalls = [];
|
|
61
|
+
const conflicts = [];
|
|
62
|
+
for (const action of pending) {
|
|
63
|
+
const failure = preconditionFailure(action, byId);
|
|
64
|
+
if (failure) {
|
|
65
|
+
conflicts.push({ actionId: action.id, reason: `precondition would fail: ${failure}` });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const mapped = opts.mapper(action);
|
|
69
|
+
if (mapped)
|
|
70
|
+
providerCalls.push({ actionId: action.id, ...mapped });
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
id: opts.id, service: opts.service, forkId: opts.forkId, baseRemoteRef: opts.baseRemoteRef,
|
|
74
|
+
transactions: pending.map((a) => a.id), providerCalls, conflicts,
|
|
75
|
+
requiresApproval: conflicts.length > 0 || providerCalls.some((c) => c.destructive),
|
|
76
|
+
createdAt: opts.createdAt,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function plansDir(service, root) {
|
|
80
|
+
return join(worldPaths(service, root).dir, 'plans');
|
|
81
|
+
}
|
|
82
|
+
export function writePlan(plan, root) {
|
|
83
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(plan.id))
|
|
84
|
+
throw new Error(`invalid plan id: ${plan.id}`);
|
|
85
|
+
const path = join(plansDir(plan.service, root), `${plan.id}.json`);
|
|
86
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
87
|
+
writeFileSync(path, `${JSON.stringify(plan, null, 2)}\n`);
|
|
88
|
+
return plan;
|
|
89
|
+
}
|
|
90
|
+
export function readPlan(service, id, root) {
|
|
91
|
+
const path = join(plansDir(service, root), `${id}.json`);
|
|
92
|
+
return existsSync(path) ? readJsonFile(path) : null;
|
|
93
|
+
}
|
|
94
|
+
export function listPlans(service, root) {
|
|
95
|
+
const dir = plansDir(service, root);
|
|
96
|
+
if (!existsSync(dir))
|
|
97
|
+
return [];
|
|
98
|
+
return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => readJsonFile(join(dir, f)));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether a plan requires approval before it may be applied — recomputed from the
|
|
102
|
+
* plan's OWN `providerCalls`/`conflicts`, the exact rule `buildApplyPlan` uses to set
|
|
103
|
+
* the field in the first place. `applyPlan` calls this instead of trusting
|
|
104
|
+
* `plan.requiresApproval`: that field lives on plan JSON that is neither signed nor
|
|
105
|
+
* otherwise authenticated, so a hand-edited file (or a hand-built plan object) that
|
|
106
|
+
* flips the boolean to `false` must not be able to skip the approval gate — only
|
|
107
|
+
* removing the destructive calls/conflicts themselves does.
|
|
108
|
+
*/
|
|
109
|
+
export function planRequiresApproval(plan) {
|
|
110
|
+
return plan.conflicts.length > 0 || plan.providerCalls.some((c) => c.destructive);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Enact a plan against the real provider — the gated push path. Refuses an
|
|
114
|
+
* unapproved plan (approval requirement recomputed from the plan's own data, never
|
|
115
|
+
* trusted off the stored bit) and a push whose base has gone stale (the live
|
|
116
|
+
* remote ref moved since `plan.baseRemoteRef` was recorded) before any provider
|
|
117
|
+
* call or lease acquisition — zero side effects either way. Then acquires an apply
|
|
118
|
+
* lease for the plan's baseRemoteRef (single-writer), drives every provider call
|
|
119
|
+
* through the push phases via the injected writeFn (the only real I/O), then
|
|
120
|
+
* releases the lease.
|
|
121
|
+
*/
|
|
122
|
+
export async function applyPlan(plan, writeFn, opts) {
|
|
123
|
+
if (planRequiresApproval(plan) && !opts.approve)
|
|
124
|
+
throw new Error(`plan ${plan.id} requires approval (conflicts or destructive calls); pass approve:true to apply`);
|
|
125
|
+
const localRefView = { service: plan.service, forkId: plan.forkId, baseRemoteRef: plan.baseRemoteRef, recordedAt: plan.createdAt };
|
|
126
|
+
if (isBaseStale(localRefView, opts.root) && !opts.overrideStaleBase) {
|
|
127
|
+
throw new Error(`plan ${plan.id} base (${plan.baseRemoteRef.provider}/${plan.baseRemoteRef.name}) is stale — the remote ref advanced since this plan's base was recorded; rebase and rebuild the plan, or pass overrideStaleBase:true to apply anyway`);
|
|
128
|
+
}
|
|
129
|
+
const lease = acquireLease({
|
|
130
|
+
service: plan.service, provider: plan.baseRemoteRef.provider, remoteRef: plan.baseRemoteRef, planId: plan.id,
|
|
131
|
+
holder: opts.holder, id: opts.leaseId, acquiredAt: opts.acquiredAt, expiresAt: opts.expiresAt, now: opts.acquiredAt, root: opts.root,
|
|
132
|
+
});
|
|
133
|
+
const pushed = [];
|
|
134
|
+
const skipped = [];
|
|
135
|
+
const actionsById = new Map(pendingActions(plan.service, opts.root).map((a) => [a.id, a]));
|
|
136
|
+
try {
|
|
137
|
+
for (const call of plan.providerCalls) {
|
|
138
|
+
const action = actionsById.get(call.actionId);
|
|
139
|
+
if (!action) {
|
|
140
|
+
skipped.push(call.actionId);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const record = await pushTransaction({ service: plan.service, action: { id: action.id, subject: action.subject, fields: action.fields ?? {}, correlationId: action.correlationId }, provider: call.provider, operation: call.operation, idempotencyKey: call.idempotencyKey, occurredAt: opts.occurredAt, root: opts.root }, () => writeFn(call));
|
|
144
|
+
pushed.push(record);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
releaseLease(plan.service, lease.id, { at: opts.occurredAt, root: opts.root });
|
|
149
|
+
}
|
|
150
|
+
return { planId: plan.id, leaseId: lease.id, pushed, skipped };
|
|
151
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** How one vendor's browser SDK is routed to its twin. Supplied by the caller. */
|
|
2
|
+
export interface VendorRoute {
|
|
3
|
+
/** The vendor's twin origin, e.g. http://127.0.0.1:12111 */
|
|
4
|
+
origin: string;
|
|
5
|
+
/** Same-origin browser API path that belongs to this vendor, e.g. '/v1/' */
|
|
6
|
+
apiPathPrefix: string;
|
|
7
|
+
/** Absolute API host to strip from the vendor's browser loader so calls become
|
|
8
|
+
* same-origin (e.g. 'https://api.stripe.com'). Omit if the SDK already calls same-origin. */
|
|
9
|
+
loaderHost?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface TwinProxyOptions {
|
|
12
|
+
/** Your app's origin, e.g. http://localhost:3000 */
|
|
13
|
+
target: string;
|
|
14
|
+
/** vendor → routing. */
|
|
15
|
+
map: Record<string, VendorRoute>;
|
|
16
|
+
/** Extra local service origins whose redirects should be rewritten back to this proxy origin. */
|
|
17
|
+
redirectOrigins?: string[];
|
|
18
|
+
/** Listen port (0 = ephemeral). */
|
|
19
|
+
port?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface TwinProxy {
|
|
22
|
+
port: number;
|
|
23
|
+
stop(): void;
|
|
24
|
+
}
|
|
25
|
+
export declare function createTwinProxy(opts: TwinProxyOptions): TwinProxy;
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
function vendorForPath(path, map) {
|
|
18
|
+
for (const [vendor, route] of Object.entries(map)) {
|
|
19
|
+
if (path.startsWith(route.apiPathPrefix))
|
|
20
|
+
return vendor;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
function escapeRegExp(value) {
|
|
25
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Strip `loaderHost` (a full origin, e.g. 'https://api.stripe.com') from `text` so its
|
|
29
|
+
* calls become same-origin — but ONLY where it appears as a complete host, not as a
|
|
30
|
+
* blind substring. A plain `split(loaderHost).join('')` (the prior implementation) is
|
|
31
|
+
* NOT position-aware: it removes the substring wherever it occurs, including as a
|
|
32
|
+
* PREFIX of a longer, different host — e.g. 'https://api.stripe.com.evil.example/x'
|
|
33
|
+
* contains 'https://api.stripe.com' as a literal substring, and a naive strip corrupts
|
|
34
|
+
* it into '.evil.example/x' (the true host is stripe.com.evil.example, not stripe.com;
|
|
35
|
+
* same substring bug would also mangle a subdomain like sandbox.api.stripe.com if the
|
|
36
|
+
* scheme weren't part of the match). Position-aware fix: only strip a match that is NOT
|
|
37
|
+
* immediately followed by a domain-continuing character (letter/digit/dot/hyphen) —
|
|
38
|
+
* that character means the matched text is a prefix of a longer host, not the whole
|
|
39
|
+
* host, so it is left untouched.
|
|
40
|
+
*/
|
|
41
|
+
function stripLoaderHost(text, loaderHost) {
|
|
42
|
+
const pattern = new RegExp(`${escapeRegExp(loaderHost)}(?![A-Za-z0-9.-])`, 'g');
|
|
43
|
+
return text.replace(pattern, '');
|
|
44
|
+
}
|
|
45
|
+
function rewriteLocation(location, req, origins) {
|
|
46
|
+
if (!location || Array.isArray(location))
|
|
47
|
+
return location;
|
|
48
|
+
const host = req.headers.host;
|
|
49
|
+
if (!host)
|
|
50
|
+
return location;
|
|
51
|
+
const proxyOrigin = `http://${host}`;
|
|
52
|
+
for (const origin of origins) {
|
|
53
|
+
const normalized = origin.replace(/\/$/, '');
|
|
54
|
+
if (location === normalized)
|
|
55
|
+
return proxyOrigin;
|
|
56
|
+
if (location.startsWith(`${normalized}/`))
|
|
57
|
+
return `${proxyOrigin}${location.slice(normalized.length)}`;
|
|
58
|
+
}
|
|
59
|
+
return location;
|
|
60
|
+
}
|
|
61
|
+
function forwardTo(origin, req, res, rewrite, redirectOrigins = []) {
|
|
62
|
+
const target = new URL(req.url || '/', origin);
|
|
63
|
+
const headers = { ...req.headers, host: target.host };
|
|
64
|
+
delete headers['accept-encoding']; // so we can rewrite uncompressed bodies
|
|
65
|
+
const upstream = http.request(target, { method: req.method, headers }, (up) => {
|
|
66
|
+
if ((up.statusCode ?? 0) >= 300 && (up.statusCode ?? 0) < 400) {
|
|
67
|
+
const responseHeaders = { ...up.headers };
|
|
68
|
+
// `location` is single-valued, so rewriteLocation returns string|undefined here (never an array).
|
|
69
|
+
responseHeaders.location = rewriteLocation(responseHeaders.location, req, [origin, ...redirectOrigins]);
|
|
70
|
+
delete responseHeaders['transfer-encoding'];
|
|
71
|
+
delete responseHeaders['content-length'];
|
|
72
|
+
res.writeHead(up.statusCode || 302, responseHeaders);
|
|
73
|
+
res.end();
|
|
74
|
+
up.resume();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const chunks = [];
|
|
78
|
+
up.on('data', (c) => chunks.push(c));
|
|
79
|
+
up.on('error', () => res.destroyed || res.end());
|
|
80
|
+
up.on('end', () => {
|
|
81
|
+
const body = Buffer.concat(chunks);
|
|
82
|
+
const out = rewrite ? rewrite(body, up.headers) : body;
|
|
83
|
+
const responseHeaders = { ...up.headers };
|
|
84
|
+
delete responseHeaders['content-length'];
|
|
85
|
+
delete responseHeaders['content-encoding'];
|
|
86
|
+
delete responseHeaders['transfer-encoding'];
|
|
87
|
+
responseHeaders['content-length'] = String(Buffer.byteLength(out));
|
|
88
|
+
res.writeHead(up.statusCode || 502, responseHeaders);
|
|
89
|
+
res.end(out);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
upstream.on('error', (error) => {
|
|
93
|
+
if (res.headersSent) {
|
|
94
|
+
res.destroy(error);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
res.writeHead(502, { 'content-type': 'text/plain' });
|
|
98
|
+
res.end(`twin proxy upstream error: ${error.message}`);
|
|
99
|
+
});
|
|
100
|
+
req.pipe(upstream);
|
|
101
|
+
}
|
|
102
|
+
export function createTwinProxy(opts) {
|
|
103
|
+
const map = {};
|
|
104
|
+
for (const [vendor, route] of Object.entries(opts.map))
|
|
105
|
+
map[vendor] = { ...route, origin: route.origin.replace(/\/$/, '') };
|
|
106
|
+
const redirectOrigins = (opts.redirectOrigins ?? []).map((origin) => origin.replace(/\/$/, ''));
|
|
107
|
+
const server = http.createServer((req, res) => {
|
|
108
|
+
const path = req.url || '/';
|
|
109
|
+
const vendor = vendorForPath(path, map);
|
|
110
|
+
if (vendor) {
|
|
111
|
+
forwardTo(map[vendor].origin, req, res, undefined, redirectOrigins); // browser vendor API call → the twin
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Otherwise pass through to the app, rewriting vendor loaders so their calls come back here.
|
|
115
|
+
forwardTo(opts.target, req, res, (body, headers) => {
|
|
116
|
+
const contentType = String(headers['content-type'] || '');
|
|
117
|
+
if (!contentType.includes('javascript') && !contentType.includes('html'))
|
|
118
|
+
return body;
|
|
119
|
+
let text = body.toString('utf8');
|
|
120
|
+
for (const route of Object.values(map)) {
|
|
121
|
+
if (route.loaderHost)
|
|
122
|
+
text = stripLoaderHost(text, route.loaderHost);
|
|
123
|
+
}
|
|
124
|
+
return Buffer.from(text, 'utf8');
|
|
125
|
+
}, redirectOrigins);
|
|
126
|
+
});
|
|
127
|
+
// WebSocket / HTTP upgrade passthrough to the app.
|
|
128
|
+
server.on('upgrade', (req, socket, head) => {
|
|
129
|
+
const target = new URL(req.url || '/', opts.target);
|
|
130
|
+
const upstream = net.connect(Number(target.port || 80), target.hostname, () => {
|
|
131
|
+
upstream.write(`${req.method} ${target.pathname}${target.search} HTTP/${req.httpVersion}\r\n`);
|
|
132
|
+
for (const [name, value] of Object.entries(req.headers))
|
|
133
|
+
upstream.write(`${name}: ${value}\r\n`);
|
|
134
|
+
upstream.write('\r\n');
|
|
135
|
+
upstream.write(head);
|
|
136
|
+
upstream.pipe(socket);
|
|
137
|
+
socket.pipe(upstream);
|
|
138
|
+
});
|
|
139
|
+
const killSocket = () => { if (!socket.destroyed)
|
|
140
|
+
socket.destroy(); };
|
|
141
|
+
const killUpstream = () => { if (!upstream.destroyed)
|
|
142
|
+
upstream.destroy(); };
|
|
143
|
+
upstream.on('error', killSocket);
|
|
144
|
+
socket.on('error', killUpstream);
|
|
145
|
+
socket.on('close', killUpstream);
|
|
146
|
+
upstream.on('close', killSocket);
|
|
147
|
+
});
|
|
148
|
+
server.listen(opts.port ?? 0, '127.0.0.1');
|
|
149
|
+
const address = server.address();
|
|
150
|
+
const port = address && typeof address === 'object' ? address.port : (opts.port ?? 0);
|
|
151
|
+
return { port, stop: () => server.close() };
|
|
152
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { SubjectFields } from './shadow.js';
|
|
2
|
+
export type PushStatus = 'intent' | 'attempted' | 'provider_accepted' | 'succeeded' | 'failed' | 'observed_confirmed' | 'projection_suppressed' | 'confirmed' | 'abandoned';
|
|
3
|
+
export type WorldPushRecord = {
|
|
4
|
+
id: string;
|
|
5
|
+
service: string;
|
|
6
|
+
actionId: string;
|
|
7
|
+
provider: string;
|
|
8
|
+
operation: string;
|
|
9
|
+
status: PushStatus;
|
|
10
|
+
idempotencyKey: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
external?: {
|
|
13
|
+
id?: string;
|
|
14
|
+
url?: string;
|
|
15
|
+
rawRef?: string;
|
|
16
|
+
};
|
|
17
|
+
confirmedByEventId?: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
data?: Record<string, unknown>;
|
|
20
|
+
/** Request-scoped correlation id (D3), inherited from the action being pushed —
|
|
21
|
+
* every phase row for one push carries the SAME id as the action row it drives,
|
|
22
|
+
* so an action row and its push-ledger rows join on this id alone (no reliance
|
|
23
|
+
* on actionId, which is a different, id-shaped field with its own naming
|
|
24
|
+
* convention). Falls back to a fresh id only if the action predates D3. */
|
|
25
|
+
correlationId?: string;
|
|
26
|
+
};
|
|
27
|
+
export type PushOutcome = {
|
|
28
|
+
externalId: string;
|
|
29
|
+
url?: string;
|
|
30
|
+
data?: Record<string, unknown>;
|
|
31
|
+
};
|
|
32
|
+
export declare function appendPushRecord(record: WorldPushRecord, root?: string): WorldPushRecord;
|
|
33
|
+
export declare function listPushLedger(service: string, root?: string): WorldPushRecord[];
|
|
34
|
+
/** Latest phase per push id (append order is the tiebreak). */
|
|
35
|
+
export declare function latestPushByActionId(service: string, root?: string): Map<string, WorldPushRecord>;
|
|
36
|
+
/** Pushes that returned provider_accepted but have not yet been observed_confirmed. */
|
|
37
|
+
export declare function unconfirmedPushes(service: string, root?: string): WorldPushRecord[];
|
|
38
|
+
export declare class UnreconciledPushError extends Error {
|
|
39
|
+
readonly pushId: string;
|
|
40
|
+
constructor(pushId: string, message: string);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Drive one local transaction commit through the push phases against the real
|
|
44
|
+
* provider via the injected writeFn (the ONLY real I/O — kernel holds no creds).
|
|
45
|
+
* On success it confirms the action into an observed event (confirmAction), which
|
|
46
|
+
* suppresses the local projection so state isn't double-counted. Idempotent: a
|
|
47
|
+
* previously-confirmed push for the same id replays without calling writeFn.
|
|
48
|
+
*
|
|
49
|
+
* Crash parity with egress.ts (performExternalWrite): the "is there a prior row,
|
|
50
|
+
* and is it terminal / unreconciled" read plus the fresh intent+attempted append
|
|
51
|
+
* run under ONE file-lock acquisition (TWIN-56), so two callers racing to push the
|
|
52
|
+
* same action id can't both observe "no attempt yet" and both append attempt rows.
|
|
53
|
+
* By default (onUnreconciled: 'fail') a latest row of `attempted` with nothing
|
|
54
|
+
* after it — a crash mid-flight, or a racing caller that lost the lock — refuses
|
|
55
|
+
* with UnreconciledPushError instead of blindly re-invoking writeFn. Pass
|
|
56
|
+
* onUnreconciled: 'retry' only after verifying external state.
|
|
57
|
+
*/
|
|
58
|
+
export declare function pushTransaction(opts: {
|
|
59
|
+
service: string;
|
|
60
|
+
action: {
|
|
61
|
+
id: string;
|
|
62
|
+
subject: {
|
|
63
|
+
type: string;
|
|
64
|
+
id: string;
|
|
65
|
+
};
|
|
66
|
+
fields: SubjectFields;
|
|
67
|
+
correlationId?: string;
|
|
68
|
+
};
|
|
69
|
+
provider: string;
|
|
70
|
+
operation: string;
|
|
71
|
+
idempotencyKey: string;
|
|
72
|
+
occurredAt: string;
|
|
73
|
+
root?: string;
|
|
74
|
+
onUnreconciled?: 'fail' | 'retry';
|
|
75
|
+
}, writeFn: () => Promise<PushOutcome>): Promise<WorldPushRecord>;
|
|
76
|
+
/** Mark a push intent abandoned (e.g. operator decided not to push). */
|
|
77
|
+
export declare function abandonPush(service: string, actionId: string, opts?: {
|
|
78
|
+
root?: string;
|
|
79
|
+
at?: string;
|
|
80
|
+
reason?: string;
|
|
81
|
+
}): WorldPushRecord;
|
|
@@ -0,0 +1,130 @@
|
|
|
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.js";
|
|
22
|
+
import { appendDurable, twinLog, withFileLock, worldPaths } from "./storage.js";
|
|
23
|
+
function ledgerPath(service, root) {
|
|
24
|
+
return join(worldPaths(service, root).dir, 'push-ledger.jsonl');
|
|
25
|
+
}
|
|
26
|
+
/** Lock guarding the push ledger's read-then-append critical sections (TWIN-56):
|
|
27
|
+
* the same file-lock primitive events/actions use, so two callers racing to push
|
|
28
|
+
* the same action can't both read "no prior attempt" and both append duplicate
|
|
29
|
+
* attempt rows. */
|
|
30
|
+
function ledgerLockPath(service, root) {
|
|
31
|
+
return `${ledgerPath(service, root)}.lock`;
|
|
32
|
+
}
|
|
33
|
+
export function appendPushRecord(record, root) {
|
|
34
|
+
const path = ledgerPath(record.service, root);
|
|
35
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
36
|
+
appendDurable(path, `${JSON.stringify(record)}\n`);
|
|
37
|
+
twinLog('push.append', { service: record.service, id: record.id, actionId: record.actionId, status: record.status, correlationId: record.correlationId });
|
|
38
|
+
return record;
|
|
39
|
+
}
|
|
40
|
+
export function listPushLedger(service, root) {
|
|
41
|
+
const path = ledgerPath(service, root);
|
|
42
|
+
if (!existsSync(path))
|
|
43
|
+
return [];
|
|
44
|
+
return readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
45
|
+
}
|
|
46
|
+
/** Latest phase per push id (append order is the tiebreak). */
|
|
47
|
+
export function latestPushByActionId(service, root) {
|
|
48
|
+
const latest = new Map();
|
|
49
|
+
for (const r of listPushLedger(service, root))
|
|
50
|
+
latest.set(r.actionId, r); // later rows overwrite
|
|
51
|
+
return latest;
|
|
52
|
+
}
|
|
53
|
+
/** Pushes that returned provider_accepted but have not yet been observed_confirmed. */
|
|
54
|
+
export function unconfirmedPushes(service, root) {
|
|
55
|
+
return [...latestPushByActionId(service, root).values()].filter((r) => r.status === 'provider_accepted' || r.status === 'attempted');
|
|
56
|
+
}
|
|
57
|
+
const TERMINAL = ['confirmed', 'projection_suppressed', 'succeeded'];
|
|
58
|
+
export class UnreconciledPushError extends Error {
|
|
59
|
+
pushId;
|
|
60
|
+
constructor(pushId, message) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'UnreconciledPushError';
|
|
63
|
+
this.pushId = pushId;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Drive one local transaction commit through the push phases against the real
|
|
68
|
+
* provider via the injected writeFn (the ONLY real I/O — kernel holds no creds).
|
|
69
|
+
* On success it confirms the action into an observed event (confirmAction), which
|
|
70
|
+
* suppresses the local projection so state isn't double-counted. Idempotent: a
|
|
71
|
+
* previously-confirmed push for the same id replays without calling writeFn.
|
|
72
|
+
*
|
|
73
|
+
* Crash parity with egress.ts (performExternalWrite): the "is there a prior row,
|
|
74
|
+
* and is it terminal / unreconciled" read plus the fresh intent+attempted append
|
|
75
|
+
* run under ONE file-lock acquisition (TWIN-56), so two callers racing to push the
|
|
76
|
+
* same action id can't both observe "no attempt yet" and both append attempt rows.
|
|
77
|
+
* By default (onUnreconciled: 'fail') a latest row of `attempted` with nothing
|
|
78
|
+
* after it — a crash mid-flight, or a racing caller that lost the lock — refuses
|
|
79
|
+
* with UnreconciledPushError instead of blindly re-invoking writeFn. Pass
|
|
80
|
+
* onUnreconciled: 'retry' only after verifying external state.
|
|
81
|
+
*/
|
|
82
|
+
export async function pushTransaction(opts, writeFn) {
|
|
83
|
+
const { service, action, provider, operation, idempotencyKey, occurredAt, root, onUnreconciled = 'fail' } = opts;
|
|
84
|
+
const pushId = `push:${service}:${action.id}`;
|
|
85
|
+
// Inherit the action's correlationId (D3) so every phase row this push writes joins
|
|
86
|
+
// back to the action row on that id alone. Actions appended after D3 always carry
|
|
87
|
+
// one (appendAction/appendActionIfAbsent stamp it); the fallback only covers an
|
|
88
|
+
// action object built by hand without going through those (e.g. an older ledger).
|
|
89
|
+
const correlationId = action.correlationId ?? randomUUID();
|
|
90
|
+
const base = { id: pushId, service, actionId: action.id, provider, operation, idempotencyKey, createdAt: occurredAt, correlationId };
|
|
91
|
+
// Read (prior status) + decide (terminal replay / unreconciled refusal) + the
|
|
92
|
+
// fresh intent/attempted append, all under one lock — the atomic critical
|
|
93
|
+
// section a cross-process race would otherwise be able to interleave (TWIN-56).
|
|
94
|
+
const early = withFileLock(ledgerLockPath(service, root), () => {
|
|
95
|
+
const prior = latestPushByActionId(service, root).get(action.id);
|
|
96
|
+
if (prior && TERMINAL.includes(prior.status))
|
|
97
|
+
return prior; // idempotent replay
|
|
98
|
+
if (prior && prior.status === 'attempted' && onUnreconciled === 'fail') {
|
|
99
|
+
throw new UnreconciledPushError(pushId, `Push ${pushId} has an 'attempted' row with no result after it — the provider call may or may not have happened. ` +
|
|
100
|
+
`Verify external state, then retry with onUnreconciled: 'retry'.`);
|
|
101
|
+
}
|
|
102
|
+
appendPushRecord({ ...base, status: 'intent' }, root);
|
|
103
|
+
appendPushRecord({ ...base, status: 'attempted' }, root);
|
|
104
|
+
return null;
|
|
105
|
+
});
|
|
106
|
+
if (early)
|
|
107
|
+
return early;
|
|
108
|
+
let outcome;
|
|
109
|
+
try {
|
|
110
|
+
outcome = await writeFn();
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return appendPushRecord({ ...base, status: 'failed', error: error instanceof Error ? error.message : String(error) }, root);
|
|
114
|
+
}
|
|
115
|
+
appendPushRecord({ ...base, status: 'provider_accepted', external: { id: outcome.externalId, ...(outcome.url ? { url: outcome.url } : {}) }, ...(outcome.data ? { data: outcome.data } : {}) }, root);
|
|
116
|
+
// Confirm: append the observed provider fact + map it back, suppressing the local projection.
|
|
117
|
+
const { observedEventId } = confirmAction({ service, actionId: action.id, subject: action.subject, fields: action.fields, occurredAt, root });
|
|
118
|
+
appendPushRecord({ ...base, status: 'observed_confirmed', external: { id: outcome.externalId }, confirmedByEventId: observedEventId }, root);
|
|
119
|
+
appendPushRecord({ ...base, status: 'projection_suppressed', confirmedByEventId: observedEventId }, root);
|
|
120
|
+
return appendPushRecord({ ...base, status: 'confirmed', external: { id: outcome.externalId }, confirmedByEventId: observedEventId }, root);
|
|
121
|
+
}
|
|
122
|
+
/** Mark a push intent abandoned (e.g. operator decided not to push). */
|
|
123
|
+
export function abandonPush(service, actionId, opts = {}) {
|
|
124
|
+
const prior = latestPushByActionId(service, opts.root).get(actionId);
|
|
125
|
+
return appendPushRecord({
|
|
126
|
+
id: `push:${service}:${actionId}`, service, actionId, provider: prior?.provider ?? '', operation: prior?.operation ?? '',
|
|
127
|
+
idempotencyKey: prior?.idempotencyKey ?? `abandon:${actionId}`, createdAt: opts.at ?? new Date().toISOString(),
|
|
128
|
+
status: 'abandoned', ...(opts.reason ? { error: opts.reason } : {}),
|
|
129
|
+
}, opts.root);
|
|
130
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { QueuedWorldServiceEvent } from './types.js';
|
|
2
|
+
export type QueueRowStatus = 'queued' | 'committed' | 'ignored' | 'superseded' | 'poisoned';
|
|
3
|
+
export type QueueStatusTransition = {
|
|
4
|
+
queueId: string;
|
|
5
|
+
status: QueueRowStatus;
|
|
6
|
+
at: string;
|
|
7
|
+
reason?: string;
|
|
8
|
+
/** for committed rows: the canonical event id it produced. */
|
|
9
|
+
eventId?: string;
|
|
10
|
+
/** for superseded rows: the row that replaced it. */
|
|
11
|
+
supersededBy?: string;
|
|
12
|
+
};
|
|
13
|
+
export type QueueRowWithStatus = {
|
|
14
|
+
row: QueuedWorldServiceEvent;
|
|
15
|
+
status: QueueRowStatus;
|
|
16
|
+
transition?: QueueStatusTransition;
|
|
17
|
+
};
|
|
18
|
+
export declare function setQueueRowStatus(service: string, queueId: string, status: QueueRowStatus, opts?: {
|
|
19
|
+
root?: string;
|
|
20
|
+
at?: string;
|
|
21
|
+
reason?: string;
|
|
22
|
+
eventId?: string;
|
|
23
|
+
supersededBy?: string;
|
|
24
|
+
}): QueueStatusTransition;
|
|
25
|
+
export declare function queueRowStatus(service: string, queueId: string, root?: string): QueueRowStatus;
|
|
26
|
+
/** Every queue row with its current lifecycle status. */
|
|
27
|
+
export declare function listQueueWithStatus(service: string, root?: string): QueueRowWithStatus[];
|
|
28
|
+
/** Rows still awaiting a decision (status === 'queued'). */
|
|
29
|
+
export declare function pendingQueueRows(service: string, root?: string): QueuedWorldServiceEvent[];
|
|
30
|
+
export type QueueCounts = Record<QueueRowStatus, number>;
|
|
31
|
+
export declare function queueCounts(service: string, root?: string): QueueCounts;
|
|
32
|
+
/** Commit a single queued row into the canonical event log + stamp `committed`. */
|
|
33
|
+
export declare function commitQueueRow(service: string, queueId: string, opts?: {
|
|
34
|
+
root?: string;
|
|
35
|
+
at?: string;
|
|
36
|
+
}): {
|
|
37
|
+
appended: boolean;
|
|
38
|
+
eventId?: string;
|
|
39
|
+
};
|
|
40
|
+
export declare function ignoreQueueRow(service: string, queueId: string, opts?: {
|
|
41
|
+
root?: string;
|
|
42
|
+
at?: string;
|
|
43
|
+
reason?: string;
|
|
44
|
+
}): QueueStatusTransition;
|
|
45
|
+
export declare function supersedeQueueRow(service: string, queueId: string, supersededBy: string, opts?: {
|
|
46
|
+
root?: string;
|
|
47
|
+
at?: string;
|
|
48
|
+
reason?: string;
|
|
49
|
+
}): QueueStatusTransition;
|
|
50
|
+
export declare function poisonQueueRow(service: string, queueId: string, opts?: {
|
|
51
|
+
root?: string;
|
|
52
|
+
at?: string;
|
|
53
|
+
reason?: string;
|
|
54
|
+
}): QueueStatusTransition;
|
|
55
|
+
/** Commit all rows currently `queued`, in deterministic provider-time order; stamps committed. */
|
|
56
|
+
export declare function commitPendingQueue(service: string, opts?: {
|
|
57
|
+
root?: string;
|
|
58
|
+
at?: string;
|
|
59
|
+
}): {
|
|
60
|
+
committed: number;
|
|
61
|
+
eventIds: string[];
|
|
62
|
+
};
|