@volter/twin 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +16 -2
  2. package/inject.cjs +453 -59
  3. package/package.json +12 -22
  4. package/src/actions.ts +234 -49
  5. package/src/blob-store.ts +136 -0
  6. package/src/changeset.ts +807 -0
  7. package/src/cli.ts +60 -10
  8. package/src/connector.ts +30 -7
  9. package/src/control-plane.ts +17 -1
  10. package/src/emit.ts +242 -0
  11. package/src/fork.ts +19 -7
  12. package/src/index.ts +139 -6
  13. package/src/lease.ts +4 -6
  14. package/src/lifecycle.ts +8 -0
  15. package/src/packRegistry.ts +248 -2
  16. package/src/plan.ts +131 -23
  17. package/src/proxy.ts +5 -2
  18. package/src/pushLedger.ts +116 -11
  19. package/src/queueLifecycle.ts +3 -4
  20. package/src/rateBudget.ts +1115 -0
  21. package/src/refs.ts +9 -10
  22. package/src/remote-execute.ts +16 -0
  23. package/src/scenario.ts +387 -0
  24. package/src/serve.ts +397 -15
  25. package/src/shadow.ts +86 -7
  26. package/src/storage.ts +76 -147
  27. package/src/sync.ts +63 -17
  28. package/src/twin-fetch.ts +115 -0
  29. package/src/validate.ts +6 -5
  30. package/src/world-clock.ts +33 -0
  31. package/src/world-store.ts +482 -0
  32. package/src/worldConfig.ts +4 -3
  33. package/dist/src/actions.d.ts +0 -138
  34. package/dist/src/actions.js +0 -201
  35. package/dist/src/args.d.ts +0 -3
  36. package/dist/src/args.js +0 -12
  37. package/dist/src/cli.d.ts +0 -2
  38. package/dist/src/cli.js +0 -425
  39. package/dist/src/connector.d.ts +0 -106
  40. package/dist/src/connector.js +0 -129
  41. package/dist/src/control-plane.d.ts +0 -21
  42. package/dist/src/control-plane.js +0 -40
  43. package/dist/src/egress.d.ts +0 -93
  44. package/dist/src/egress.js +0 -264
  45. package/dist/src/fork.d.ts +0 -126
  46. package/dist/src/fork.js +0 -206
  47. package/dist/src/index.d.ts +0 -42
  48. package/dist/src/index.js +0 -52
  49. package/dist/src/lease.d.ts +0 -50
  50. package/dist/src/lease.js +0 -80
  51. package/dist/src/packRegistry.d.ts +0 -34
  52. package/dist/src/packRegistry.js +0 -22
  53. package/dist/src/plan.d.ts +0 -97
  54. package/dist/src/plan.js +0 -151
  55. package/dist/src/proxy.d.ts +0 -25
  56. package/dist/src/proxy.js +0 -152
  57. package/dist/src/pushLedger.d.ts +0 -81
  58. package/dist/src/pushLedger.js +0 -130
  59. package/dist/src/queueLifecycle.d.ts +0 -62
  60. package/dist/src/queueLifecycle.js +0 -95
  61. package/dist/src/reconcile.d.ts +0 -58
  62. package/dist/src/reconcile.js +0 -137
  63. package/dist/src/refs.d.ts +0 -29
  64. package/dist/src/refs.js +0 -68
  65. package/dist/src/schemas.d.ts +0 -78
  66. package/dist/src/schemas.js +0 -50
  67. package/dist/src/serve.d.ts +0 -44
  68. package/dist/src/serve.js +0 -93
  69. package/dist/src/shadow.d.ts +0 -77
  70. package/dist/src/shadow.js +0 -138
  71. package/dist/src/status.d.ts +0 -31
  72. package/dist/src/status.js +0 -42
  73. package/dist/src/storage.d.ts +0 -119
  74. package/dist/src/storage.js +0 -535
  75. package/dist/src/sync.d.ts +0 -91
  76. package/dist/src/sync.js +0 -121
  77. package/dist/src/types.d.ts +0 -40
  78. package/dist/src/types.js +0 -1
  79. package/dist/src/validate.d.ts +0 -27
  80. package/dist/src/validate.js +0 -68
  81. package/dist/src/visualizer.d.ts +0 -13
  82. package/dist/src/visualizer.js +0 -133
  83. package/dist/src/worldConfig.d.ts +0 -9
  84. package/dist/src/worldConfig.js +0 -16
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@volter/twin",
3
- "version": "0.1.0",
4
- "description": "Local twins of SaaS APIs 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.",
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
- "types": "./dist/src/index.d.ts",
47
- "default": "./dist/src/index.js"
48
- },
49
- "./args": {
50
- "types": "./dist/src/args.d.ts",
51
- "default": "./dist/src/args.js"
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": "dist/src/cli.js"
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, appendEvent, twinLog, withFileLock, worldPaths } from './storage.ts';
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
- mkdirSync(dirname(path), { recursive: true });
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
- const stamped = withCorrelationId(action);
120
- assertPreconditions(stamped, root);
121
- // Cross-process line-atomic append (same lock the dedup path uses, so writes from a
122
- // backend + a browser proxy sharing one twin can't interleave or race a check-then-append).
123
- withFileLock(actionsLock(stamped.service, root), () => appendActionRaw(stamped, root));
124
- return stamped;
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
- * check-then-append runs under the actions lock, so it's atomic across processes (two
129
- * concurrent identical writes converge to ONE action; distinct writes both land). */
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
- const stamped = withCorrelationId(action);
132
- assertPreconditions(stamped, root);
133
- return withFileLock(actionsLock(stamped.service, root), () => {
134
- const exists = listActions(stamped.service, root).some((a) => a.id === stamped.id);
135
- if (!exists) appendActionRaw(stamped, root);
136
- return { action: stamped, appended: !exists };
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
- if (!existsSync(path)) return [];
147
- return readFileSync(path, 'utf8')
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
- function assertPreconditions(action: TwinAction, root?: string): void {
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 resource = resources.find((r) => r.type === precondition.subject.type && r.id === precondition.subject.id);
166
- const actual = projectedField(resource, precondition.field);
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 observedEventId = `confirmed:${opts.service}:${opts.subject.type}:${opts.subject.id}:${opts.actionId}`;
241
- appendEvent(
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}.${opts.subject.type}.delta`,
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: opts.subject,
252
- data: { changed: Object.fromEntries(Object.entries(opts.fields).map(([k, v]) => [k, { after: v }])) },
253
- } as unknown as WorldServiceEvent,
254
- opts.root,
255
- );
256
- appendAction(
257
- { id: `confirm:${opts.actionId}`, service: opts.service, op: 'confirm', subject: opts.subject, occurredAt: opts.occurredAt, confirmsActionId: opts.actionId, observedEventId },
258
- opts.root,
259
- );
260
- return { observedEventId };
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
+ }