@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
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { keepProcessAlive } from './lifecycle.ts';
2
3
  import { readFileSync } from 'node:fs';
3
4
  import { join } from 'node:path';
4
5
  import { hasFlag, optionValue } from './args.ts';
@@ -28,8 +29,11 @@ import {
28
29
  listRemoteRefs,
29
30
  pendingActions,
30
31
  pendingConflicts,
32
+ buildLocalActionPlan,
33
+ recordPlanReview,
31
34
  createTwinProxy,
32
35
  getPack,
36
+ runEmitCli,
33
37
  } from './index.ts';
34
38
  import { readForkMeta } from './fork.ts';
35
39
  import { twinResources } from './serve.ts';
@@ -45,11 +49,16 @@ function jsonField(stdout: string, path: string): string {
45
49
  }
46
50
  // Comma-separated paths compose an id from multiple fields, joined with ':'
47
51
  // (e.g. --id-field channel,ts → "C123:1718000.42").
48
- const parts = path.split(',').map((segmentPath) => {
52
+ // NB: these locals are deliberately NOT named `segment`/`segmentPath`. The architecture
53
+ // guardrail forbids any vendor's pack name appearing as a bare identifier anywhere in the
54
+ // kernel, and `segment` became a vendor pack name — a generic word colliding with a vendor is
55
+ // exactly the substring trap ADDING_A_TWIN warns about, and it turns a kernel-local variable
56
+ // into a false "kernel branches on vendor identity" report.
57
+ const parts = path.split(',').map((fieldPath) => {
49
58
  let value = parsed;
50
- for (const segment of segmentPath.trim().split('.')) {
59
+ for (const key of fieldPath.trim().split('.')) {
51
60
  if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
52
- value = (value as Record<string, unknown>)[segment];
61
+ value = (value as Record<string, unknown>)[key];
53
62
  }
54
63
  return typeof value === 'string' || typeof value === 'number' ? String(value) : '';
55
64
  });
@@ -73,13 +82,18 @@ Resources:
73
82
  paths show
74
83
  egress write, ledger, unreconciled
75
84
  validate (egress reconciliation; annotations are a tracker concern now)
76
- status <service> plan <service> refs <service>
85
+ status <service> plan <service> review <service> refs <service>
77
86
  scrub <service> | --all [--force] [--root <path>] delete pulled data at rest
87
+ emit <service> --list | <service> <event.type> <subject-id> [--root <path>]
88
+ deliver a signed vendor event synthesized from twin state (pack-provided;
89
+ the standing surface is the pack bin, e.g. \`world-stripe emit ...\`)
78
90
 
79
91
  Examples:
80
92
  world events append --file /tmp/event.json
81
93
  world events list chat --json
82
94
  world state rebuild chat
95
+ world plan chat --root <project>
96
+ world review chat --plan-id <exact-id> --decision approved|rejected --actor-id <id> [--reason <text>] --root <project>
83
97
  world validate [--root <path>] [--services chat,github]
84
98
  world egress write chat --operation message.send --provider slack \\
85
99
  --subject-type channel --subject-id dev --key case:ENG-1:approval-request \\
@@ -156,9 +170,24 @@ async function main(): Promise<void> {
156
170
  // The provider-call mapping is a vendor-pack concern (see buildApplyPlan).
157
171
  const service = action;
158
172
  if (!service) throw new Error('world plan: missing service');
159
- const transactions = pendingActions(service).map((a) => ({ id: a.id, operation: a.operation, subject: a.subject }));
160
- const conflicts = pendingConflicts(service);
161
- print({ service, transactions, conflicts, requiresApproval: conflicts.length > 0 }, true);
173
+ const root = optionValue(rest, '--root') || undefined;
174
+ print(buildLocalActionPlan(service, root), true);
175
+ return;
176
+ }
177
+
178
+ if (resource === 'review') {
179
+ const service = action;
180
+ if (!service) throw new Error('world review: missing service');
181
+ const expectedTransactionSetId = optionValue(rest, '--plan-id');
182
+ if (!expectedTransactionSetId) throw new Error('world review: --plan-id is required (run `world plan <service>` and review that exact id)');
183
+ const decision = optionValue(rest, '--decision');
184
+ if (decision !== 'approved' && decision !== 'rejected') throw new Error('world review: --decision must be approved or rejected');
185
+ const actorId = optionValue(rest, '--actor-id');
186
+ if (!actorId) throw new Error('world review: --actor-id is required');
187
+ const actorKind = optionValue(rest, '--actor-kind', 'human');
188
+ if (actorKind !== 'human' && actorKind !== 'agent') throw new Error('world review: --actor-kind must be human or agent');
189
+ const root = optionValue(rest, '--root') || undefined;
190
+ print(recordPlanReview({ service, expectedTransactionSetId, decision, actor: { kind: actorKind, id: actorId }, reason: optionValue(rest, '--reason') || undefined, root }), true);
162
191
  return;
163
192
  }
164
193
 
@@ -182,7 +211,7 @@ async function main(): Promise<void> {
182
211
  if (once) { print(resolveTwinRead(service, once), true); return; }
183
212
  const server = createTwinServer({ service, readOnly, ...(port ? { port } : {}) });
184
213
  process.stdout.write(`state inspection for ${service}${readOnly ? ' (read-only)' : ''} at http://127.0.0.1:${server.port} (generic reads — NOT the vendor API)\n`);
185
- await new Promise(() => {}); // serve until killed
214
+ await keepProcessAlive(); // serve until killed
186
215
  }
187
216
 
188
217
  // Browser zero-edit injection: a dev proxy in front of your app that forwards
@@ -227,7 +256,7 @@ async function main(): Promise<void> {
227
256
  const proxy = createTwinProxy({ target, map, ...(port ? { port } : {}) });
228
257
  const pairs = Object.entries(map).map(([v, r]) => `${v}(${r.apiPathPrefix})→${r.origin}`).join(', ');
229
258
  process.stdout.write(`twin proxy on http://127.0.0.1:${proxy.port} → ${target} (forwarding ${pairs})\n`);
230
- await new Promise(() => {}); // serve until killed
259
+ await keepProcessAlive(); // serve until killed
231
260
  }
232
261
 
233
262
  // NOTE: `world annotations` moved to the tracker — annotations are a tracker
@@ -390,6 +419,27 @@ async function main(): Promise<void> {
390
419
  return;
391
420
  }
392
421
 
422
+ // The DELIVER verb: `volter-twin emit <service> …` fires a vendor-shaped, SIGNED event
423
+ // synthesized from current twin state at the app's registered webhook endpoint(s).
424
+ // Vendor synthesis lives in the pack (TwinPack.emitter) — the kernel never imports packs,
425
+ // so this only works in a consumer process that registered the pack; the standing operator
426
+ // surface is the pack's own bin (`world-stripe emit …`), like the mirror UIs.
427
+ if (resource === 'emit') {
428
+ const service = action;
429
+ if (!service) throw new Error('volter-twin emit: missing service (e.g. `volter-twin emit stripe --list`)');
430
+ const emitter = getPack(service)?.emitter;
431
+ if (!emitter) {
432
+ throw new Error(
433
+ `volter-twin emit: no emitter is registered for "${service}" in this process. ` +
434
+ `Emit is vendor knowledge and lives in the pack — run \`world-${service} emit --list\` / ` +
435
+ `\`world-${service} emit <event.type> <subject-id> [--root DIR]\` (packs without emitter ` +
436
+ `support say so loudly there), or register the ${service} pack (registerPack) before using this verb.`,
437
+ );
438
+ }
439
+ process.exitCode = await runEmitCli(emitter, rest);
440
+ return;
441
+ }
442
+
393
443
  // Mirror UIs moved to the per-vendor twin packages (modular). Point users there.
394
444
  if (resource === 'mirror') {
395
445
  throw new Error(`world mirror: vendor mirror UIs live in their own packages now — run \`world-${action ?? '<vendor>'} mirror\` (e.g. world-stripe / world-linear / world-jira / world-github / world-slack).`);
@@ -404,7 +454,7 @@ async function main(): Promise<void> {
404
454
  const port = Number(optionValue(rest, '--port', '0')) || undefined;
405
455
  const server = createVisualizerServer({ service, ...(port ? { port } : {}) });
406
456
  process.stdout.write(`${service} twin visualizer at http://127.0.0.1:${server.port}\n`);
407
- await new Promise(() => {});
457
+ await keepProcessAlive();
408
458
  return;
409
459
  }
410
460
 
package/src/connector.ts CHANGED
@@ -14,8 +14,18 @@
14
14
  // landing at exactly that timestamp after the poll is not excluded by a
15
15
  // strictly-greater filter; the overlap re-fetch is free (unchanged
16
16
  // observations append nothing)
17
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
18
- import { dirname, join } from 'node:path';
17
+ // - PULL POSTURE (the rule that is not a comment): both runners are CADENCE runners — a cursor
18
+ // exists so the poll can be repeated, a sweep exists so subjects can be re-observed. So each
19
+ // one asserts the vendor's declared pull posture BEFORE `fetchSince`/`fetchSubject` is ever
20
+ // called: a refused vendor issues ZERO vendor requests. `connector.vendor` is required (a
21
+ // pack descriptor, or a registered vendor id) because an unnamed vendor cannot be checked, and
22
+ // an unchecked scheduled pull is how a rate-fragile vendor gets locked out. A one-off,
23
+ // human-asked pull of an on-demand vendor is still allowed — pass `trigger: 'explicit'`, which
24
+ // is a deliberate, greppable statement rather than the default.
25
+ import { join } from 'node:path';
26
+ import { getActiveWorldStore } from './world-store.ts';
27
+ import { assertContinuousPullAllowed, DEFAULT_PULL_TRIGGER } from './packRegistry.ts';
28
+ import type { PullTrigger, PullVendor } from './packRegistry.ts';
19
29
  import { buildShadowState, recordObservedDelta } from './shadow.ts';
20
30
  import type { SubjectFields } from './shadow.ts';
21
31
  import { appendEvent, readJsonFile, rebuildGenericState, worldPaths } from './storage.ts';
@@ -38,6 +48,13 @@ export type ConnectorObservation = {
38
48
 
39
49
  export type WorldConnector = {
40
50
  service: string;
51
+ /**
52
+ * WHICH VENDOR this connector pulls — the pack descriptor (preferred: it carries its own
53
+ * posture) or a vendor id registered via `registerPack`. Required, and deliberately separate
54
+ * from `service`: a world's service name is the caller's word (a chat world may be served by any
55
+ * chat vendor), while the posture is a fact about the VENDOR's rate limits.
56
+ */
57
+ vendor: PullVendor;
41
58
  /** Cursor filename under the service's cursors/ dir (default poll.json). */
42
59
  cursorFile?: string;
43
60
  /** Seed the shadow from historical event types (default: deltas only). */
@@ -71,23 +88,25 @@ export function pollCursorPath(service: string, root?: string, cursorFile = 'pol
71
88
 
72
89
  export function loadPollCursor(service: string, root?: string, cursorFile?: string): string {
73
90
  const path = pollCursorPath(service, root, cursorFile);
74
- if (!existsSync(path)) return '';
91
+ if (!getActiveWorldStore().exists(path)) return '';
75
92
  return readJsonFile<{ after?: string }>(path).after ?? '';
76
93
  }
77
94
 
78
95
  export function savePollCursor(service: string, after: string, root?: string, cursorFile?: string): void {
79
96
  const path = pollCursorPath(service, root, cursorFile);
80
- mkdirSync(dirname(path), { recursive: true });
81
- writeFileSync(path, `${JSON.stringify({ after, updatedAt: new Date().toISOString() }, null, 2)}\n`);
97
+ getActiveWorldStore().write(path, `${JSON.stringify({ after, updatedAt: new Date().toISOString() }, null, 2)}\n`);
82
98
  }
83
99
 
84
100
  export async function runConnectorPoll(
85
101
  connector: WorldConnector,
86
- options: { root?: string; cursor?: string; limit?: number } = {},
102
+ options: { root?: string; cursor?: string; limit?: number; trigger?: PullTrigger } = {},
87
103
  ): Promise<ConnectorPollResult> {
88
104
  const root = options.root;
89
105
  const service = connector.service;
90
106
  const limit = options.limit ?? 10000;
107
+ // BEFORE any I/O: a scheduled poll of a vendor that may only be pulled on demand is refused
108
+ // here, with the cursor untouched and not one vendor request issued.
109
+ if ((options.trigger ?? DEFAULT_PULL_TRIGGER) === 'scheduled') assertContinuousPullAllowed(connector.vendor);
91
110
  const cursorBefore = options.cursor ?? loadPollCursor(service, root, connector.cursorFile);
92
111
 
93
112
  const { observations, truncated } = await connector.fetchSince({ cursor: cursorBefore, limit, root: root ?? process.env.PROJECT_ROOT ?? process.cwd() });
@@ -149,6 +168,8 @@ import type { ShadowState } from './shadow.ts';
149
168
 
150
169
  export type SweepConnector = {
151
170
  service: string;
171
+ /** WHICH VENDOR this sweep re-observes — see `WorldConnector.vendor`. Required, same reason. */
172
+ vendor: PullVendor;
152
173
  shadowExtractor?: (event: WorldServiceEvent) => SubjectFields | null;
153
174
  /** Which tracked subjects to re-observe this sweep. */
154
175
  selectSubjects(input: {
@@ -176,12 +197,14 @@ export type ConnectorSweepResult = {
176
197
 
177
198
  export async function runConnectorSweep(
178
199
  connector: SweepConnector,
179
- options: { root?: string; limit?: number; sweepAll?: boolean } = {},
200
+ options: { root?: string; limit?: number; sweepAll?: boolean; trigger?: PullTrigger } = {},
180
201
  ): Promise<ConnectorSweepResult> {
181
202
  const root = options.root;
182
203
  const service = connector.service;
183
204
  const limit = options.limit ?? 200;
184
205
  const sweepAll = options.sweepAll ?? false;
206
+ // Same guard, same place: before a single `fetchSubject` goes out.
207
+ if ((options.trigger ?? DEFAULT_PULL_TRIGGER) === 'scheduled') assertContinuousPullAllowed(connector.vendor);
185
208
 
186
209
  const generic = loadState<GenericWorldState>(service, root);
187
210
  const allSubjects = Object.values(generic?.subjects ?? {}).map((subject) => {
@@ -26,12 +26,28 @@ export type { TwinResource, TwinWriteResult } from './serve.ts';
26
26
  export { formatStatus, worldStatus } from './status.ts';
27
27
  export type { WorldStatus } from './status.ts';
28
28
 
29
+ // the changeset primitive + the ledger diff (operational VCS v0): mark a base, diff the
30
+ // delta since it, freeze it into a content-addressed changeset, replay it into another world
31
+ export {
32
+ buildChangeset,
33
+ captureMarker,
34
+ changesetContentHash,
35
+ changesetHashMatches,
36
+ diffLedgers,
37
+ formatChangeset,
38
+ formatLedgerDelta,
39
+ formatReplayReport,
40
+ replayChangeset,
41
+ worldBootMarker,
42
+ } from './changeset.ts';
43
+ export type { Changeset, ChangesetAction, LedgerDelta, LedgerRef, ReplayReport, ReplayTarget, WorldMarker } from './changeset.ts';
44
+
29
45
  // plan + apply orchestration
30
46
  export { applyPlan, buildApplyPlan, listPlans, pendingConflicts, planRequiresApproval, readPlan, writePlan } from './plan.ts';
31
47
  export type { ActionMapper, ApplyResult, ProviderCall, WorldApplyPlan } from './plan.ts';
32
48
 
33
49
  // push ledger + phases
34
- export { abandonPush, appendPushRecord, latestPushByActionId, listPushLedger, pushTransaction, unconfirmedPushes, UnreconciledPushError } from './pushLedger.ts';
50
+ export { abandonPush, appendPushRecord, assertFastForward, latestPushByActionId, listPushLedger, NonFastForwardPushError, pushTransaction, unconfirmedPushes, UnreconciledPushError } from './pushLedger.ts';
35
51
  export type { PushOutcome, PushStatus, WorldPushRecord } from './pushLedger.ts';
36
52
 
37
53
  // apply leases (single-writer per checkpoint)
package/src/emit.ts ADDED
@@ -0,0 +1,242 @@
1
+ // The DELIVER verb (`emit`) — fire a vendor-shaped, SIGNED webhook/event at the app under
2
+ // test, synthesized from CURRENT TWIN STATE, without hand-constructing the envelope or the
3
+ // signature. The engine here is vendor-agnostic; everything vendor-shaped (which event types
4
+ // exist, which twin resource supplies `data.object`, where registered endpoints + signing
5
+ // secrets live in state, how a delivery is signed) is a pack's knowledge, declared as a
6
+ // `TwinEmitter` and registered on its `TwinPack.emitter` (same argument as `browserRouting`:
7
+ // vendor knowledge in the descriptor, kernel stays vendor-agnostic).
8
+ //
9
+ // The operator surface is the PACK's bin (`world-stripe emit …`), exactly like the mirror
10
+ // UIs — the kernel never imports packs, so `volter-twin emit <service>` only works when a
11
+ // consumer registered the pack in-process, and otherwise fails loudly pointing at the
12
+ // vendor bin. Unlike the twin's own background emission on state changes (fire-and-forget,
13
+ // vendor-faithful), an explicit DELIVER is an operator asking for exactly this delivery, so
14
+ // every failure here is LOUD: unknown event type, unknown subject, no registered endpoint,
15
+ // and non-2xx delivery responses all throw / report instead of being swallowed.
16
+
17
+ /** One event type a pack can synthesize, and the twin resource type that supplies its payload. */
18
+ export type EmittableEvent = {
19
+ type: string;
20
+ /** twin resource type whose current state becomes the event payload (`data.object` for Stripe). */
21
+ subjectType: string;
22
+ description?: string;
23
+ };
24
+
25
+ /** A delivery destination registered IN TWIN STATE (at rest — the emit CLI runs in its own
26
+ * process, so in-memory registries don't count; endpoints must be resolvable from the root). */
27
+ export type EmitEndpoint = {
28
+ /** the vendor resource id of the registration (e.g. Stripe `we_…`), when it has one. */
29
+ id?: string;
30
+ url: string;
31
+ /** event-type subscriptions; `*` wildcards supported. Absent ⇒ subscribed to everything. */
32
+ enabledEvents?: string[];
33
+ };
34
+
35
+ /** A fully built, signed delivery for one endpoint: exact body bytes + headers to POST. */
36
+ export type SynthesizedDelivery = {
37
+ /** the exact payload string — the signature covers THESE bytes, do not re-serialize. */
38
+ payload: string;
39
+ headers: Record<string, string>;
40
+ /** the synthesized event envelope (for reporting/assertions). */
41
+ event: Record<string, unknown>;
42
+ };
43
+
44
+ /** What a pack declares to support `emit` — see `TwinPack.emitter`. All state reads take the
45
+ * same `root` every pack verb takes, so the CLI composes with worlds unchanged. */
46
+ export type TwinEmitter = {
47
+ vendor: string;
48
+ /** the emittable catalog (drives `--list` and the unknown-event error). */
49
+ events(): EmittableEvent[];
50
+ /** endpoints registered in twin state (e.g. Stripe webhook_endpoint rows). */
51
+ endpoints(root?: string): EmitEndpoint[];
52
+ /** subject ids present in state for a subject type (drives `--list` + unknown-subject errors). */
53
+ subjects(subjectType: string, root?: string): string[];
54
+ /** synthesize the signed delivery for one endpoint. MUST throw loudly on an unknown subject. */
55
+ synthesize(opts: { type: string; subjectId: string; endpoint: EmitEndpoint; root?: string; occurredAt?: string }): SynthesizedDelivery;
56
+ };
57
+
58
+ export type EmitDeliveryResult = {
59
+ endpoint: EmitEndpoint;
60
+ ok: boolean;
61
+ /** HTTP status when the POST completed; absent when the request itself failed. */
62
+ status?: number;
63
+ error?: string;
64
+ event: Record<string, unknown>;
65
+ };
66
+
67
+ export type EmitReport = {
68
+ vendor: string;
69
+ type: string;
70
+ subjectId: string;
71
+ deliveries: EmitDeliveryResult[];
72
+ /** true only when EVERY delivery reached its endpoint and got a 2xx back. */
73
+ ok: boolean;
74
+ };
75
+
76
+ /** Vendor wildcard subscription match (Stripe semantics: `*` matches all;
77
+ * `invoice.*` matches every invoice event; otherwise exact). */
78
+ export function eventSubscriptionMatches(pattern: string, type: string): boolean {
79
+ if (pattern === '*') return true;
80
+ if (pattern.endsWith('.*')) return type.startsWith(pattern.slice(0, -1));
81
+ return pattern === type;
82
+ }
83
+
84
+ function endpointSubscribed(endpoint: EmitEndpoint, type: string): boolean {
85
+ if (!endpoint.enabledEvents || endpoint.enabledEvents.length === 0) return true;
86
+ return endpoint.enabledEvents.some((p) => eventSubscriptionMatches(p, type));
87
+ }
88
+
89
+ /** The `--list` view: everything emittable right now — event types, the subject ids present in
90
+ * state for each subject type, and the registered endpoints. Pure state read. */
91
+ export function listEmittable(emitter: TwinEmitter, root?: string): {
92
+ vendor: string;
93
+ events: Array<EmittableEvent & { subjects: string[] }>;
94
+ endpoints: EmitEndpoint[];
95
+ } {
96
+ const subjectsByType = new Map<string, string[]>();
97
+ const events = emitter.events().map((e) => {
98
+ let subjects = subjectsByType.get(e.subjectType);
99
+ if (!subjects) {
100
+ subjects = emitter.subjects(e.subjectType, root);
101
+ subjectsByType.set(e.subjectType, subjects);
102
+ }
103
+ return { ...e, subjects };
104
+ });
105
+ return { vendor: emitter.vendor, events, endpoints: emitter.endpoints(root) };
106
+ }
107
+
108
+ /**
109
+ * DELIVER: synthesize the event for (`type`, `subjectId`) from current twin state and POST it,
110
+ * signed, to every registered endpoint subscribed to that event type (or the one endpoint
111
+ * `endpoint` names by url or id). Loud failures:
112
+ * • event type the pack can't synthesize → throws, listing the emittable types;
113
+ * • unknown subject id → the pack's synthesize throws (listing available ids);
114
+ * • no registered/subscribed endpoint → throws (register one via the vendor API first);
115
+ * • a delivery that errors or comes back non-2xx → reported per-endpoint, report.ok=false.
116
+ * `fetchFn` is the delivery seam (tests inject; default real fetch).
117
+ */
118
+ export async function emitTwinEvent(
119
+ emitter: TwinEmitter,
120
+ opts: {
121
+ type: string;
122
+ subjectId: string;
123
+ root?: string;
124
+ /** restrict delivery to the endpoint with this url or vendor id. */
125
+ endpoint?: string;
126
+ occurredAt?: string;
127
+ fetchFn?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ status: number }>;
128
+ },
129
+ ): Promise<EmitReport> {
130
+ const catalog = emitter.events();
131
+ const known = catalog.find((e) => e.type === opts.type);
132
+ if (!known) {
133
+ throw new Error(
134
+ `emit: the ${emitter.vendor} pack cannot synthesize "${opts.type}". Emittable event types:\n ${catalog.map((e) => `${e.type} (subject: ${e.subjectType})`).join('\n ')}`,
135
+ );
136
+ }
137
+
138
+ const all = emitter.endpoints(opts.root);
139
+ if (all.length === 0) {
140
+ throw new Error(
141
+ `emit: no ${emitter.vendor} webhook endpoint is registered in twin state — nothing to deliver to. ` +
142
+ `Register one through the vendor API first (for Stripe: POST /v1/webhook_endpoints with url + enabled_events).`,
143
+ );
144
+ }
145
+ let targets = all.filter((e) => endpointSubscribed(e, opts.type));
146
+ if (opts.endpoint) {
147
+ targets = targets.filter((e) => e.url === opts.endpoint || e.id === opts.endpoint);
148
+ if (targets.length === 0) {
149
+ throw new Error(
150
+ `emit: no registered endpoint matches "${opts.endpoint}" for event "${opts.type}". Registered endpoints:\n ${all.map((e) => `${e.id ?? '-'} ${e.url} [${(e.enabledEvents ?? ['*']).join(', ')}]`).join('\n ')}`,
151
+ );
152
+ }
153
+ }
154
+ if (targets.length === 0) {
155
+ throw new Error(
156
+ `emit: ${all.length} endpoint(s) registered but none subscribes to "${opts.type}". Registered endpoints:\n ${all.map((e) => `${e.id ?? '-'} ${e.url} [${(e.enabledEvents ?? ['*']).join(', ')}]`).join('\n ')}`,
157
+ );
158
+ }
159
+
160
+ const fetchFn = opts.fetchFn ?? (async (url: string, init: { method: string; headers: Record<string, string>; body: string }) => {
161
+ const res = await fetch(url, init);
162
+ return { status: res.status };
163
+ });
164
+
165
+ const deliveries: EmitDeliveryResult[] = [];
166
+ for (const endpoint of targets) {
167
+ // synthesize PER ENDPOINT: signatures are per-endpoint secrets (real Stripe signs each
168
+ // delivery with the destination endpoint's own whsec). Unknown subject throws here — loudly.
169
+ const built = emitter.synthesize({ type: opts.type, subjectId: opts.subjectId, endpoint, ...(opts.root !== undefined ? { root: opts.root } : {}), ...(opts.occurredAt !== undefined ? { occurredAt: opts.occurredAt } : {}) });
170
+ try {
171
+ const res = await fetchFn(endpoint.url, { method: 'POST', headers: built.headers, body: built.payload });
172
+ deliveries.push({ endpoint, ok: res.status >= 200 && res.status < 300, status: res.status, event: built.event, ...(res.status >= 200 && res.status < 300 ? {} : { error: `endpoint responded ${res.status}` }) });
173
+ } catch (error) {
174
+ deliveries.push({ endpoint, ok: false, error: error instanceof Error ? error.message : String(error), event: built.event });
175
+ }
176
+ }
177
+ return { vendor: emitter.vendor, type: opts.type, subjectId: opts.subjectId, deliveries, ok: deliveries.every((d) => d.ok) };
178
+ }
179
+
180
+ /**
181
+ * Shared CLI glue so every pack's `emit` verb behaves identically:
182
+ * world-<vendor> emit --list [--root DIR] [--json]
183
+ * world-<vendor> emit <event.type> <subject-id> [--root DIR] [--endpoint URL|ID] [--at ISO] [--json]
184
+ * Returns the process exit code (0 delivered; 1 loud failure / any failed delivery; 2 usage).
185
+ */
186
+ export async function runEmitCli(
187
+ emitter: TwinEmitter,
188
+ argv: string[],
189
+ io: { out: (s: string) => void; err: (s: string) => void } = { out: (s) => process.stdout.write(s), err: (s) => process.stderr.write(s) },
190
+ ): Promise<number> {
191
+ const flags = new Map<string, string>();
192
+ const positional: string[] = [];
193
+ let list = false;
194
+ let json = false;
195
+ for (let i = 0; i < argv.length; i += 1) {
196
+ const a = argv[i]!;
197
+ if (a === '--list') list = true;
198
+ else if (a === '--json') json = true;
199
+ else if (a.startsWith('--')) {
200
+ flags.set(a, argv[i + 1] ?? '');
201
+ i += 1;
202
+ } else positional.push(a);
203
+ }
204
+ const root = flags.get('--root');
205
+
206
+ if (list) {
207
+ const view = listEmittable(emitter, root);
208
+ if (json) {
209
+ io.out(`${JSON.stringify(view, null, 2)}\n`);
210
+ } else {
211
+ io.out(`emittable ${view.vendor} events (synthesized from current twin state):\n`);
212
+ for (const e of view.events) {
213
+ io.out(` ${e.type} (subject: ${e.subjectType}${e.subjects.length ? ` — ${e.subjects.join(', ')}` : ' — none in state'})\n`);
214
+ }
215
+ io.out(view.endpoints.length === 0
216
+ ? 'no webhook endpoints registered — deliveries have nowhere to go.\n'
217
+ : `endpoints:\n${view.endpoints.map((e) => ` ${e.id ?? '-'} ${e.url} [${(e.enabledEvents ?? ['*']).join(', ')}]`).join('\n')}\n`);
218
+ }
219
+ return 0;
220
+ }
221
+
222
+ const [type, subjectId] = positional;
223
+ if (!type || !subjectId) {
224
+ io.err(`Usage: world-${emitter.vendor} emit --list [--root DIR] [--json]\n world-${emitter.vendor} emit <event.type> <subject-id> [--root DIR] [--endpoint URL|ID] [--at ISO] [--json]\n`);
225
+ return 2;
226
+ }
227
+ try {
228
+ const endpointFlag = flags.get('--endpoint');
229
+ const at = flags.get('--at');
230
+ const report = await emitTwinEvent(emitter, { type, subjectId, ...(root !== undefined ? { root } : {}), ...(endpointFlag !== undefined ? { endpoint: endpointFlag } : {}), ...(at !== undefined ? { occurredAt: at } : {}) });
231
+ if (json) io.out(`${JSON.stringify(report, null, 2)}\n`);
232
+ else {
233
+ for (const d of report.deliveries) {
234
+ io.out(`${d.ok ? 'delivered' : 'FAILED'} ${report.type} (${String(d.event.id ?? '')}) → ${d.endpoint.url}${d.status !== undefined ? ` [${d.status}]` : ''}${d.error ? ` — ${d.error}` : ''}\n`);
235
+ }
236
+ }
237
+ return report.ok ? 0 : 1;
238
+ } catch (error) {
239
+ io.err(`${error instanceof Error ? error.message : String(error)}\n`);
240
+ return 1;
241
+ }
242
+ }
package/src/fork.ts CHANGED
@@ -20,8 +20,8 @@
20
20
  //
21
21
  // Convergence (rebase onto a fresh pull / push the divergence to real) is the
22
22
  // separate `reconcile` concern and is not implemented here.
23
- import { existsSync, rmSync, writeFileSync } from 'node:fs';
24
23
  import { dirname, join } from 'node:path';
24
+ import { getActiveWorldStore } from './world-store.ts';
25
25
  import { listEgressLedger } from './egress.ts';
26
26
  import { appendAction, listActions, pendingActions, TwinActionPreconditionError } from './actions.ts';
27
27
  import type { TwinAction } from './actions.ts';
@@ -62,7 +62,7 @@ export function forkTwin(opts: {
62
62
  occurredAt: string;
63
63
  }): ForkMeta {
64
64
  const { service, toRoot, fromRoot } = opts;
65
- if (existsSync(forkMetaPath(service, toRoot))) {
65
+ if (getActiveWorldStore().exists(forkMetaPath(service, toRoot))) {
66
66
  throw new Error(`fork target already initialized for ${service}: ${toRoot}`);
67
67
  }
68
68
  const baseEvents = listEvents(service, fromRoot);
@@ -78,18 +78,18 @@ export function forkTwin(opts: {
78
78
  baseLatestEventId: baseEvents.length ? baseEvents[baseEvents.length - 1]!.id : null,
79
79
  baseline,
80
80
  };
81
- writeFileSync(forkMetaPath(service, toRoot), `${JSON.stringify(meta, null, 2)}\n`);
81
+ getActiveWorldStore().write(forkMetaPath(service, toRoot), `${JSON.stringify(meta, null, 2)}\n`);
82
82
  return meta;
83
83
  }
84
84
 
85
85
  export function readForkMeta(service: string, root: string): ForkMeta {
86
86
  const path = forkMetaPath(service, root);
87
- if (!existsSync(path)) throw new Error(`not a fork (no fork-meta.json) for ${service}: ${root}`);
87
+ if (!getActiveWorldStore().exists(path)) throw new Error(`not a fork (no fork-meta.json) for ${service}: ${root}`);
88
88
  return readJsonFile<ForkMeta>(path);
89
89
  }
90
90
 
91
91
  export function isFork(service: string, root: string): boolean {
92
- return existsSync(forkMetaPath(service, root));
92
+ return getActiveWorldStore().exists(forkMetaPath(service, root));
93
93
  }
94
94
 
95
95
  export type FieldDivergence = { before: unknown; after: unknown };
@@ -189,7 +189,7 @@ export function auditForkNoRealWrites(service: string, forkRoot: string): ForkAu
189
189
 
190
190
  /** Remove a fork root entirely (the fork is throwaway by design). */
191
191
  export function discardFork(forkRoot: string): void {
192
- rmSync(forkRoot, { recursive: true, force: true });
192
+ getActiveWorldStore().remove(forkRoot);
193
193
  }
194
194
 
195
195
  // ── Git-like fork operations (the twins architecture notes → "Fork Operations").
@@ -210,7 +210,12 @@ export function cherryPickActions(opts: { service: string; fromRoot: string; toR
210
210
  const copied: TwinAction[] = [];
211
211
  const conflicts: CopyResult['conflicts'] = [];
212
212
  for (const a of src) {
213
- try { copied.push(appendAction({ ...a, occurredAt: opts.occurredAt }, opts.toRoot)); }
213
+ // The source root's shadowBasis names events in the SOURCE's log — stripped so the
214
+ // appender re-stamps against the target root's own mirror (R14). NB appendAction does
215
+ // not dedupe ids (pre-existing): a re-run cherry-pick appends duplicate rows, and the
216
+ // push gate binds to the FIRST copy's (older, safer) basis.
217
+ const { shadowBasis: _foreignBasis, ...rest } = a;
218
+ try { copied.push(appendAction({ ...rest, occurredAt: opts.occurredAt }, opts.toRoot)); }
214
219
  catch (e) { if (e instanceof TwinActionPreconditionError) conflicts.push({ actionId: a.id, reason: e.message }); else throw e; }
215
220
  }
216
221
  return { copied, conflicts };
@@ -248,6 +253,13 @@ export function resetFork(opts: { service: string; root: string; occurredAt: str
248
253
  * rebase: commit fresh remote observations onto the fork's base, then replay active
249
254
  * local transactions over the new base. Transactions whose preconditions now fail
250
255
  * are returned as conflicts (the doc's "failed preconditions become conflicts").
256
+ *
257
+ * R14 note: this is the FETCH step of "fetch + reconcile" — it does NOT refresh pending
258
+ * actions' shadowBasis (rows are append-only). A pending action whose subject the fresh
259
+ * events touched stays push-refused until it is reverted and re-authored over the new
260
+ * mirror (re-authoring stamps a fresh basis), or pushed with onDrift:'force' after a
261
+ * reviewed reconcile decided the twin's value wins. There is no rebase of the action
262
+ * itself — append-only history is the audit.
251
263
  */
252
264
  export function rebaseFork(opts: { service: string; root: string; freshEvents: WorldServiceEvent[] }): { applied: number; conflicts: Array<{ actionId: string; reason: string }> } {
253
265
  let applied = 0;