@volter/twin 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +16 -2
  2. package/inject.cjs +453 -59
  3. package/package.json +12 -22
  4. package/src/actions.ts +234 -49
  5. package/src/blob-store.ts +136 -0
  6. package/src/changeset.ts +807 -0
  7. package/src/cli.ts +60 -10
  8. package/src/connector.ts +30 -7
  9. package/src/control-plane.ts +17 -1
  10. package/src/emit.ts +242 -0
  11. package/src/fork.ts +19 -7
  12. package/src/index.ts +139 -6
  13. package/src/lease.ts +4 -6
  14. package/src/lifecycle.ts +8 -0
  15. package/src/packRegistry.ts +248 -2
  16. package/src/plan.ts +131 -23
  17. package/src/proxy.ts +5 -2
  18. package/src/pushLedger.ts +116 -11
  19. package/src/queueLifecycle.ts +3 -4
  20. package/src/rateBudget.ts +1115 -0
  21. package/src/refs.ts +9 -10
  22. package/src/remote-execute.ts +16 -0
  23. package/src/scenario.ts +387 -0
  24. package/src/serve.ts +397 -15
  25. package/src/shadow.ts +86 -7
  26. package/src/storage.ts +76 -147
  27. package/src/sync.ts +63 -17
  28. package/src/twin-fetch.ts +115 -0
  29. package/src/validate.ts +6 -5
  30. package/src/world-clock.ts +33 -0
  31. package/src/world-store.ts +482 -0
  32. package/src/worldConfig.ts +4 -3
  33. package/dist/src/actions.d.ts +0 -138
  34. package/dist/src/actions.js +0 -201
  35. package/dist/src/args.d.ts +0 -3
  36. package/dist/src/args.js +0 -12
  37. package/dist/src/cli.d.ts +0 -2
  38. package/dist/src/cli.js +0 -425
  39. package/dist/src/connector.d.ts +0 -106
  40. package/dist/src/connector.js +0 -129
  41. package/dist/src/control-plane.d.ts +0 -21
  42. package/dist/src/control-plane.js +0 -40
  43. package/dist/src/egress.d.ts +0 -93
  44. package/dist/src/egress.js +0 -264
  45. package/dist/src/fork.d.ts +0 -126
  46. package/dist/src/fork.js +0 -206
  47. package/dist/src/index.d.ts +0 -42
  48. package/dist/src/index.js +0 -52
  49. package/dist/src/lease.d.ts +0 -50
  50. package/dist/src/lease.js +0 -80
  51. package/dist/src/packRegistry.d.ts +0 -34
  52. package/dist/src/packRegistry.js +0 -22
  53. package/dist/src/plan.d.ts +0 -97
  54. package/dist/src/plan.js +0 -151
  55. package/dist/src/proxy.d.ts +0 -25
  56. package/dist/src/proxy.js +0 -152
  57. package/dist/src/pushLedger.d.ts +0 -81
  58. package/dist/src/pushLedger.js +0 -130
  59. package/dist/src/queueLifecycle.d.ts +0 -62
  60. package/dist/src/queueLifecycle.js +0 -95
  61. package/dist/src/reconcile.d.ts +0 -58
  62. package/dist/src/reconcile.js +0 -137
  63. package/dist/src/refs.d.ts +0 -29
  64. package/dist/src/refs.js +0 -68
  65. package/dist/src/schemas.d.ts +0 -78
  66. package/dist/src/schemas.js +0 -50
  67. package/dist/src/serve.d.ts +0 -44
  68. package/dist/src/serve.js +0 -93
  69. package/dist/src/shadow.d.ts +0 -77
  70. package/dist/src/shadow.js +0 -138
  71. package/dist/src/status.d.ts +0 -31
  72. package/dist/src/status.js +0 -42
  73. package/dist/src/storage.d.ts +0 -119
  74. package/dist/src/storage.js +0 -535
  75. package/dist/src/sync.d.ts +0 -91
  76. package/dist/src/sync.js +0 -121
  77. package/dist/src/types.d.ts +0 -40
  78. package/dist/src/types.js +0 -1
  79. package/dist/src/validate.d.ts +0 -27
  80. package/dist/src/validate.js +0 -68
  81. package/dist/src/visualizer.d.ts +0 -13
  82. package/dist/src/visualizer.js +0 -133
  83. package/dist/src/worldConfig.d.ts +0 -9
  84. package/dist/src/worldConfig.js +0 -16
