@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/dist/src/actions.js
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
// Transaction/action log (scorecard R18) — the semantic correction that separates
|
|
2
|
-
// OBSERVED facts from LOCAL transaction commits.
|
|
3
|
-
//
|
|
4
|
-
// The observed-event log (`events.jsonl`) holds only what was observed upstream
|
|
5
|
-
// (connector pulls) or confirmed after a push. Local simulator/fork writes do NOT
|
|
6
|
-
// go there — they are transaction commits in `actions.jsonl`, projected OVER the
|
|
7
|
-
// observed mirror to produce the twin's current state. Undo is a `revert` commit;
|
|
8
|
-
// a push that succeeds appends a `confirm` commit mapping the local transaction
|
|
9
|
-
// to the observed event it produced, which SUPPRESSES the local projection (the
|
|
10
|
-
// fact is now carried by the observed log, so it must not be double-counted).
|
|
11
|
-
//
|
|
12
|
-
// Projection = observed mirror, then apply each `set` transaction in order,
|
|
13
|
-
// skipping any transaction that was reverted or confirmed.
|
|
14
|
-
import { randomUUID } from 'node:crypto';
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
16
|
-
import { dirname, join } from 'node:path';
|
|
17
|
-
import { buildShadowState } from "./shadow.js";
|
|
18
|
-
import { appendDurable, appendEvent, twinLog, withFileLock, worldPaths } from "./storage.js";
|
|
19
|
-
export class TwinActionPreconditionError extends Error {
|
|
20
|
-
actionId;
|
|
21
|
-
failed;
|
|
22
|
-
constructor(actionId, failed) {
|
|
23
|
-
super(`Twin transaction precondition failed for ${actionId}: ${failed.subject.type}:${failed.subject.id}.${failed.field} ${failed.op}`);
|
|
24
|
-
this.actionId = actionId;
|
|
25
|
-
this.failed = failed;
|
|
26
|
-
this.name = 'TwinActionPreconditionError';
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
function actionsPath(service, root) {
|
|
30
|
-
return join(dirname(worldPaths(service, root).events), 'actions.jsonl');
|
|
31
|
-
}
|
|
32
|
-
const actionsLock = (service, root) => `${actionsPath(service, root)}.lock`;
|
|
33
|
-
/** Every appended action carries a correlationId — generate one when the caller
|
|
34
|
-
* hasn't supplied it, so downstream joins (push ledger, logs) always have an id
|
|
35
|
-
* to key on (D3). Preserves a caller-supplied id (e.g. propagated from an HTTP
|
|
36
|
-
* request id) so a whole call chain can share one. */
|
|
37
|
-
function withCorrelationId(action) {
|
|
38
|
-
return action.correlationId ? action : { ...action, correlationId: randomUUID() };
|
|
39
|
-
}
|
|
40
|
-
function appendActionRaw(action, root) {
|
|
41
|
-
const path = actionsPath(action.service, root);
|
|
42
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
43
|
-
appendDurable(path, `${JSON.stringify(action)}\n`);
|
|
44
|
-
twinLog('action.append', { service: action.service, id: action.id, op: action.op, correlationId: action.correlationId });
|
|
45
|
-
}
|
|
46
|
-
export function appendAction(action, root) {
|
|
47
|
-
const stamped = withCorrelationId(action);
|
|
48
|
-
assertPreconditions(stamped, root);
|
|
49
|
-
// Cross-process line-atomic append (same lock the dedup path uses, so writes from a
|
|
50
|
-
// backend + a browser proxy sharing one twin can't interleave or race a check-then-append).
|
|
51
|
-
withFileLock(actionsLock(stamped.service, root), () => appendActionRaw(stamped, root));
|
|
52
|
-
return stamped;
|
|
53
|
-
}
|
|
54
|
-
/** Append `action` only if no action with the same id already exists — the whole
|
|
55
|
-
* check-then-append runs under the actions lock, so it's atomic across processes (two
|
|
56
|
-
* concurrent identical writes converge to ONE action; distinct writes both land). */
|
|
57
|
-
export function appendActionIfAbsent(action, root) {
|
|
58
|
-
const stamped = withCorrelationId(action);
|
|
59
|
-
assertPreconditions(stamped, root);
|
|
60
|
-
return withFileLock(actionsLock(stamped.service, root), () => {
|
|
61
|
-
const exists = listActions(stamped.service, root).some((a) => a.id === stamped.id);
|
|
62
|
-
if (!exists)
|
|
63
|
-
appendActionRaw(stamped, root);
|
|
64
|
-
return { action: stamped, appended: !exists };
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
export function appendTransactionCommit(commit, root) {
|
|
68
|
-
return appendAction(commit, root);
|
|
69
|
-
}
|
|
70
|
-
export function listActions(service, root) {
|
|
71
|
-
const path = actionsPath(service, root);
|
|
72
|
-
if (!existsSync(path))
|
|
73
|
-
return [];
|
|
74
|
-
return readFileSync(path, 'utf8')
|
|
75
|
-
.split('\n')
|
|
76
|
-
.filter((line) => line.trim())
|
|
77
|
-
.map((line) => JSON.parse(line));
|
|
78
|
-
}
|
|
79
|
-
const META = new Set(['id', 'type', 'updatedAt']);
|
|
80
|
-
function projectedField(resource, field) {
|
|
81
|
-
if (!resource)
|
|
82
|
-
return undefined;
|
|
83
|
-
if (field === 'id' || field === 'type' || field === 'updatedAt')
|
|
84
|
-
return resource[field];
|
|
85
|
-
return resource[field];
|
|
86
|
-
}
|
|
87
|
-
function assertPreconditions(action, root) {
|
|
88
|
-
if (!action.preconditions?.length)
|
|
89
|
-
return;
|
|
90
|
-
const resources = projectResources(action.service, root);
|
|
91
|
-
for (const precondition of action.preconditions) {
|
|
92
|
-
const resource = resources.find((r) => r.type === precondition.subject.type && r.id === precondition.subject.id);
|
|
93
|
-
const actual = projectedField(resource, precondition.field);
|
|
94
|
-
const passes = precondition.op === 'exists' ? actual !== undefined
|
|
95
|
-
: precondition.op === 'not_exists' ? actual === undefined
|
|
96
|
-
: precondition.op === 'eq' || precondition.op === 'version_eq' ? Object.is(actual, precondition.value)
|
|
97
|
-
: precondition.op === 'neq' ? !Object.is(actual, precondition.value)
|
|
98
|
-
: false;
|
|
99
|
-
if (!passes)
|
|
100
|
-
throw new TwinActionPreconditionError(action.id, precondition);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* Project the action log over the observed mirror → current twin resources.
|
|
105
|
-
* `set` actions overlay fields (creating subjects that don't exist in the mirror);
|
|
106
|
-
* reverted and confirmed actions are skipped (confirmed facts come from the
|
|
107
|
-
* observed log instead, so they are not projected twice).
|
|
108
|
-
*/
|
|
109
|
-
export function projectResources(service, root) {
|
|
110
|
-
// base: observed mirror (events.jsonl only)
|
|
111
|
-
const mirror = buildShadowState(service, () => null, root);
|
|
112
|
-
const subjects = new Map();
|
|
113
|
-
for (const s of Object.values(mirror.subjects)) {
|
|
114
|
-
subjects.set(`${s.subject.type}:${s.subject.id}`, { type: s.subject.type, id: s.subject.id, updatedAt: s.updatedAt, fields: { ...s.fields } });
|
|
115
|
-
}
|
|
116
|
-
const actions = listActions(service, root);
|
|
117
|
-
const reverted = new Set();
|
|
118
|
-
const confirmed = new Set();
|
|
119
|
-
for (const a of actions) {
|
|
120
|
-
if (a.op === 'revert' && a.revertsActionId)
|
|
121
|
-
reverted.add(a.revertsActionId);
|
|
122
|
-
if (a.op === 'confirm' && a.confirmsActionId)
|
|
123
|
-
confirmed.add(a.confirmsActionId);
|
|
124
|
-
}
|
|
125
|
-
const overlay = (type, id, fields, at) => {
|
|
126
|
-
const key = `${type}:${id}`;
|
|
127
|
-
const existing = subjects.get(key) ?? { type, id, updatedAt: at, fields: {} };
|
|
128
|
-
subjects.set(key, { ...existing, updatedAt: at, fields: { ...existing.fields, ...fields } });
|
|
129
|
-
};
|
|
130
|
-
for (const a of actions) {
|
|
131
|
-
if (a.op !== 'set')
|
|
132
|
-
continue;
|
|
133
|
-
if (reverted.has(a.id) || confirmed.has(a.id))
|
|
134
|
-
continue; // suppressed
|
|
135
|
-
// single-resource shorthand: overlay `fields` on the action's own subject.
|
|
136
|
-
if (a.fields)
|
|
137
|
-
overlay(a.subject.type, a.subject.id, a.fields, a.occurredAt);
|
|
138
|
-
// richer multi-resource transaction projection (applied in order; deletes remove).
|
|
139
|
-
if (a.projection) {
|
|
140
|
-
for (const c of a.projection.creates ?? [])
|
|
141
|
-
overlay(c.type, c.id, c.fields, a.occurredAt);
|
|
142
|
-
for (const u of a.projection.updates ?? [])
|
|
143
|
-
overlay(u.type, u.id, u.fields, a.occurredAt);
|
|
144
|
-
for (const d of a.projection.deletes ?? [])
|
|
145
|
-
subjects.delete(`${d.type}:${d.id}`);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return [...subjects.values()].map((s) => {
|
|
149
|
-
const out = { id: s.id, type: s.type, updatedAt: s.updatedAt };
|
|
150
|
-
for (const [k, v] of Object.entries(s.fields))
|
|
151
|
-
if (!META.has(k))
|
|
152
|
-
out[k] = v;
|
|
153
|
-
return out;
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* Confirm a local action after it was pushed to the real vendor (R18): record the
|
|
158
|
-
* confirmed fields as an OBSERVED event (origin 'external' — it's now real) and
|
|
159
|
-
* append a `confirm` action mapping the local action → that observed event id.
|
|
160
|
-
* Projection then drops the local action (the fact lives in the observed log), so
|
|
161
|
-
* the change is counted exactly once. Returns the observed event id.
|
|
162
|
-
*/
|
|
163
|
-
export function confirmAction(opts) {
|
|
164
|
-
const observedEventId = `confirmed:${opts.service}:${opts.subject.type}:${opts.subject.id}:${opts.actionId}`;
|
|
165
|
-
appendEvent({
|
|
166
|
-
id: observedEventId,
|
|
167
|
-
service: opts.service,
|
|
168
|
-
type: `${opts.service}.${opts.subject.type}.delta`,
|
|
169
|
-
schemaVersion: 1,
|
|
170
|
-
idempotencyKey: observedEventId,
|
|
171
|
-
occurredAt: opts.occurredAt,
|
|
172
|
-
observedAt: opts.occurredAt,
|
|
173
|
-
origin: 'external', // confirmed by the real vendor → an observed fact
|
|
174
|
-
subject: opts.subject,
|
|
175
|
-
data: { changed: Object.fromEntries(Object.entries(opts.fields).map(([k, v]) => [k, { after: v }])) },
|
|
176
|
-
}, opts.root);
|
|
177
|
-
appendAction({ id: `confirm:${opts.actionId}`, service: opts.service, op: 'confirm', subject: opts.subject, occurredAt: opts.occurredAt, confirmsActionId: opts.actionId, observedEventId }, opts.root);
|
|
178
|
-
return { observedEventId };
|
|
179
|
-
}
|
|
180
|
-
/** Local pending actions (set, not reverted, not yet confirmed) — the divergence from the mirror. */
|
|
181
|
-
export function pendingActions(service, root) {
|
|
182
|
-
const actions = listActions(service, root);
|
|
183
|
-
const reverted = new Set(actions.filter((a) => a.op === 'revert').map((a) => a.revertsActionId));
|
|
184
|
-
const confirmed = new Set(actions.filter((a) => a.op === 'confirm').map((a) => a.confirmsActionId));
|
|
185
|
-
return actions.filter((a) => a.op === 'set' && !reverted.has(a.id) && !confirmed.has(a.id));
|
|
186
|
-
}
|
|
187
|
-
/**
|
|
188
|
-
* Deliveries (webhooks/events/notifications) the active transactions want fired —
|
|
189
|
-
* the `projection.emits` of pending `set` actions, in order. A twin's event layer
|
|
190
|
-
* reads these to know what to deliver; projecting state ignores emits (side effects).
|
|
191
|
-
*/
|
|
192
|
-
export function pendingEmits(service, root) {
|
|
193
|
-
const out = [];
|
|
194
|
-
for (const a of pendingActions(service, root)) {
|
|
195
|
-
for (const d of a.projection?.emits ?? [])
|
|
196
|
-
out.push({ actionId: a.id, delivery: d });
|
|
197
|
-
}
|
|
198
|
-
return out;
|
|
199
|
-
}
|
|
200
|
-
export const listTransactionCommits = listActions;
|
|
201
|
-
export const pendingTransactionCommits = pendingActions;
|
package/dist/src/args.d.ts
DELETED
package/dist/src/args.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
// Shared CLI argument helpers for world scripts and tools. Scripts kept
|
|
2
|
-
// growing private copies of these (5 in the sync subsystem alone before this
|
|
3
|
-
// file); a parsing fix that lands in one copy and not the others corrupts
|
|
4
|
-
// flag handling silently.
|
|
5
|
-
/** Value of `--name <value>`; fallback when the flag is absent or has no value. */
|
|
6
|
-
export function optionValue(args, name, fallback = '') {
|
|
7
|
-
const index = args.indexOf(name);
|
|
8
|
-
return index >= 0 && index + 1 < args.length ? args[index + 1] : fallback;
|
|
9
|
-
}
|
|
10
|
-
export function hasFlag(args, name) {
|
|
11
|
-
return args.includes(name);
|
|
12
|
-
}
|
package/dist/src/cli.d.ts
DELETED
package/dist/src/cli.js
DELETED
|
@@ -1,425 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { hasFlag, optionValue } from "./args.js";
|
|
4
|
-
import { appendEvent, listEgressLedger, listEvents, listUnreconciledWriteIntents, loadState, performExternalWrite, rebuildGenericState, validateWorld, worldPaths, createTwinServer, resolveTwinRead, forkTwin, forkDivergence, auditForkNoRealWrites, createVisualizerServer, renderTwinHtml, scrubService, scrubWorld, syncPull, reconcile, worldStatus, formatStatus, listRemoteRefs, pendingActions, pendingConflicts, createTwinProxy, getPack, } from "./index.js";
|
|
5
|
-
import { readForkMeta } from "./fork.js";
|
|
6
|
-
import { twinResources } from "./serve.js";
|
|
7
|
-
function jsonField(stdout, path) {
|
|
8
|
-
let parsed;
|
|
9
|
-
try {
|
|
10
|
-
parsed = JSON.parse(stdout);
|
|
11
|
-
}
|
|
12
|
-
catch {
|
|
13
|
-
throw new Error('world egress write: command output is not JSON; omit --id-field to use raw stdout');
|
|
14
|
-
}
|
|
15
|
-
// Comma-separated paths compose an id from multiple fields, joined with ':'
|
|
16
|
-
// (e.g. --id-field channel,ts → "C123:1718000.42").
|
|
17
|
-
const parts = path.split(',').map((segmentPath) => {
|
|
18
|
-
let value = parsed;
|
|
19
|
-
for (const segment of segmentPath.trim().split('.')) {
|
|
20
|
-
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
21
|
-
return '';
|
|
22
|
-
value = value[segment];
|
|
23
|
-
}
|
|
24
|
-
return typeof value === 'string' || typeof value === 'number' ? String(value) : '';
|
|
25
|
-
});
|
|
26
|
-
return parts.every(Boolean) ? parts.join(':') : '';
|
|
27
|
-
}
|
|
28
|
-
function readJsonArg(args) {
|
|
29
|
-
const file = optionValue(args, '--file');
|
|
30
|
-
if (file)
|
|
31
|
-
return JSON.parse(readFileSync(file, 'utf8'));
|
|
32
|
-
const json = optionValue(args, '--json');
|
|
33
|
-
if (json)
|
|
34
|
-
return JSON.parse(json);
|
|
35
|
-
throw new Error('Expected --file <path> or --json <json>');
|
|
36
|
-
}
|
|
37
|
-
function printHelp() {
|
|
38
|
-
process.stdout.write(`Usage: world <resource> <action> [args...]
|
|
39
|
-
|
|
40
|
-
Resources:
|
|
41
|
-
events append, list
|
|
42
|
-
state get, rebuild
|
|
43
|
-
paths show
|
|
44
|
-
egress write, ledger, unreconciled
|
|
45
|
-
validate (egress reconciliation; annotations are a tracker concern now)
|
|
46
|
-
status <service> plan <service> refs <service>
|
|
47
|
-
scrub <service> | --all [--force] [--root <path>] delete pulled data at rest
|
|
48
|
-
|
|
49
|
-
Examples:
|
|
50
|
-
world events append --file /tmp/event.json
|
|
51
|
-
world events list chat --json
|
|
52
|
-
world state rebuild chat
|
|
53
|
-
world validate [--root <path>] [--services chat,github]
|
|
54
|
-
world egress write chat --operation message.send --provider slack \\
|
|
55
|
-
--subject-type channel --subject-id dev --key case:ENG-1:approval-request \\
|
|
56
|
-
--id-field id -- bash tools/external/slack/slack message send dev --user otto --text "..." --json
|
|
57
|
-
world egress unreconciled chat
|
|
58
|
-
world scrub chat # delete that service pulled event log + state (refuses on foreign contents)
|
|
59
|
-
world scrub --all --force # delete the whole world state dir, even if it looks unfamiliar
|
|
60
|
-
See docs/DATA_AT_REST.md for the full data-at-rest story (redaction, retention, purge).
|
|
61
|
-
`);
|
|
62
|
-
}
|
|
63
|
-
function print(value, asJson) {
|
|
64
|
-
if (asJson)
|
|
65
|
-
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
66
|
-
else
|
|
67
|
-
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
68
|
-
}
|
|
69
|
-
async function main() {
|
|
70
|
-
const args = process.argv.slice(2);
|
|
71
|
-
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
72
|
-
printHelp();
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
const [resource, action, ...rest] = args;
|
|
76
|
-
const asJson = hasFlag(rest, '--json');
|
|
77
|
-
if (resource === 'events' && action === 'append') {
|
|
78
|
-
const event = readJsonArg(rest);
|
|
79
|
-
print(appendEvent(event), asJson);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
if (resource === 'events' && action === 'list') {
|
|
83
|
-
const service = rest[0];
|
|
84
|
-
if (!service)
|
|
85
|
-
throw new Error('world events list: missing service');
|
|
86
|
-
const events = listEvents(service);
|
|
87
|
-
if (asJson)
|
|
88
|
-
print(events, true);
|
|
89
|
-
else {
|
|
90
|
-
for (const event of events) {
|
|
91
|
-
process.stdout.write(`${event.id}\t${event.type}\t${event.subject.type}:${event.subject.id}\n`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
if (resource === 'state' && action === 'get') {
|
|
97
|
-
const service = rest[0];
|
|
98
|
-
if (!service)
|
|
99
|
-
throw new Error('world state get: missing service');
|
|
100
|
-
print(loadState(service), true);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (resource === 'state' && action === 'rebuild') {
|
|
104
|
-
const service = rest[0];
|
|
105
|
-
if (!service)
|
|
106
|
-
throw new Error('world state rebuild: missing service');
|
|
107
|
-
print(rebuildGenericState(service), true);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
// Operator surface (R20): world status / plan / refs.
|
|
111
|
-
if (resource === 'status') {
|
|
112
|
-
const service = action;
|
|
113
|
-
if (!service)
|
|
114
|
-
throw new Error('world status: missing service (e.g. `world status linear`)');
|
|
115
|
-
const provider = optionValue(rest, '--provider') || undefined;
|
|
116
|
-
const forkId = optionValue(rest, '--fork') || undefined;
|
|
117
|
-
const status = worldStatus(service, { ...(provider ? { provider } : {}), ...(forkId ? { forkId } : {}) });
|
|
118
|
-
if (asJson)
|
|
119
|
-
print(status, true);
|
|
120
|
-
else
|
|
121
|
-
process.stdout.write(`${formatStatus(status)}\n`);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
if (resource === 'plan') {
|
|
125
|
-
// Kernel-level (transaction) plan: the pending local commits + any conflicts.
|
|
126
|
-
// The provider-call mapping is a vendor-pack concern (see buildApplyPlan).
|
|
127
|
-
const service = action;
|
|
128
|
-
if (!service)
|
|
129
|
-
throw new Error('world plan: missing service');
|
|
130
|
-
const transactions = pendingActions(service).map((a) => ({ id: a.id, operation: a.operation, subject: a.subject }));
|
|
131
|
-
const conflicts = pendingConflicts(service);
|
|
132
|
-
print({ service, transactions, conflicts, requiresApproval: conflicts.length > 0 }, true);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (resource === 'refs') {
|
|
136
|
-
const service = action;
|
|
137
|
-
if (!service)
|
|
138
|
-
throw new Error('world refs: missing service');
|
|
139
|
-
print(listRemoteRefs(service), true);
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
// State INSPECTION (not the vendor API): a generic read server over a twin's mirrored
|
|
143
|
-
// state — `volter-twin inspect <service>` answers generic `/<type>/<id>` reads for
|
|
144
|
-
// scripting/debugging, and `--once <path>` resolves a single read and prints it.
|
|
145
|
-
// This is NOT the vendor SDK surface — point your real SDK at `world-<vendor> serve`.
|
|
146
|
-
if (resource === 'inspect') {
|
|
147
|
-
const service = action; // `volter-twin inspect <service> ...`
|
|
148
|
-
if (!service)
|
|
149
|
-
throw new Error('volter-twin inspect: missing service (e.g. `volter-twin inspect linear`). NOTE: this serves generic state reads, not the vendor API — use `world-<vendor> serve` for that.');
|
|
150
|
-
const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
|
|
151
|
-
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
152
|
-
const once = optionValue(rest, '--once');
|
|
153
|
-
if (once) {
|
|
154
|
-
print(resolveTwinRead(service, once), true);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
const server = createTwinServer({ service, readOnly, ...(port ? { port } : {}) });
|
|
158
|
-
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`);
|
|
159
|
-
await new Promise(() => { }); // serve until killed
|
|
160
|
-
}
|
|
161
|
-
// Browser zero-edit injection: a dev proxy in front of your app that forwards
|
|
162
|
-
// the browser's vendor SDK calls to a twin (so browser + backend share one twin).
|
|
163
|
-
// The proxy needs each vendor's browser routing (api path + loader host). It reads that
|
|
164
|
-
// from the pack's TwinPack.browserRouting when the pack is registered; otherwise pass it
|
|
165
|
-
// explicitly (the kernel itself knows nothing vendor-specific):
|
|
166
|
-
// volter-twin proxy --target http://localhost:3000 \
|
|
167
|
-
// --map stripe=http://127.0.0.1:12111 --route stripe=/v1/ --loader-host stripe=https://api.stripe.com
|
|
168
|
-
if (resource === 'proxy') {
|
|
169
|
-
const target = optionValue(rest, '--target');
|
|
170
|
-
if (!target)
|
|
171
|
-
throw new Error("world proxy: --target <app origin> is required (e.g. --target http://localhost:3000)");
|
|
172
|
-
// Collect repeated --map / --route / --loader-host vendor=value flags.
|
|
173
|
-
const collect = (flag) => {
|
|
174
|
-
const out = {};
|
|
175
|
-
for (let i = 0; i < rest.length; i += 1) {
|
|
176
|
-
if (rest[i] === flag && rest[i + 1]) {
|
|
177
|
-
const eq = rest[i + 1].indexOf('=');
|
|
178
|
-
if (eq > 0)
|
|
179
|
-
out[rest[i + 1].slice(0, eq).trim()] = rest[i + 1].slice(eq + 1).trim();
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return out;
|
|
183
|
-
};
|
|
184
|
-
const origins = collect('--map');
|
|
185
|
-
const routeFlags = collect('--route');
|
|
186
|
-
const loaderFlags = collect('--loader-host');
|
|
187
|
-
if (Object.keys(origins).length === 0)
|
|
188
|
-
throw new Error('world proxy: at least one --map <vendor>=<twin-url> is required');
|
|
189
|
-
const map = {};
|
|
190
|
-
for (const [vendor, origin] of Object.entries(origins)) {
|
|
191
|
-
const reg = getPack(vendor)?.browserRouting; // vendor knowledge lives in the pack, not the kernel
|
|
192
|
-
const apiPathPrefix = routeFlags[vendor] ?? reg?.apiPathPrefix;
|
|
193
|
-
const loaderHost = loaderFlags[vendor] ?? reg?.loaderHost;
|
|
194
|
-
if (!apiPathPrefix)
|
|
195
|
-
throw new Error(`world proxy: don't know ${vendor}'s browser API path. Pass --route ${vendor}=/<prefix> ` +
|
|
196
|
-
`(and optionally --loader-host ${vendor}=https://<host>), or register the ${vendor} pack ` +
|
|
197
|
-
`so its TwinPack.browserRouting is used.`);
|
|
198
|
-
map[vendor] = { origin, apiPathPrefix, ...(loaderHost ? { loaderHost } : {}) };
|
|
199
|
-
}
|
|
200
|
-
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
201
|
-
const proxy = createTwinProxy({ target, map, ...(port ? { port } : {}) });
|
|
202
|
-
const pairs = Object.entries(map).map(([v, r]) => `${v}(${r.apiPathPrefix})→${r.origin}`).join(', ');
|
|
203
|
-
process.stdout.write(`twin proxy on http://127.0.0.1:${proxy.port} → ${target} (forwarding ${pairs})\n`);
|
|
204
|
-
await new Promise(() => { }); // serve until killed
|
|
205
|
-
}
|
|
206
|
-
// NOTE: `world annotations` moved to the tracker — annotations are a tracker
|
|
207
|
-
// (verification) concern. Use @volter/tracker/world-annotations.
|
|
208
|
-
if (resource === 'egress' && action === 'write') {
|
|
209
|
-
const service = rest[0];
|
|
210
|
-
if (!service)
|
|
211
|
-
throw new Error('world egress write: missing service');
|
|
212
|
-
const splitIndex = rest.indexOf('--');
|
|
213
|
-
if (splitIndex < 0 || splitIndex === rest.length - 1) {
|
|
214
|
-
throw new Error('world egress write: provide the provider command after --');
|
|
215
|
-
}
|
|
216
|
-
const flags = rest.slice(0, splitIndex);
|
|
217
|
-
const command = rest.slice(splitIndex + 1);
|
|
218
|
-
const operation = optionValue(flags, '--operation');
|
|
219
|
-
const provider = optionValue(flags, '--provider');
|
|
220
|
-
const subjectType = optionValue(flags, '--subject-type');
|
|
221
|
-
const subjectId = optionValue(flags, '--subject-id');
|
|
222
|
-
const idempotencyKey = optionValue(flags, '--key');
|
|
223
|
-
if (!operation || !provider || !subjectType || !subjectId || !idempotencyKey) {
|
|
224
|
-
throw new Error('world egress write: --operation, --provider, --subject-type, --subject-id, and --key are required');
|
|
225
|
-
}
|
|
226
|
-
const idField = optionValue(flags, '--id-field');
|
|
227
|
-
// For operations whose provider output carries no id (e.g. reaction add),
|
|
228
|
-
// the caller supplies the deterministic external id up front.
|
|
229
|
-
const explicitExternalId = optionValue(flags, '--external-id');
|
|
230
|
-
const urlField = optionValue(flags, '--url-field');
|
|
231
|
-
const dataJson = optionValue(flags, '--data');
|
|
232
|
-
const onUnreconciled = optionValue(flags, '--unreconciled') === 'retry' ? 'retry' : 'fail';
|
|
233
|
-
// --passthrough makes the wrapper invisible to callers: the wrapped
|
|
234
|
-
// command's stdout is reproduced verbatim and the egress record goes to
|
|
235
|
-
// stderr. Replays reproduce the recorded stdout.
|
|
236
|
-
const passthrough = hasFlag(flags, '--passthrough');
|
|
237
|
-
// On a live write, passthrough must reproduce the wrapped command's
|
|
238
|
-
// stdout VERBATIM; the recorded copy is capped (replays of very large
|
|
239
|
-
// outputs come back truncated, which the egress note on stderr flags).
|
|
240
|
-
let liveStdout = null;
|
|
241
|
-
const result = await performExternalWrite({
|
|
242
|
-
service,
|
|
243
|
-
operation,
|
|
244
|
-
provider,
|
|
245
|
-
subject: { type: subjectType, id: subjectId },
|
|
246
|
-
idempotencyKey,
|
|
247
|
-
...(dataJson ? { data: JSON.parse(dataJson) } : {}),
|
|
248
|
-
}, async () => {
|
|
249
|
-
const proc = Bun.spawnSync(command, { stdout: 'pipe', stderr: 'pipe' });
|
|
250
|
-
const stdout = proc.stdout.toString();
|
|
251
|
-
const stderr = proc.stderr.toString();
|
|
252
|
-
if (proc.exitCode !== 0) {
|
|
253
|
-
throw new Error(`${command[0]} exited with code ${proc.exitCode}: ${stderr.trim() || stdout.trim()}`);
|
|
254
|
-
}
|
|
255
|
-
const externalId = explicitExternalId
|
|
256
|
-
|| (idField ? jsonField(stdout, idField) : stdout.trim().split('\n').at(-1)?.trim() ?? '');
|
|
257
|
-
if (!externalId)
|
|
258
|
-
throw new Error(`world egress write: could not extract external id from command output${idField ? ` (field ${idField})` : ''}`);
|
|
259
|
-
const url = urlField ? jsonField(stdout, urlField) : '';
|
|
260
|
-
liveStdout = stdout;
|
|
261
|
-
return {
|
|
262
|
-
externalId,
|
|
263
|
-
...(url ? { url } : {}),
|
|
264
|
-
data: { stdout: stdout.trim().slice(0, 65536), stdoutTruncated: stdout.trim().length > 65536 },
|
|
265
|
-
};
|
|
266
|
-
}, { onUnreconciled });
|
|
267
|
-
if (passthrough) {
|
|
268
|
-
const stdout = liveStdout ?? (typeof result.outcome.data?.stdout === 'string' ? result.outcome.data.stdout : '');
|
|
269
|
-
if (stdout)
|
|
270
|
-
process.stdout.write(stdout.endsWith('\n') ? stdout : `${stdout}\n`);
|
|
271
|
-
process.stderr.write(`${JSON.stringify({ egress: { status: result.status, intentEventId: result.intentEventId, externalId: result.outcome.externalId, ...(result.outcome.data?.stdoutTruncated ? { replayStdoutTruncated: true } : {}) } })}\n`);
|
|
272
|
-
}
|
|
273
|
-
else {
|
|
274
|
-
print(result, true);
|
|
275
|
-
}
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
if (resource === 'egress' && action === 'ledger') {
|
|
279
|
-
const service = rest[0];
|
|
280
|
-
if (!service)
|
|
281
|
-
throw new Error('world egress ledger: missing service');
|
|
282
|
-
print(listEgressLedger(service), true);
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
if (resource === 'egress' && action === 'unreconciled') {
|
|
286
|
-
const service = rest[0];
|
|
287
|
-
if (!service)
|
|
288
|
-
throw new Error('world egress unreconciled: missing service');
|
|
289
|
-
const intents = listUnreconciledWriteIntents(service);
|
|
290
|
-
print(intents, true);
|
|
291
|
-
if (intents.length > 0)
|
|
292
|
-
process.exitCode = 2;
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
if (resource === 'validate') {
|
|
296
|
-
const validateArgs = [action, ...rest].filter((arg) => typeof arg === 'string');
|
|
297
|
-
const rootOption = optionValue(validateArgs, '--root');
|
|
298
|
-
const servicesOption = optionValue(validateArgs, '--services');
|
|
299
|
-
const report = validateWorld({
|
|
300
|
-
...(rootOption ? { root: rootOption } : {}),
|
|
301
|
-
...(servicesOption ? { services: servicesOption.split(',').map((item) => item.trim()).filter(Boolean) } : {}),
|
|
302
|
-
});
|
|
303
|
-
print(report, true);
|
|
304
|
-
if (!report.valid)
|
|
305
|
-
process.exitCode = 1;
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
if (resource === 'paths' && action === 'show') {
|
|
309
|
-
const service = rest[0];
|
|
310
|
-
if (!service)
|
|
311
|
-
throw new Error('world paths show: missing service');
|
|
312
|
-
print(worldPaths(service), true);
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
// Scrub: delete pulled data at rest (TWIN-45 dev/02a). `world scrub <service>` removes just
|
|
316
|
-
// that service's event log/state; `world scrub --all` removes the whole world state dir.
|
|
317
|
-
// Both are plain `rm` (see docs/DATA_AT_REST.md) and both refuse unfamiliar contents unless
|
|
318
|
-
// --force is passed — never rm a path that doesn't look like our own state dir.
|
|
319
|
-
if (resource === 'scrub') {
|
|
320
|
-
const scrubArgs = [action, ...rest].filter((arg) => typeof arg === 'string');
|
|
321
|
-
const all = hasFlag(scrubArgs, '--all');
|
|
322
|
-
const force = hasFlag(scrubArgs, '--force');
|
|
323
|
-
const rootOption = optionValue(scrubArgs, '--root') || undefined;
|
|
324
|
-
if (all) {
|
|
325
|
-
print(scrubWorld({ ...(rootOption ? { root: rootOption } : {}), force }), true);
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
const service = action;
|
|
329
|
-
if (!service || service === '--force' || service === '--root') {
|
|
330
|
-
throw new Error('world scrub: missing service (e.g. `world scrub chat`), or pass --all to scrub the whole world state dir');
|
|
331
|
-
}
|
|
332
|
-
print(scrubService(service, { ...(rootOption ? { root: rootOption } : {}), force }), true);
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
// Sync: pull (real→twin) folds observed resources into the log; reconcile
|
|
336
|
-
// computes a three-way merge plan (base from fork-meta, fork from twin state,
|
|
337
|
-
// real from a provided snapshot). Push-to-real is library-only: it needs an
|
|
338
|
-
// injected real write fn carrying the user's own auth (auth-boundary), so it
|
|
339
|
-
// is intentionally NOT a CLI command.
|
|
340
|
-
if (resource === 'sync' && action === 'pull') {
|
|
341
|
-
const service = rest[0];
|
|
342
|
-
if (!service)
|
|
343
|
-
throw new Error('world sync pull: missing service');
|
|
344
|
-
const file = optionValue(rest, '--resources');
|
|
345
|
-
if (!file)
|
|
346
|
-
throw new Error('world sync pull: missing --resources <file.json> (array of {type,id,fields})');
|
|
347
|
-
const at = optionValue(rest, '--at') || new Date().toISOString();
|
|
348
|
-
const resources = JSON.parse(readFileSync(file, 'utf8'));
|
|
349
|
-
print(syncPull({ service, resources, occurredAt: at }), true);
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
if (resource === 'reconcile') {
|
|
353
|
-
const service = action;
|
|
354
|
-
if (!service)
|
|
355
|
-
throw new Error('world reconcile: missing service');
|
|
356
|
-
const forkRoot = optionValue(rest, '--root');
|
|
357
|
-
const realFile = optionValue(rest, '--real');
|
|
358
|
-
if (!forkRoot || !realFile)
|
|
359
|
-
throw new Error('world reconcile <service> --root <forkRoot> --real <file> [--policy hub-wins|twin-wins|merge]');
|
|
360
|
-
const policy = (optionValue(rest, '--policy') || 'hub-wins');
|
|
361
|
-
const base = readForkMeta(service, forkRoot).baseline;
|
|
362
|
-
const fork = twinResources(service, forkRoot);
|
|
363
|
-
const realRaw = JSON.parse(readFileSync(realFile, 'utf8'));
|
|
364
|
-
const real = realRaw.map((r) => (r.fields ? { id: r.id, type: r.type, updatedAt: '', ...r.fields } : r));
|
|
365
|
-
print(reconcile({ policy, base, fork, real }), true);
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
// Mirror UIs moved to the per-vendor twin packages (modular). Point users there.
|
|
369
|
-
if (resource === 'mirror') {
|
|
370
|
-
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).`);
|
|
371
|
-
}
|
|
372
|
-
// Visualizer (debug inspector): a generic status view of the twin. `world visualize <service>` serves it;
|
|
373
|
-
// `--once` prints the rendered HTML (scriptable).
|
|
374
|
-
if (resource === 'visualize') {
|
|
375
|
-
const service = action;
|
|
376
|
-
if (!service)
|
|
377
|
-
throw new Error('world visualize: missing service (e.g. `world visualize linear`)');
|
|
378
|
-
if (hasFlag(rest, '--once')) {
|
|
379
|
-
process.stdout.write(renderTwinHtml(service));
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
383
|
-
const server = createVisualizerServer({ service, ...(port ? { port } : {}) });
|
|
384
|
-
process.stdout.write(`${service} twin visualizer at http://127.0.0.1:${server.port}\n`);
|
|
385
|
-
await new Promise(() => { });
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
// Fork mode: `world fork create <service> --to <root> [--from <root>] --at <iso>`,
|
|
389
|
-
// `world fork divergence <service> --root <forkRoot>`,
|
|
390
|
-
// `world fork audit <service> --root <forkRoot>`.
|
|
391
|
-
if (resource === 'fork') {
|
|
392
|
-
const service = rest[0];
|
|
393
|
-
if (!service)
|
|
394
|
-
throw new Error('world fork: missing service');
|
|
395
|
-
if (action === 'create') {
|
|
396
|
-
const toRoot = optionValue(rest, '--to');
|
|
397
|
-
if (!toRoot)
|
|
398
|
-
throw new Error('world fork create: missing --to <root>');
|
|
399
|
-
const fromRoot = optionValue(rest, '--from') || undefined;
|
|
400
|
-
const at = optionValue(rest, '--at') || new Date().toISOString();
|
|
401
|
-
print(forkTwin({ service, toRoot, ...(fromRoot ? { fromRoot } : {}), occurredAt: at }), true);
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
const forkRoot = optionValue(rest, '--root');
|
|
405
|
-
if (!forkRoot)
|
|
406
|
-
throw new Error(`world fork ${action}: missing --root <forkRoot>`);
|
|
407
|
-
if (action === 'divergence') {
|
|
408
|
-
print(forkDivergence(service, forkRoot), true);
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
if (action === 'audit') {
|
|
412
|
-
const audit = auditForkNoRealWrites(service, forkRoot);
|
|
413
|
-
print(audit, true);
|
|
414
|
-
if (!audit.ok)
|
|
415
|
-
process.exitCode = 1;
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
throw new Error(`Unknown world fork action: ${action ?? ''}`);
|
|
419
|
-
}
|
|
420
|
-
throw new Error(`Unknown world command: ${resource ?? ''} ${action ?? ''}`.trim());
|
|
421
|
-
}
|
|
422
|
-
main().catch((error) => {
|
|
423
|
-
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
424
|
-
process.exit(1);
|
|
425
|
-
});
|