@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.
- package/README.md +16 -2
- package/inject.cjs +453 -59
- package/package.json +12 -22
- package/src/actions.ts +234 -49
- package/src/blob-store.ts +136 -0
- package/src/changeset.ts +807 -0
- package/src/cli.ts +60 -10
- package/src/connector.ts +30 -7
- package/src/control-plane.ts +17 -1
- package/src/emit.ts +242 -0
- package/src/fork.ts +19 -7
- package/src/index.ts +139 -6
- package/src/lease.ts +4 -6
- package/src/lifecycle.ts +8 -0
- package/src/packRegistry.ts +248 -2
- package/src/plan.ts +131 -23
- package/src/proxy.ts +5 -2
- package/src/pushLedger.ts +116 -11
- package/src/queueLifecycle.ts +3 -4
- package/src/rateBudget.ts +1115 -0
- package/src/refs.ts +9 -10
- package/src/remote-execute.ts +16 -0
- package/src/scenario.ts +387 -0
- package/src/serve.ts +397 -15
- package/src/shadow.ts +86 -7
- package/src/storage.ts +76 -147
- package/src/sync.ts +63 -17
- package/src/twin-fetch.ts +115 -0
- package/src/validate.ts +6 -5
- package/src/world-clock.ts +33 -0
- package/src/world-store.ts +482 -0
- package/src/worldConfig.ts +4 -3
- package/dist/src/actions.d.ts +0 -138
- package/dist/src/actions.js +0 -201
- package/dist/src/args.d.ts +0 -3
- package/dist/src/args.js +0 -12
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.js +0 -425
- package/dist/src/connector.d.ts +0 -106
- package/dist/src/connector.js +0 -129
- package/dist/src/control-plane.d.ts +0 -21
- package/dist/src/control-plane.js +0 -40
- package/dist/src/egress.d.ts +0 -93
- package/dist/src/egress.js +0 -264
- package/dist/src/fork.d.ts +0 -126
- package/dist/src/fork.js +0 -206
- package/dist/src/index.d.ts +0 -42
- package/dist/src/index.js +0 -52
- package/dist/src/lease.d.ts +0 -50
- package/dist/src/lease.js +0 -80
- package/dist/src/packRegistry.d.ts +0 -34
- package/dist/src/packRegistry.js +0 -22
- package/dist/src/plan.d.ts +0 -97
- package/dist/src/plan.js +0 -151
- package/dist/src/proxy.d.ts +0 -25
- package/dist/src/proxy.js +0 -152
- package/dist/src/pushLedger.d.ts +0 -81
- package/dist/src/pushLedger.js +0 -130
- package/dist/src/queueLifecycle.d.ts +0 -62
- package/dist/src/queueLifecycle.js +0 -95
- package/dist/src/reconcile.d.ts +0 -58
- package/dist/src/reconcile.js +0 -137
- package/dist/src/refs.d.ts +0 -29
- package/dist/src/refs.js +0 -68
- package/dist/src/schemas.d.ts +0 -78
- package/dist/src/schemas.js +0 -50
- package/dist/src/serve.d.ts +0 -44
- package/dist/src/serve.js +0 -93
- package/dist/src/shadow.d.ts +0 -77
- package/dist/src/shadow.js +0 -138
- package/dist/src/status.d.ts +0 -31
- package/dist/src/status.js +0 -42
- package/dist/src/storage.d.ts +0 -119
- package/dist/src/storage.js +0 -535
- package/dist/src/sync.d.ts +0 -91
- package/dist/src/sync.js +0 -121
- package/dist/src/types.d.ts +0 -40
- package/dist/src/types.js +0 -1
- package/dist/src/validate.d.ts +0 -27
- package/dist/src/validate.js +0 -68
- package/dist/src/visualizer.d.ts +0 -13
- package/dist/src/visualizer.js +0 -133
- package/dist/src/worldConfig.d.ts +0 -9
- 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
|
-
|
|
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
|
|
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>)[
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
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 (!
|
|
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
|
-
|
|
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) => {
|
package/src/control-plane.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|