@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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Local twins of SaaS APIs
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Local twins of SaaS APIs \u2014 the kernel + control plane: append-only event log, projection, mirror/simulator/fork modes, egress write-ledger, connectors, and the operator control plane (status/plan/lease/refs/queue/fork). Point your real vendor SDK at a faithful, stateful local replica.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
7
7
|
"local",
|
|
@@ -23,9 +23,8 @@
|
|
|
23
23
|
"access": "public"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
|
-
"src",
|
|
27
|
-
"dist",
|
|
28
26
|
"inject.cjs",
|
|
27
|
+
"src",
|
|
29
28
|
"client",
|
|
30
29
|
"test-fixtures",
|
|
31
30
|
"README.md",
|
|
@@ -42,30 +41,21 @@
|
|
|
42
41
|
"homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/control-plane#readme",
|
|
43
42
|
"type": "module",
|
|
44
43
|
"exports": {
|
|
45
|
-
".":
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"./
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
},
|
|
53
|
-
"./schemas": {
|
|
54
|
-
"types": "./dist/src/schemas.d.ts",
|
|
55
|
-
"default": "./dist/src/schemas.js"
|
|
56
|
-
},
|
|
57
|
-
"./inject": "./inject.cjs"
|
|
44
|
+
".": "./src/index.ts",
|
|
45
|
+
"./args": "./src/args.ts",
|
|
46
|
+
"./lifecycle": "./src/lifecycle.ts",
|
|
47
|
+
"./schemas": "./src/schemas.ts",
|
|
48
|
+
"./inject": "./inject.cjs",
|
|
49
|
+
"./attach": "./attach.cjs",
|
|
50
|
+
"./storage": "./src/storage.ts"
|
|
58
51
|
},
|
|
59
52
|
"bin": {
|
|
60
|
-
"volter-twin": "
|
|
53
|
+
"volter-twin": "src/cli.ts"
|
|
61
54
|
},
|
|
62
55
|
"scripts": {
|
|
63
56
|
"cli": "bun run src/cli.ts",
|
|
64
57
|
"test": "bun test src/*.test.ts",
|
|
65
|
-
"typecheck": "tsc --noEmit"
|
|
66
|
-
"build": "node scripts/build.mjs",
|
|
67
|
-
"prepack": "node scripts/prepare-publish.mjs prepack",
|
|
68
|
-
"postpack": "node scripts/prepare-publish.mjs postpack"
|
|
58
|
+
"typecheck": "tsc --noEmit"
|
|
69
59
|
},
|
|
70
60
|
"dependencies": {
|
|
71
61
|
"zod": "^4.4.2"
|
package/src/actions.ts
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
// Projection = observed mirror, then apply each `set` transaction in order,
|
|
13
13
|
// skipping any transaction that was reverted or confirmed.
|
|
14
14
|
import { randomUUID } from 'node:crypto';
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
16
15
|
import { dirname, join } from 'node:path';
|
|
17
|
-
import { buildShadowState } from './shadow.ts';
|
|
16
|
+
import { buildShadowState, canonicalJson, hashFieldValue, remoteRefs, subjectKey } from './shadow.ts';
|
|
18
17
|
import type { SubjectFields } from './shadow.ts';
|
|
19
|
-
import { appendDurable,
|
|
18
|
+
import { appendDurable, appendEventLocked, eventsLockPath, projectionLockPath, twinLog, withFileLock, worldPaths } from './storage.ts';
|
|
19
|
+
import { WorldServiceEventSchema } from './schemas.ts';
|
|
20
|
+
import { getActiveWorldStore } from './world-store.ts';
|
|
20
21
|
import type { WorldServiceEvent } from './types.ts';
|
|
21
22
|
import type { TwinResource } from './serve.ts';
|
|
22
23
|
|
|
@@ -72,6 +73,8 @@ export type TwinAction = {
|
|
|
72
73
|
// op 'confirm': the prior action id now reflected as an observed event.
|
|
73
74
|
confirmsActionId?: string;
|
|
74
75
|
observedEventId?: string;
|
|
76
|
+
/** Every observed row materialized by a compound confirmation; observedEventId is the primary. */
|
|
77
|
+
observedEventIds?: string[];
|
|
75
78
|
/** Optional machine-readable hint for how a UI/pack should construct a revert. */
|
|
76
79
|
revert?: TwinActionRevertSpec;
|
|
77
80
|
/**
|
|
@@ -83,6 +86,16 @@ export type TwinAction = {
|
|
|
83
86
|
* alone, with no dependence on actionId/pushId naming conventions.
|
|
84
87
|
*/
|
|
85
88
|
correlationId?: string;
|
|
89
|
+
/**
|
|
90
|
+
* The action's MERGE BASE against the remote (runtime contract R14, non-fast-forward
|
|
91
|
+
* rule): per touched subject, the observed mirror's remote ref (shadow.ts remoteRefs)
|
|
92
|
+
* at authoring time — null when the mirror had never seen the subject. Stamped by the
|
|
93
|
+
* appenders on every `set` action; pushTransaction compares these refs again at push
|
|
94
|
+
* time and REFUSES when any has moved (someone else changed the remote), demanding
|
|
95
|
+
* fetch + reconcile. Authoring metadata like correlationId: excluded from replay
|
|
96
|
+
* identity, so an identical retry converges on the first commit's (older, safer) basis.
|
|
97
|
+
*/
|
|
98
|
+
shadowBasis?: Record<string, string | null>;
|
|
86
99
|
};
|
|
87
100
|
export type TwinTransactionCommit = TwinAction;
|
|
88
101
|
export type TwinTransactionCommitOp = TwinActionOp;
|
|
@@ -99,6 +112,7 @@ function actionsPath(service: string, root?: string): string {
|
|
|
99
112
|
return join(dirname(worldPaths(service, root).events), 'actions.jsonl');
|
|
100
113
|
}
|
|
101
114
|
const actionsLock = (service: string, root?: string): string => `${actionsPath(service, root)}.lock`;
|
|
115
|
+
const projectionLock = (service: string, root?: string): string => projectionLockPath(worldPaths(service, root));
|
|
102
116
|
|
|
103
117
|
/** Every appended action carries a correlationId — generate one when the caller
|
|
104
118
|
* hasn't supplied it, so downstream joins (push ledger, logs) always have an id
|
|
@@ -108,33 +122,123 @@ function withCorrelationId(action: TwinAction): TwinAction {
|
|
|
108
122
|
return action.correlationId ? action : { ...action, correlationId: randomUUID() };
|
|
109
123
|
}
|
|
110
124
|
|
|
125
|
+
/** Subjects a `set` action touches: its own subject, every projection resource, and every
|
|
126
|
+
* precondition subject — a precondition established the action's validity against that
|
|
127
|
+
* subject's state, so remote movement there is drift for this action too. */
|
|
128
|
+
function touchedSubjects(action: TwinAction): Array<{ type: string; id: string }> {
|
|
129
|
+
const out = new Map<string, { type: string; id: string }>();
|
|
130
|
+
out.set(subjectKey(action.subject), action.subject);
|
|
131
|
+
for (const r of [
|
|
132
|
+
...(action.projection?.creates ?? []),
|
|
133
|
+
...(action.projection?.updates ?? []),
|
|
134
|
+
...(action.projection?.deletes ?? []),
|
|
135
|
+
]) out.set(subjectKey(r), { type: r.type, id: r.id });
|
|
136
|
+
for (const p of action.preconditions ?? []) out.set(subjectKey(p.subject), p.subject);
|
|
137
|
+
return [...out.values()];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Stamp the merge base (R14): every appended `set` action records the remote ref of each
|
|
141
|
+
* subject it touches. Runs inside the appenders' projection lock, so the stamped refs
|
|
142
|
+
* and the precondition evaluation see the same mirror. */
|
|
143
|
+
function withShadowBasis(action: TwinAction, root?: string): TwinAction {
|
|
144
|
+
if (action.op !== 'set' || action.shadowBasis) return action;
|
|
145
|
+
return { ...action, shadowBasis: remoteRefs(action.service, touchedSubjects(action), root) };
|
|
146
|
+
}
|
|
147
|
+
|
|
111
148
|
function appendActionRaw(action: TwinAction, root?: string): void {
|
|
112
149
|
const path = actionsPath(action.service, root);
|
|
113
|
-
|
|
150
|
+
getActiveWorldStore().mkdir(dirname(path));
|
|
114
151
|
appendDurable(path, `${JSON.stringify(action)}\n`);
|
|
115
152
|
twinLog('action.append', { service: action.service, id: action.id, op: action.op, correlationId: action.correlationId });
|
|
116
153
|
}
|
|
117
154
|
|
|
155
|
+
function replayBody(action: TwinAction): string {
|
|
156
|
+
const { correlationId: _correlationId, shadowBasis: _shadowBasis, ...stampedBody } = action;
|
|
157
|
+
// A confirmation is content-addressed by the observed event ids. Its timestamp is observation
|
|
158
|
+
// metadata, just like the occurredAt/observedAt values that appendEvent ignores when replaying
|
|
159
|
+
// one content-addressed event. Two reconcilers confirming the same bytes at different wall-clock
|
|
160
|
+
// instants must therefore converge on the first durable confirmation rather than conflict.
|
|
161
|
+
const body = stampedBody.op === 'confirm'
|
|
162
|
+
? (({ occurredAt: _occurredAt, ...confirmation }) => confirmation)(stampedBody)
|
|
163
|
+
: stampedBody;
|
|
164
|
+
return canonicalJson(body);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function existingExactAction(action: TwinAction, root?: string): TwinAction | undefined {
|
|
168
|
+
const existing = listActions(action.service, root).find((candidate) => candidate.id === action.id);
|
|
169
|
+
if (!existing) return undefined;
|
|
170
|
+
if (replayBody(existing) !== replayBody(action)) {
|
|
171
|
+
throw new Error(`Conflicting duplicate twin action: ${action.service}/${action.id}`);
|
|
172
|
+
}
|
|
173
|
+
return existing;
|
|
174
|
+
}
|
|
175
|
+
|
|
118
176
|
export function appendAction(action: TwinAction, root?: string): TwinAction {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
withFileLock(
|
|
124
|
-
|
|
177
|
+
// Evaluate and append under the service projection and actions locks. A precondition is a
|
|
178
|
+
// compare-and-set, not an advisory validation: checking outside either lock would let another
|
|
179
|
+
// writer invalidate it before this action lands. The shadow basis is stamped under the same
|
|
180
|
+
// lock so the recorded merge base is the mirror the preconditions were checked against.
|
|
181
|
+
return withFileLock(projectionLock(action.service, root), () => withFileLock(actionsLock(action.service, root), () => {
|
|
182
|
+
const stamped = withShadowBasis(withCorrelationId(action), root);
|
|
183
|
+
assertPreconditions(stamped, root);
|
|
184
|
+
appendActionRaw(stamped, root);
|
|
185
|
+
return stamped;
|
|
186
|
+
}));
|
|
125
187
|
}
|
|
126
188
|
|
|
127
|
-
/** Append `action` only if no action with the same id already exists — the whole
|
|
128
|
-
*
|
|
129
|
-
* concurrent identical writes converge to ONE action; distinct
|
|
189
|
+
/** Append `action` only if no exact action with the same id already exists — the whole
|
|
190
|
+
* replay/precondition/append decision runs under the service projection + actions locks, so it's
|
|
191
|
+
* atomic across processes (two concurrent identical writes converge to ONE action; distinct
|
|
192
|
+
* writes both land). A reused id with different content fails loudly. */
|
|
130
193
|
export function appendActionIfAbsent(action: TwinAction, root?: string): { action: TwinAction; appended: boolean } {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
194
|
+
return withFileLock(projectionLock(action.service, root), () => withFileLock(actionsLock(action.service, root), () => {
|
|
195
|
+
const identified = withCorrelationId(action);
|
|
196
|
+
// An exact retry is already committed. Resolve it before re-evaluating author-time
|
|
197
|
+
// preconditions against the state that first commit intentionally changed — and
|
|
198
|
+
// before paying the basis stamp's event-log read (the replayed path stays cheap).
|
|
199
|
+
const existing = existingExactAction(identified, root);
|
|
200
|
+
if (existing) return { action: existing, appended: false };
|
|
201
|
+
const stamped = withShadowBasis(identified, root);
|
|
202
|
+
assertPreconditions(stamped, root);
|
|
203
|
+
appendActionRaw(stamped, root);
|
|
204
|
+
return { action: stamped, appended: true };
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type AtomicActionDecision<T> =
|
|
209
|
+
| { kind: 'skip'; value: T }
|
|
210
|
+
| { kind: 'append'; action: TwinAction; value: T };
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Evaluate current projected state and optionally append one action under ONE service projection
|
|
214
|
+
* transaction (the shared projection lock plus the action-log lock). This is the narrow
|
|
215
|
+
* state-dependent seam for vendor operations whose acceptance and resulting fields depend on the
|
|
216
|
+
* latest observed + local projection (for example, immutable version publish).
|
|
217
|
+
*
|
|
218
|
+
* The callback must remain synchronous and side-effect free: it computes a decision from the
|
|
219
|
+
* supplied snapshot. Durable state changes only through the returned action, which this helper
|
|
220
|
+
* appends before releasing the lock.
|
|
221
|
+
*/
|
|
222
|
+
export function decideAndAppendAction<T>(
|
|
223
|
+
service: string,
|
|
224
|
+
decide: (resources: TwinResource[]) => AtomicActionDecision<T>,
|
|
225
|
+
root?: string,
|
|
226
|
+
): { value: T; action?: TwinAction; appended: boolean } {
|
|
227
|
+
return withFileLock(projectionLock(service, root), () => withFileLock(actionsLock(service, root), () => {
|
|
228
|
+
const resources = projectResources(service, root);
|
|
229
|
+
const decision = decide(resources);
|
|
230
|
+
if (decision.kind === 'skip') return { value: decision.value, appended: false };
|
|
231
|
+
if (decision.action.service !== service) {
|
|
232
|
+
throw new Error(`Atomic action service mismatch: expected ${service}, got ${decision.action.service}`);
|
|
233
|
+
}
|
|
234
|
+
const identified = withCorrelationId(decision.action);
|
|
235
|
+
const existing = existingExactAction(identified, root);
|
|
236
|
+
if (existing) return { value: decision.value, action: existing, appended: false };
|
|
237
|
+
const stamped = withShadowBasis(identified, root);
|
|
238
|
+
assertPreconditionsAgainst(stamped, resources);
|
|
239
|
+
appendActionRaw(stamped, root);
|
|
240
|
+
return { value: decision.value, action: stamped, appended: true };
|
|
241
|
+
}));
|
|
138
242
|
}
|
|
139
243
|
|
|
140
244
|
export function appendTransactionCommit(commit: TwinTransactionCommit, root?: string): TwinTransactionCommit {
|
|
@@ -143,9 +247,8 @@ export function appendTransactionCommit(commit: TwinTransactionCommit, root?: st
|
|
|
143
247
|
|
|
144
248
|
export function listActions(service: string, root?: string): TwinAction[] {
|
|
145
249
|
const path = actionsPath(service, root);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
.split('\n')
|
|
250
|
+
return getActiveWorldStore()
|
|
251
|
+
.readLines(path)
|
|
149
252
|
.filter((line) => line.trim())
|
|
150
253
|
.map((line) => JSON.parse(line) as TwinAction);
|
|
151
254
|
}
|
|
@@ -158,22 +261,38 @@ function projectedField(resource: TwinResource | undefined, field: string): unkn
|
|
|
158
261
|
return resource[field];
|
|
159
262
|
}
|
|
160
263
|
|
|
161
|
-
|
|
264
|
+
/** The kernel's ONE deterministic check: does `actual` satisfy the precondition expression?
|
|
265
|
+
* Shared by write-time preconditions (here), plan conflict detection (plan.ts) and changeset
|
|
266
|
+
* verifiers (changeset.ts) — one evaluator, so a check means the same thing everywhere. */
|
|
267
|
+
export function checkPrecondition(precondition: TwinActionPrecondition, actual: unknown): boolean {
|
|
268
|
+
switch (precondition.op) {
|
|
269
|
+
case 'exists': return actual !== undefined;
|
|
270
|
+
case 'not_exists': return actual === undefined;
|
|
271
|
+
case 'eq': case 'version_eq': return Object.is(actual, precondition.value);
|
|
272
|
+
case 'neq': return !Object.is(actual, precondition.value);
|
|
273
|
+
default: return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Look a precondition subject's field up in projected resources (undefined = no resource or
|
|
278
|
+
* no field — exactly what `exists`/`not_exists` distinguish). */
|
|
279
|
+
export function projectedPreconditionValue(precondition: TwinActionPrecondition, resources: TwinResource[]): unknown {
|
|
280
|
+
const resource = resources.find((r) => r.type === precondition.subject.type && r.id === precondition.subject.id);
|
|
281
|
+
return projectedField(resource, precondition.field);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function assertPreconditionsAgainst(action: TwinAction, resources: TwinResource[]): void {
|
|
162
285
|
if (!action.preconditions?.length) return;
|
|
163
|
-
const resources = projectResources(action.service, root);
|
|
164
286
|
for (const precondition of action.preconditions) {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
const passes =
|
|
168
|
-
precondition.op === 'exists' ? actual !== undefined
|
|
169
|
-
: precondition.op === 'not_exists' ? actual === undefined
|
|
170
|
-
: precondition.op === 'eq' || precondition.op === 'version_eq' ? Object.is(actual, precondition.value)
|
|
171
|
-
: precondition.op === 'neq' ? !Object.is(actual, precondition.value)
|
|
172
|
-
: false;
|
|
173
|
-
if (!passes) throw new TwinActionPreconditionError(action.id, precondition);
|
|
287
|
+
const actual = projectedPreconditionValue(precondition, resources);
|
|
288
|
+
if (!checkPrecondition(precondition, actual)) throw new TwinActionPreconditionError(action.id, precondition);
|
|
174
289
|
}
|
|
175
290
|
}
|
|
176
291
|
|
|
292
|
+
function assertPreconditions(action: TwinAction, root?: string): void {
|
|
293
|
+
assertPreconditionsAgainst(action, projectResources(action.service, root));
|
|
294
|
+
}
|
|
295
|
+
|
|
177
296
|
/**
|
|
178
297
|
* Project the action log over the observed mirror → current twin resources.
|
|
179
298
|
* `set` actions overlay fields (creating subjects that don't exist in the mirror);
|
|
@@ -234,30 +353,96 @@ export function confirmAction(opts: {
|
|
|
234
353
|
actionId: string;
|
|
235
354
|
subject: { type: string; id: string };
|
|
236
355
|
fields: SubjectFields;
|
|
356
|
+
/** Additional observed resources confirmed by the same compound action. They are folded before
|
|
357
|
+
* the primary subject, under the same service projection lock. */
|
|
358
|
+
additionalObservations?: Array<{ subject: { type: string; id: string }; fields: SubjectFields }>;
|
|
237
359
|
occurredAt: string;
|
|
238
360
|
root?: string;
|
|
239
|
-
}): { observedEventId: string } {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
{
|
|
361
|
+
}): { observedEventId: string; observedEventIds: string[] } {
|
|
362
|
+
const observations = [
|
|
363
|
+
...(opts.additionalObservations ?? []),
|
|
364
|
+
{ subject: opts.subject, fields: opts.fields },
|
|
365
|
+
];
|
|
366
|
+
const events = observations.map((observation) => {
|
|
367
|
+
// Content-address the observation, not merely the action. If a process crashes after writing
|
|
368
|
+
// observations but before the confirm row, a retry over newer mutable remote state appends a
|
|
369
|
+
// new observation instead of conflicting with the abandoned row's id.
|
|
370
|
+
const contentHash = hashFieldValue({ actionId: opts.actionId, subject: observation.subject, fields: observation.fields });
|
|
371
|
+
const observedEventId = `confirmed:${opts.service}:${observation.subject.type}:${observation.subject.id}:${opts.actionId}:${contentHash}`;
|
|
372
|
+
return WorldServiceEventSchema.parse({
|
|
243
373
|
id: observedEventId,
|
|
244
374
|
service: opts.service,
|
|
245
|
-
type: `${opts.service}.${
|
|
375
|
+
type: `${opts.service}.${observation.subject.type}.delta`,
|
|
246
376
|
schemaVersion: 1,
|
|
247
377
|
idempotencyKey: observedEventId,
|
|
248
378
|
occurredAt: opts.occurredAt,
|
|
249
379
|
observedAt: opts.occurredAt,
|
|
250
380
|
origin: 'external', // confirmed by the real vendor → an observed fact
|
|
251
|
-
subject:
|
|
252
|
-
data: { changed: Object.fromEntries(Object.entries(
|
|
253
|
-
} as
|
|
254
|
-
|
|
255
|
-
);
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
381
|
+
subject: observation.subject,
|
|
382
|
+
data: { changed: Object.fromEntries(Object.entries(observation.fields).map(([k, v]) => [k, { after: v }])) },
|
|
383
|
+
}) as WorldServiceEvent;
|
|
384
|
+
});
|
|
385
|
+
const observedEventIds = events.map((event) => event.id);
|
|
386
|
+
const observedEventId = observedEventIds.at(-1)!;
|
|
387
|
+
const paths = worldPaths(opts.service, opts.root);
|
|
388
|
+
const confirm: TwinAction = {
|
|
389
|
+
id: `confirm:${opts.actionId}:${hashFieldValue(observedEventIds)}`,
|
|
390
|
+
service: opts.service,
|
|
391
|
+
op: 'confirm',
|
|
392
|
+
subject: opts.subject,
|
|
393
|
+
occurredAt: opts.occurredAt,
|
|
394
|
+
confirmsActionId: opts.actionId,
|
|
395
|
+
observedEventId,
|
|
396
|
+
observedEventIds,
|
|
397
|
+
};
|
|
398
|
+
// One outer projection lock prevents local decisions or connector observations from observing
|
|
399
|
+
// an in-process half-confirm. The writes remain separate append-only rows, so crash recovery is
|
|
400
|
+
// deliberately idempotent: every event and the confirm action have content-addressed ids.
|
|
401
|
+
withFileLock(projectionLock(opts.service, opts.root), () => {
|
|
402
|
+
withFileLock(eventsLockPath(paths), () => {
|
|
403
|
+
for (const event of events) appendEventLocked(event, paths);
|
|
404
|
+
});
|
|
405
|
+
withFileLock(actionsLock(opts.service, opts.root), () => {
|
|
406
|
+
const stamped = withCorrelationId(confirm);
|
|
407
|
+
if (!existingExactAction(stamped, opts.root)) appendActionRaw(stamped, opts.root);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
return { observedEventId, observedEventIds };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export type RevertOutcome =
|
|
414
|
+
| { status: 'reverted'; revertId: string }
|
|
415
|
+
| { status: 'already-reverted' }
|
|
416
|
+
| { status: 'confirmed' }
|
|
417
|
+
| { status: 'not-found' }
|
|
418
|
+
| { status: 'not-revertable'; op: TwinActionOp };
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Revert one pending `set` action — decision AND append under the service projection +
|
|
422
|
+
* actions locks, so a concurrent writer (a connector confirming the same action from
|
|
423
|
+
* another process) cannot land between the check and the revert: the whole read-decide-
|
|
424
|
+
* append is ONE critical section. Idempotent: a repeat answers 'already-reverted'.
|
|
425
|
+
* Only `set` rows are revertable — reverting a revert or a confirm is a category error.
|
|
426
|
+
*/
|
|
427
|
+
export function revertAction(opts: { service: string; actionId: string; occurredAt: string; root?: string }): RevertOutcome {
|
|
428
|
+
return withFileLock(projectionLock(opts.service, opts.root), () => withFileLock(actionsLock(opts.service, opts.root), (): RevertOutcome => {
|
|
429
|
+
const all = listActions(opts.service, opts.root);
|
|
430
|
+
const target = all.find((a) => a.id === opts.actionId);
|
|
431
|
+
if (target === undefined) return { status: 'not-found' };
|
|
432
|
+
if (target.op !== 'set') return { status: 'not-revertable', op: target.op };
|
|
433
|
+
if (all.some((a) => a.op === 'confirm' && a.confirmsActionId === opts.actionId)) return { status: 'confirmed' };
|
|
434
|
+
if (all.some((a) => a.op === 'revert' && a.revertsActionId === opts.actionId)) return { status: 'already-reverted' };
|
|
435
|
+
const revert: TwinAction = withCorrelationId({
|
|
436
|
+
id: `revert:${opts.actionId}`,
|
|
437
|
+
service: opts.service,
|
|
438
|
+
op: 'revert',
|
|
439
|
+
subject: target.subject,
|
|
440
|
+
occurredAt: opts.occurredAt,
|
|
441
|
+
revertsActionId: opts.actionId,
|
|
442
|
+
});
|
|
443
|
+
appendActionRaw(revert, opts.root);
|
|
444
|
+
return { status: 'reverted', revertId: revert.id };
|
|
445
|
+
}));
|
|
261
446
|
}
|
|
262
447
|
|
|
263
448
|
/** Local pending actions (set, not reverted, not yet confirmed) — the divergence from the mirror. */
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// THE BLOB SEAM (runtime contract R11) — byte storage behind every handler that touches
|
|
2
|
+
// raw bytes (slack file uploads, jira attachments), so a serverless namespace can put its
|
|
3
|
+
// bytes in object storage while local worlds keep today's on-disk layout unchanged.
|
|
4
|
+
//
|
|
5
|
+
// Why bytes get their OWN seam instead of riding WorldStore: WorldStore is string-typed by
|
|
6
|
+
// design (JSONL rows, JSON sidecars — the hydrate/flush snapshot is a path→string map), and
|
|
7
|
+
// blob bytes must never enter that snapshot: they can be large, they never participate in
|
|
8
|
+
// preconditions or projection, and they are served/accepted at the edge. Blob KEYS live in
|
|
9
|
+
// string state; bytes live here. That separation is what keeps namespace snapshots small
|
|
10
|
+
// enough for a Durable Object's memory (runtime contract R11–R14).
|
|
11
|
+
//
|
|
12
|
+
// ASYNC-NATIVE, unlike WorldStore: the sync kernel never reads blobs mid-projection — bytes
|
|
13
|
+
// are read/written at the request edge (upload accept, download serve), which is already
|
|
14
|
+
// async in every server. So there is no hydrate-whole-namespace step for blobs and no sync
|
|
15
|
+
// constraint to honor: an R2/S3 adapter implements this interface directly, per request.
|
|
16
|
+
//
|
|
17
|
+
// Keys are PATHS (the FsWorldStore convention): the fs backend uses them verbatim on disk,
|
|
18
|
+
// so packs keep computing today's exact layout (…/resources/blobs/sha256/<ab>/<hex>) and
|
|
19
|
+
// local worlds change NOTHING — scrub, backup, and inspection all still see the same tree.
|
|
20
|
+
// A remote adapter maps the path to an object key (namespace prefix + path).
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
export interface BlobStore {
|
|
26
|
+
/** Bytes at `key`, or null when absent. Never throws for a missing key. */
|
|
27
|
+
get(key: string): Promise<Uint8Array | null>;
|
|
28
|
+
/** Store bytes at `key`, atomically (a reader never observes a torn blob). Creating
|
|
29
|
+
* parent "directories" is the store's job; idempotent overwrite is allowed (content-
|
|
30
|
+
* addressed callers never rewrite different bytes under one key). */
|
|
31
|
+
put(key: string, bytes: Uint8Array): Promise<void>;
|
|
32
|
+
exists(key: string): Promise<boolean>;
|
|
33
|
+
/** Remove the blob (or, for a prefix ending in '/', everything under it — namespace
|
|
34
|
+
* reset uses this). A missing key is not an error. */
|
|
35
|
+
remove(key: string): Promise<void>;
|
|
36
|
+
/** Keys currently stored under `prefix` (full keys, sorted). Lifecycle/reset tooling
|
|
37
|
+
* only — the serve path addresses blobs by exact key. */
|
|
38
|
+
list(prefix: string): Promise<string[]>;
|
|
39
|
+
/** Byte size at `key`, or null when absent. */
|
|
40
|
+
size(key: string): Promise<number | null>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Today's layout, verbatim: keys are filesystem paths. */
|
|
44
|
+
export class FsBlobStore implements BlobStore {
|
|
45
|
+
async get(key: string): Promise<Uint8Array | null> {
|
|
46
|
+
if (!existsSync(key)) return null;
|
|
47
|
+
return new Uint8Array(readFileSync(key));
|
|
48
|
+
}
|
|
49
|
+
async put(key: string, bytes: Uint8Array): Promise<void> {
|
|
50
|
+
mkdirSync(dirname(key), { recursive: true });
|
|
51
|
+
// Write-then-rename so a crash mid-write never leaves a truncated blob that a
|
|
52
|
+
// content-addressed caller would forever report as present (the slack-blobs rule,
|
|
53
|
+
// now the seam's rule).
|
|
54
|
+
const tmp = `${key}.tmp.${process.pid}`;
|
|
55
|
+
writeFileSync(tmp, bytes);
|
|
56
|
+
renameSync(tmp, key);
|
|
57
|
+
}
|
|
58
|
+
async exists(key: string): Promise<boolean> {
|
|
59
|
+
return existsSync(key);
|
|
60
|
+
}
|
|
61
|
+
async remove(key: string): Promise<void> {
|
|
62
|
+
rmSync(key, { force: true, recursive: key.endsWith('/') });
|
|
63
|
+
}
|
|
64
|
+
async list(prefix: string): Promise<string[]> {
|
|
65
|
+
const out: string[] = [];
|
|
66
|
+
const walk = (dir: string): void => {
|
|
67
|
+
if (!existsSync(dir)) return;
|
|
68
|
+
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
|
69
|
+
const child = join(dir, name.name);
|
|
70
|
+
if (name.isDirectory()) walk(child);
|
|
71
|
+
else if (!name.name.includes('.tmp.')) out.push(child);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
walk(prefix.replace(/\/$/, ''));
|
|
75
|
+
return out.sort();
|
|
76
|
+
}
|
|
77
|
+
async size(key: string): Promise<number | null> {
|
|
78
|
+
if (!existsSync(key)) return null;
|
|
79
|
+
return statSync(key).size;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** In-memory backend — tests and hydration rehearsals; also the shape an R2/S3 adapter
|
|
84
|
+
* implements (same interface, object keys instead of paths). */
|
|
85
|
+
export class MemoryBlobStore implements BlobStore {
|
|
86
|
+
private blobs = new Map<string, Uint8Array>();
|
|
87
|
+
async get(key: string): Promise<Uint8Array | null> {
|
|
88
|
+
const bytes = this.blobs.get(key);
|
|
89
|
+
return bytes === undefined ? null : new Uint8Array(bytes);
|
|
90
|
+
}
|
|
91
|
+
async put(key: string, bytes: Uint8Array): Promise<void> {
|
|
92
|
+
this.blobs.set(key, new Uint8Array(bytes));
|
|
93
|
+
}
|
|
94
|
+
async exists(key: string): Promise<boolean> {
|
|
95
|
+
return this.blobs.has(key);
|
|
96
|
+
}
|
|
97
|
+
async remove(key: string): Promise<void> {
|
|
98
|
+
if (key.endsWith('/')) {
|
|
99
|
+
for (const k of [...this.blobs.keys()]) if (k.startsWith(key)) this.blobs.delete(k);
|
|
100
|
+
} else this.blobs.delete(key);
|
|
101
|
+
}
|
|
102
|
+
async list(prefix: string): Promise<string[]> {
|
|
103
|
+
return [...this.blobs.keys()].filter((k) => k.startsWith(prefix.replace(/\/$/, ''))).sort();
|
|
104
|
+
}
|
|
105
|
+
async size(key: string): Promise<number | null> {
|
|
106
|
+
return this.blobs.get(key)?.length ?? null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Content-address helper shared by byte-carrying packs: sha256 hex of the bytes. */
|
|
111
|
+
export function blobDigest(bytes: Uint8Array): string {
|
|
112
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Active-store scoping, the world-store.ts pattern exactly: default fs; a serverless entry
|
|
116
|
+
// (or a test) swaps in its adapter for the scope of a request.
|
|
117
|
+
let activeBlobStore: BlobStore = new FsBlobStore();
|
|
118
|
+
|
|
119
|
+
export function getActiveBlobStore(): BlobStore {
|
|
120
|
+
return activeBlobStore;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function setActiveBlobStore(store: BlobStore): BlobStore {
|
|
124
|
+
const previous = activeBlobStore;
|
|
125
|
+
activeBlobStore = store;
|
|
126
|
+
return previous;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function withBlobStore<T>(store: BlobStore, fn: () => Promise<T> | T): Promise<T> {
|
|
130
|
+
const previous = setActiveBlobStore(store);
|
|
131
|
+
try {
|
|
132
|
+
return await fn();
|
|
133
|
+
} finally {
|
|
134
|
+
setActiveBlobStore(previous);
|
|
135
|
+
}
|
|
136
|
+
}
|