@@ -1,97 +0,0 @@
1
- import type { TwinAction } from './actions.js';
2
- import type { WorldRemoteRef } from './refs.js';
3
- import type { PushOutcome, WorldPushRecord } from './pushLedger.js';
4
- export type ProviderCall = {
5
- actionId: string;
6
- provider: string;
7
- operation: string;
8
- input: Record<string, unknown>;
9
- idempotencyKey: string;
10
- destructive: boolean;
11
- expectedConfirmation: {
12
- subject: {
13
- type: string;
14
- id: string;
15
- };
16
- eventType: string;
17
- matcher: Record<string, unknown>;
18
- };
19
- };
20
- export type WorldApplyPlan = {
21
- id: string;
22
- service: string;
23
- forkId: string;
24
- baseRemoteRef: WorldRemoteRef;
25
- transactions: string[];
26
- providerCalls: ProviderCall[];
27
- conflicts: Array<{
28
- actionId: string;
29
- reason: string;
30
- }>;
31
- requiresApproval: boolean;
32
- createdAt: string;
33
- };
34
- /** Map a local transaction commit → the provider call that materializes it (vendor-specific). Return null to skip. */
35
- export type ActionMapper = (action: TwinAction) => Omit<ProviderCall, 'actionId'> | null;
36
- /** Pending transactions whose preconditions would fail against current projected state. */
37
- export declare function pendingConflicts(service: string, root?: string): Array<{
38
- actionId: string;
39
- reason: string;
40
- }>;
41
- /**
42
- * Build a reviewable apply plan from a fork's pending transactions. `mapper` is the
43
- * vendor-specific action→provider-call mapping; actions it returns null for are
44
- * omitted. Conflicts are pending actions whose preconditions fail against current
45
- * projected state. Approval is required if any call is destructive or any conflict exists.
46
- */
47
- export declare function buildApplyPlan(opts: {
48
- service: string;
49
- forkId: string;
50
- baseRemoteRef: WorldRemoteRef;
51
- mapper: ActionMapper;
52
- id: string;
53
- createdAt: string;
54
- root?: string;
55
- }): WorldApplyPlan;
56
- export declare function writePlan(plan: WorldApplyPlan, root?: string): WorldApplyPlan;
57
- export declare function readPlan(service: string, id: string, root?: string): WorldApplyPlan | null;
58
- export declare function listPlans(service: string, root?: string): WorldApplyPlan[];
59
- export type ApplyResult = {
60
- planId: string;
61
- leaseId: string;
62
- pushed: WorldPushRecord[];
63
- skipped: string[];
64
- };
65
- /**
66
- * Whether a plan requires approval before it may be applied — recomputed from the
67
- * plan's OWN `providerCalls`/`conflicts`, the exact rule `buildApplyPlan` uses to set
68
- * the field in the first place. `applyPlan` calls this instead of trusting
69
- * `plan.requiresApproval`: that field lives on plan JSON that is neither signed nor
70
- * otherwise authenticated, so a hand-edited file (or a hand-built plan object) that
71
- * flips the boolean to `false` must not be able to skip the approval gate — only
72
- * removing the destructive calls/conflicts themselves does.
73
- */
74
- export declare function planRequiresApproval(plan: WorldApplyPlan): boolean;
75
- /**
76
- * Enact a plan against the real provider — the gated push path. Refuses an
77
- * unapproved plan (approval requirement recomputed from the plan's own data, never
78
- * trusted off the stored bit) and a push whose base has gone stale (the live
79
- * remote ref moved since `plan.baseRemoteRef` was recorded) before any provider
80
- * call or lease acquisition — zero side effects either way. Then acquires an apply
81
- * lease for the plan's baseRemoteRef (single-writer), drives every provider call
82
- * through the push phases via the injected writeFn (the only real I/O), then
83
- * releases the lease.
84
- */
85
- export declare function applyPlan(plan: WorldApplyPlan, writeFn: (call: ProviderCall) => Promise<PushOutcome>, opts: {
86
- holder: {
87
- kind: 'agent' | 'human' | 'system';
88
- id: string;
89
- };
90
- leaseId: string;
91
- acquiredAt: string;
92
- expiresAt: string;
93
- occurredAt: string;
94
- approve?: boolean;
95
- overrideStaleBase?: boolean;
96
- root?: string;
97
- }): Promise<ApplyResult>;
package/dist/src/plan.js DELETED
@@ -1,151 +0,0 @@
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
- }
@@ -1,25 +0,0 @@
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;
package/dist/src/proxy.js DELETED
@@ -1,152 +0,0 @@
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
- }
@@ -1,81 +0,0 @@
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;
@@ -1,130 +0,0 @@
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
- }