@volter/world-core 3.0.155 → 3.0.156

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 (101) hide show
  1. package/dist/generated/pack-facts.json +143 -12
  2. package/dist/inject.cjs +150 -86
  3. package/dist/src/actions.d.ts +15 -0
  4. package/dist/src/actions.js +82 -21
  5. package/dist/src/ancestry.d.ts +5 -4
  6. package/dist/src/ancestry.js +53 -28
  7. package/dist/src/changeset.js +1 -1
  8. package/dist/src/clickhouse/compression.d.ts +28 -0
  9. package/dist/src/clickhouse/compression.js +281 -0
  10. package/dist/src/clickhouse/index.d.ts +1 -0
  11. package/dist/src/clickhouse/index.js +1 -0
  12. package/dist/src/derived-core.d.ts +12 -1
  13. package/dist/src/derived-core.js +145 -11
  14. package/dist/src/derived.js +3 -0
  15. package/dist/src/fork.js +1 -1
  16. package/dist/src/grpc-wire.js +2 -1
  17. package/dist/src/h2.js +63 -10
  18. package/dist/src/head.d.ts +3 -0
  19. package/dist/src/head.js +9 -0
  20. package/dist/src/history.d.ts +1 -1
  21. package/dist/src/history.js +7 -7
  22. package/dist/src/index.d.ts +4 -1
  23. package/dist/src/index.js +5 -1
  24. package/dist/src/log.d.ts +23 -3
  25. package/dist/src/log.js +160 -37
  26. package/dist/src/managed-original-inputs.d.ts +101 -0
  27. package/dist/src/managed-original-inputs.js +125 -0
  28. package/dist/src/mongodb/client.d.ts +8 -0
  29. package/dist/src/mongodb/client.js +27 -0
  30. package/dist/src/mongodb/index.d.ts +2 -0
  31. package/dist/src/mongodb/index.js +5 -0
  32. package/dist/src/mongodb/stream.d.ts +74 -0
  33. package/dist/src/mongodb/stream.js +206 -0
  34. package/dist/src/observe.js +1 -1
  35. package/dist/src/pack-fetch.js +20 -8
  36. package/dist/src/packRegistry.d.ts +10 -14
  37. package/dist/src/packRegistry.js +2 -13
  38. package/dist/src/protocol.d.ts +13 -0
  39. package/dist/src/protocol.js +13 -0
  40. package/dist/src/redis/engine.d.ts +10 -4
  41. package/dist/src/redis/engine.js +103 -32
  42. package/dist/src/redis/member-storage.js +20 -8
  43. package/dist/src/redis/service-times.d.ts +25 -0
  44. package/dist/src/redis/service-times.js +87 -0
  45. package/dist/src/redis/stream.d.ts +2 -1
  46. package/dist/src/redis/stream.js +109 -20
  47. package/dist/src/runtime-state.d.ts +11 -0
  48. package/dist/src/runtime-state.js +13 -0
  49. package/dist/src/runtime.d.ts +4 -2
  50. package/dist/src/runtime.js +5 -2
  51. package/dist/src/serve-http.d.ts +2 -0
  52. package/dist/src/serve-http.js +21 -4
  53. package/dist/src/serve.d.ts +10 -0
  54. package/dist/src/serve.js +43 -3
  55. package/dist/src/storage.d.ts +2 -1
  56. package/dist/src/storage.js +6 -3
  57. package/dist/src/world-boot.d.ts +1 -0
  58. package/dist/src/world-boot.js +6 -0
  59. package/dist/src/world-clock.js +6 -2
  60. package/dist/src/world-store.d.ts +108 -7
  61. package/dist/src/world-store.js +883 -103
  62. package/dist/src/xml.d.ts +30 -0
  63. package/dist/src/xml.js +74 -0
  64. package/generated/pack-facts.json +143 -12
  65. package/inject.cjs +150 -86
  66. package/package.json +20 -1
  67. package/src/actions.ts +78 -21
  68. package/src/ancestry.ts +46 -24
  69. package/src/changeset.ts +1 -1
  70. package/src/clickhouse/compression.ts +172 -0
  71. package/src/clickhouse/index.ts +2 -0
  72. package/src/derived-core.ts +142 -11
  73. package/src/derived.ts +3 -0
  74. package/src/fork.ts +1 -1
  75. package/src/grpc-wire.ts +2 -1
  76. package/src/h2.ts +46 -5
  77. package/src/head.ts +9 -0
  78. package/src/history.ts +7 -7
  79. package/src/index.ts +6 -1
  80. package/src/log.ts +147 -32
  81. package/src/managed-original-inputs.ts +163 -0
  82. package/src/mongodb/client.ts +27 -0
  83. package/src/mongodb/index.ts +5 -0
  84. package/src/mongodb/stream.ts +213 -0
  85. package/src/observe.ts +1 -1
  86. package/src/pack-fetch.ts +20 -8
  87. package/src/packRegistry.ts +3 -15
  88. package/src/protocol.ts +13 -0
  89. package/src/redis/engine.ts +100 -57
  90. package/src/redis/member-storage.ts +17 -9
  91. package/src/redis/service-times.ts +81 -0
  92. package/src/redis/stream.ts +66 -14
  93. package/src/runtime-state.ts +14 -0
  94. package/src/runtime.ts +6 -1
  95. package/src/serve-http.ts +23 -4
  96. package/src/serve.ts +45 -3
  97. package/src/storage.ts +5 -3
  98. package/src/world-boot.ts +6 -0
  99. package/src/world-clock.ts +6 -2
  100. package/src/world-store.ts +704 -91
  101. package/src/xml.ts +71 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.155",
3
+ "version": "3.0.156",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
@@ -128,9 +128,25 @@
128
128
  "types": "./dist/src/live/index.d.ts",
129
129
  "default": "./dist/src/live/index.js"
130
130
  },
131
+ "./mongodb": {
132
+ "types": "./dist/src/mongodb/index.d.ts",
133
+ "default": "./dist/src/mongodb/index.js"
134
+ },
131
135
  "./clickhouse": {
132
136
  "types": "./dist/src/clickhouse/index.d.ts",
133
137
  "default": "./dist/src/clickhouse/index.js"
138
+ },
139
+ "./runtime-state": {
140
+ "types": "./dist/src/runtime-state.d.ts",
141
+ "default": "./dist/src/runtime-state.js"
142
+ },
143
+ "./browser-state": {
144
+ "types": "./dist/src/browser-state.d.ts",
145
+ "default": "./dist/src/browser-state.js"
146
+ },
147
+ "./managed-original-inputs": {
148
+ "types": "./dist/src/managed-original-inputs.d.ts",
149
+ "default": "./dist/src/managed-original-inputs.js"
134
150
  }
135
151
  },
136
152
  "scripts": {
@@ -156,5 +172,8 @@
156
172
  },
157
173
  "engines": {
158
174
  "node": ">=22.3"
175
+ },
176
+ "optionalDependencies": {
177
+ "@xmldom/xmldom": "0.9.12"
159
178
  }
160
179
  }
package/src/actions.ts CHANGED
@@ -12,7 +12,7 @@ import { parseExactJson } from './exact-json.ts';
12
12
  //
13
13
  // Projection = observed mirror, then apply each `set` transaction in order,
14
14
  // skipping any transaction that was reverted or confirmed.
15
- import { withHistoryLock } from './ancestry.ts';
15
+ import { canonicalStatePath, withHistoryLock } from './ancestry.ts';
16
16
  import { AsyncLocalStorage } from 'node:async_hooks';
17
17
  import { randomUUID } from 'node:crypto';
18
18
  import { dirname, join } from 'node:path';
@@ -20,10 +20,10 @@ import { canonicalJson, hashFieldValue, subjectKey } from './hash.ts';
20
20
  import type { SubjectFields } from './hash.ts';
21
21
  import { appendDurable, eventsLockPath, ownerStoreRoots, projectionLockPath, twinLog, withFileLock, withoutIdentityClaim, worldPaths } from './storage.ts';
22
22
  import { landAsPlaceholder, placeholderPullActive } from './placeholder-remote.ts';
23
- import { aliasesFrom, dropCheckpoint, landedCopy, landedIds, parentEntries, readTree, treeStamp, type Receipt } from './log.ts';
23
+ import { aliasesFrom, appendToBranchLog, dropCheckpoint, landedCopy, landedIds, parentEntries, readTree, treeStamp, type Receipt } from './log.ts';
24
24
  import { WorldServiceEventSchema } from './schemas.ts';
25
25
  import { inVendorMove, refuseReadOnlyWrite } from './request-scope.ts';
26
- import { getActiveWorldStore } from './world-store.ts';
26
+ import { getActiveWorldStore, storageLockScope, type WorldLockOptions } from './world-store.ts';
27
27
  import { currentTraceparent, traceparentForDelivery } from './trace-context.ts';
28
28
  import type { WorldServiceEvent } from './types.ts';
29
29
  import type { TwinResource } from './serve.ts';
@@ -124,8 +124,11 @@ export class TwinActionPreconditionError extends Error {
124
124
  function actionsPath(service: string, root?: string): string {
125
125
  return join(dirname(worldPaths(service, root).events), 'actions.jsonl');
126
126
  }
127
- const actionsLock = (service: string, root?: string): string => `${actionsPath(service, root)}.lock`;
128
- const projectionLock = (service: string, root?: string): string => projectionLockPath(worldPaths(service, root));
127
+ // Canonical, as the history lock's path is (ancestry.ts withHistoryLock), so a take inside it is seen as nested in the same
128
+ // directory whatever the path it was asked by (/var or /private/var). The file is the same one, so a take by another
129
+ // process through the other spelling still excludes this one.
130
+ const actionsLock = (service: string, root?: string): string => join(canonicalStatePath(worldPaths(service, root).dir), 'actions.jsonl.lock');
131
+ const projectionLock = (service: string, root?: string): string => { const paths = worldPaths(service, root); return projectionLockPath({ ...paths, dir: canonicalStatePath(paths.dir) }); };
129
132
 
130
133
  /** Every appended action carries a correlationId — generate one when the caller
131
134
  * hasn't supplied it, so downstream joins (push ledger, logs) always have an id
@@ -186,20 +189,33 @@ function actionIds(service: string, root?: string): ReadonlySet<string> {
186
189
 
187
190
  /** A service's projection lock, under its history lock: one directory's locks are taken history, projection,
188
191
  * actions, events, in that order (architecture, the history lock). */
189
- function withProjection<T>(service: string, root: string | undefined, fn: () => T): T {
190
- return withHistoryLock(worldPaths(service, root).dir, () => withFileLock(projectionLock(service, root), fn));
192
+ function withProjection<T>(service: string, root: string | undefined, fn: () => T, options: WorldLockOptions = {}): T {
193
+ const scope = storageLockScope(options);
194
+ return withHistoryLock(worldPaths(service, root).dir, () => withFileLock(projectionLock(service, root), fn, scope), scope);
191
195
  }
192
196
 
193
197
  function appendActionRaw(action: TwinAction, root?: string): void {
194
- const path = actionsPath(action.service, root);
195
- getActiveWorldStore().mkdir(dirname(path));
198
+ appendActionsRaw([action], root);
199
+ }
200
+
201
+ /** One service's actions appended as ONE store append, in order: the same lines `appendActionRaw` writes one at a time. */
202
+ function appendActionsRaw(actions: readonly TwinAction[], root?: string): void {
203
+ if (!actions.length) return;
204
+ const service = actions[0]!.service;
205
+ const path = actionsPath(service, root);
206
+ // No mkdir: every append is made holding the projection lock (withProjection), whose file is in this same directory
207
+ // and was made there by the take (S2 R17 review).
196
208
  const memos = idMemoSlot(path);
197
209
  const held = memos.get(path);
198
210
  const current = held !== undefined && held.key === logKey(path);
199
- withHistoryLock(worldPaths(action.service, root).dir, () => appendDurable(path, `${JSON.stringify(action)}\n`));
200
- if (current) { held.ids.add(action.id); held.key = logKey(path); } else memos.delete(path);
201
- twinLog('action.append', { service: action.service, id: action.id, op: action.op, correlationId: action.correlationId });
202
- appendObserver?.(action, root);
211
+ const data = actions.map((action) => `${JSON.stringify(action)}\n`).join('');
212
+ // the log is the branch log a read's tree is folded from: the append extends that memo too (log.ts appendToBranchLog)
213
+ const after = withHistoryLock(worldPaths(service, root).dir, () => appendToBranchLog(service, root, data, () => appendDurable(path, data)), { operation: 'action append' });
214
+ if (current) { for (const action of actions) held.ids.add(action.id); held.key = after ?? logKey(path); } else memos.delete(path);
215
+ for (const action of actions) {
216
+ twinLog('action.append', { service: action.service, id: action.id, op: action.op, correlationId: action.correlationId });
217
+ appendObserver?.(action, root);
218
+ }
203
219
  }
204
220
 
205
221
  /** ONE observer of appended entries (state-system.ts: the runtime's `deploy: auto` hook). Called
@@ -264,7 +280,7 @@ export function appendAction(action: TwinAction, root?: string): TwinAction {
264
280
  if (placeholder) return placeholder;
265
281
  appendActionRaw(stamped, root);
266
282
  return stamped;
267
- }));
283
+ }, { operation: 'action append' }), { operation: 'action append' });
268
284
  }
269
285
 
270
286
  /** Append `action` only if no exact action with the same id already exists — the whole
@@ -286,7 +302,7 @@ export function appendActionIfAbsent(action: TwinAction, root?: string): { actio
286
302
  if (placeholder) return { action: placeholder, appended: true, placeholder: true };
287
303
  appendActionRaw(stamped, root);
288
304
  return { action: stamped, appended: true };
289
- }));
305
+ }, { operation: 'action append' }), { operation: 'action append' });
290
306
  }
291
307
 
292
308
  /**
@@ -314,12 +330,53 @@ export function appendActionOccurrence(base: TwinAction, root?: string): { actio
314
330
  if (placeholder) return { action: placeholder, appended: true, placeholder: true };
315
331
  appendActionRaw(stamped, root);
316
332
  return { action: stamped, appended: true };
317
- }));
333
+ }, { operation: 'action append' }), { operation: 'action append' });
334
+ }
335
+
336
+ /**
337
+ * Append several writes of ONE service as occurrences, in order, under one projection + actions lock and as one store
338
+ * append: each lands with the id, ordinal and correlation `appendActionOccurrence` would give it called once per write
339
+ * in the same order (a write's ordinal counts the batch's earlier writes as taken). A Redis run's flush made one
340
+ * append per changed member and key, each taking the history, projection and actions locks and appending alone, one
341
+ * after another under the twin's single command queue (S2 R19: Twenty's queue scripts spent up to 2.1 s of a 2 s
342
+ * window in flush while 38 commands waited). A write carrying preconditions is not taken here: each is checked against
343
+ * the state the earlier ones made, which only the one-at-a-time path sees. While a placeholder pull is open a write
344
+ * lands as observed events, as `appendActionOccurrence`'s does.
345
+ */
346
+ export function appendActionOccurrences(bases: readonly TwinAction[], root?: string): Array<{ action: TwinAction; appended: boolean; placeholder?: true }> {
347
+ if (!bases.length) return [];
348
+ const service = bases[0]!.service;
349
+ if (bases.some((base) => base.service !== service)) throw new Error(`appendActionOccurrences: one service per batch, got ${[...new Set(bases.map((base) => base.service))].join(', ')}`);
350
+ if (bases.some((base) => base.preconditions?.length)) throw new Error('appendActionOccurrences: a write with preconditions is appended alone');
351
+ refuseReadOnlyWrite(service);
352
+ return withProjection(service, root, () => withFileLock(actionsLock(service, root), () => {
353
+ const logged = actionIds(service, root);
354
+ const batch = new Set<string>();
355
+ const taken = { has: (id: string) => batch.has(id) || logged.has(id) };
356
+ const landed: Array<{ action: TwinAction; appended: boolean; placeholder?: true }> = [];
357
+ let unwritten: TwinAction[] = [];
358
+ for (const base of bases) {
359
+ const stamped = occurrenceOf(base, taken, root);
360
+ const placeholder = landIfPlaceholderPull(stamped, root);
361
+ if (placeholder) {
362
+ // no action row: its id is not taken, as it is not in the log the one-at-a-time path reads
363
+ // the lines before it are written first, so the log and the pull keep the writes' order
364
+ appendActionsRaw(unwritten, root); unwritten = [];
365
+ landed.push({ action: placeholder, appended: true, placeholder: true });
366
+ continue;
367
+ }
368
+ batch.add(stamped.id);
369
+ unwritten.push(stamped);
370
+ landed.push({ action: stamped, appended: true });
371
+ }
372
+ appendActionsRaw(unwritten, root);
373
+ return landed;
374
+ }, { operation: 'action append' }), { operation: 'action append' });
318
375
  }
319
376
 
320
377
  /** The occurrence rule, shared by both appenders that use it — the ordinal AND the merge base.
321
378
  * Pure over (base, the log as it stands): the caller holds the lock. */
322
- function occurrenceOf(base: TwinAction, taken: ReadonlySet<string>, root?: string): TwinAction {
379
+ function occurrenceOf(base: TwinAction, taken: Pick<ReadonlySet<string>, 'has'>, root?: string): TwinAction {
323
380
  {
324
381
  let ordinal = 0;
325
382
  while (taken.has(occurrenceId(base.id, ordinal))) ordinal += 1;
@@ -393,7 +450,7 @@ export function decideAndAppendAction<T>(
393
450
  if (placeholder) return { value: decision.value, action: placeholder, appended: true, placeholder: true };
394
451
  appendActionRaw(stamped, root);
395
452
  return { value: decision.value, action: stamped, appended: true };
396
- }));
453
+ }, { operation: 'action decision' }), { operation: 'action decision' });
397
454
  }
398
455
 
399
456
  export function appendTransactionCommit(commit: TwinTransactionCommit, root?: string): TwinTransactionCommit {
@@ -565,9 +622,9 @@ export function confirmAction(opts: {
565
622
  withFileLock(eventsLockPath(paths), () => {
566
623
  const held = landedIds(parentEntries(opts.service, opts.root));
567
624
  for (const copy of copies) if (!held.has(copy.id)) appendDurable(paths.events, `${JSON.stringify(copy)}\n`);
568
- });
625
+ }, { operation: 'action confirmation' });
569
626
  dropCheckpoint(opts.service, opts.root);
570
- });
627
+ }, { operation: 'action confirmation' });
571
628
  const observedEventIds = copies.map((c) => c.id);
572
629
  return { observedEventId: observedEventIds.at(-1)!, observedEventIds };
573
630
  }
@@ -607,7 +664,7 @@ export function revertAction(opts: { service: string; actionId: string; occurred
607
664
  });
608
665
  appendActionRaw(revert, opts.root);
609
666
  return { status: 'reverted', revertId: revert.id };
610
- }));
667
+ }, { operation: 'action revert' }), { operation: 'action revert' });
611
668
  }
612
669
 
613
670
  /** The LOCAL OVERLAY: pending actions (set, not reverted, not yet confirmed) — the divergence
package/src/ancestry.ts CHANGED
@@ -4,7 +4,7 @@ import { createHash, randomUUID } from 'node:crypto';
4
4
  import { volterHome } from './volter-home.ts';
5
5
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
6
6
  import { stateDirName } from './storage.ts';
7
- import { getActiveWorldStore, type WorldStore } from './world-store.ts';
7
+ import { getActiveWorldStore, storageLockScope, withStorageLockOperation, type WorldLockOptions, type WorldStore } from './world-store.ts';
8
8
 
9
9
  type Pin = { child: string; ownerPid: number; committed?: boolean };
10
10
  type Ancestry = { generation: string; children: Pin[] };
@@ -18,16 +18,17 @@ const inside = (base: string, path: string) => { const rel = relative(canonicalS
18
18
  * bookkeeping (generations, pins, branch pointers, removal receipts) and the not-being-removed check: every World
19
19
  * sharing the VOLTER_HOME waits on it, so no append, fold or caller's critical section runs inside it, and no state lock
20
20
  * is taken inside it (`withStateLock` takes its lock first). */
21
- export function withAncestryLock<T>(fn: () => T): T {
21
+ export function withAncestryLock<T>(fn: () => T, options: WorldLockOptions = {}): T {
22
+ const scope = storageLockScope(options);
22
23
  const store = getActiveWorldStore();
23
24
  const active = entered ??= new WeakSet<WorldStore>();
24
25
  if (active.has(store)) return fn();
25
26
  const path = join(volterHome(), 'branch-ancestry.lock');
26
27
  return store.withLock(path, () => {
27
28
  active.add(store);
28
- try { recoverRemovals(); return fn(); }
29
+ try { recoverRemovals(path); return fn(); }
29
30
  finally { active.delete(store); }
30
- }, { retainLiveOwner: true });
31
+ }, { ...scope, retainLiveOwner: true });
31
32
  }
32
33
 
33
34
  /** The state locks this process holds, per store: taken again inside one, a lock is already ours. */
@@ -36,22 +37,33 @@ const heldBy = (store: WorldStore): Set<string> => { const map = heldLocks ??= n
36
37
 
37
38
  /** Run `fn` holding the cross-process lock at `lockPath`, a lock of the directory it sits in: taken first, then the
38
39
  * coordinator only to check that the directory is not being removed. Re-entrant within a process. */
39
- export function withStateLock<T>(lockPath: string, fn: () => T): T {
40
+ export function withStateLock<T>(lockPath: string, fn: () => T, options: WorldLockOptions = {}): T {
41
+ const scope = storageLockScope(options);
40
42
  const store = getActiveWorldStore();
41
43
  const held = heldBy(store);
42
44
  if (held.has(lockPath)) return fn();
43
45
  if (entered?.has(store)) throw new Error(`State lock ${lockPath} taken inside the ancestry coordinator: a state lock is taken before it, never inside it`);
46
+ // A lock taken inside a held lock of the SAME directory asks the coordinator nothing: the outer take checked that
47
+ // the directory is not being removed, and a removal recorded since waits on that outer lock before it deletes
48
+ // anything, so this operation finishes first, as a holder that checked before the receipt does. Asked again, each
49
+ // write took the machine-wide coordinator three times for one directory.
50
+ const directory = dirname(lockPath);
51
+ const nested = [...held].some((path) => dirname(path) === directory);
44
52
  return store.withLock(lockPath, () => {
45
- withAncestryLock(() => assertNotBeingRemoved(dirname(lockPath)));
53
+ // The removal check reads the receipts without the coordinator, and takes it only where one is there (S2 R17;
54
+ // architecture, the history lock): this holder already has the directory's lock, and a removal writes its receipt
55
+ // under the coordinator before it waits on every lock inside the tree, so a receipt not yet visible here belongs to
56
+ // a removal that waits for this holder, which finishes first. A visible receipt is checked, recovered, as before.
57
+ if (!nested && removalRecorded()) withAncestryLock(() => assertNotBeingRemoved(directory), { operation: 'removal preflight' });
46
58
  held.add(lockPath);
47
59
  try { return fn(); } finally { held.delete(lockPath); }
48
- });
60
+ }, scope);
49
61
  }
50
62
 
51
63
  /** A state directory's history lock: its appends, captures, observation batches and folds. A branch's is taken before
52
64
  * its ancestors'. */
53
- export function withHistoryLock<T>(directory: string, fn: () => T): T {
54
- return withStateLock(join(canonicalStatePath(directory), 'history.lock'), fn);
65
+ export function withHistoryLock<T>(directory: string, fn: () => T, options: WorldLockOptions = {}): T {
66
+ return withStateLock(join(canonicalStatePath(directory), 'history.lock'), fn, storageLockScope(options));
55
67
  }
56
68
 
57
69
  /** A lock file a holder has recorded itself in (`{pid, hostname}`, written before the holder runs anything): a file
@@ -61,9 +73,10 @@ function isHeldLock(text: string | null): boolean {
61
73
  }
62
74
 
63
75
  /** Several sibling directories' history locks at once (a World's services, a changeset's targets), in one order. */
64
- export function withHistoryLocks<T>(directories: string[], fn: () => T): T {
76
+ export function withHistoryLocks<T>(directories: string[], fn: () => T, options: WorldLockOptions = {}): T {
77
+ const scope = storageLockScope(options);
65
78
  const sorted = [...new Set(directories.map((d) => canonicalStatePath(d)))].sort();
66
- const take = (i: number): T => (i === sorted.length ? fn() : withHistoryLock(sorted[i]!, () => take(i + 1)));
79
+ const take = (i: number): T => (i === sorted.length ? fn() : withHistoryLock(sorted[i]!, () => take(i + 1), scope));
67
80
  return take(0);
68
81
  }
69
82
 
@@ -105,7 +118,7 @@ export function stateGeneration(dir: string): string {
105
118
  let meta = metadata(dir);
106
119
  if (!meta) { meta = { generation: randomUUID(), children: [] }; store.writeAtomic(metaPath(dir), JSON.stringify(meta)); }
107
120
  return meta.generation;
108
- });
121
+ }, { operation: 'state generation' });
109
122
  }
110
123
 
111
124
  /** Must be called in the same critical section as child pointer publication. A crash before
@@ -220,19 +233,28 @@ function releaseOutgoing(removal: Removal): void {
220
233
 
221
234
  /** A crash after deleting files cannot strand committed child pins. Receipts are replayed
222
235
  * before any subsequent ancestry publication, so path reuse cannot hide completed removal. */
223
- function recoverRemovals(): void {
236
+ function recoverRemovals(lockPath: string): void {
224
237
  const store = getActiveWorldStore();
238
+ const receipts = store.list(removalDir()).filter(n => n.endsWith('.json'));
239
+ if (!receipts.length) { beingRemoved = []; return; }
225
240
  const pending: string[] = [];
226
- for (const name of store.list(removalDir()).filter(n => n.endsWith('.json'))) {
227
- const file = join(removalDir(), name);
228
- const removal = JSON.parse(store.read(file)!) as Removal;
229
- if (typeof removal.path !== 'string' || !Array.isArray(removal.outgoing)) throw new Error('Invalid branch removal receipt; retained');
230
- if (!store.exists(removal.path)) { releaseOutgoing(removal); store.remove(file); }
231
- else pending.push(removal.path);
232
- }
241
+ withStorageLockOperation(lockPath, 'ancestry removal recovery', () => {
242
+ for (const name of receipts) {
243
+ const file = join(removalDir(), name);
244
+ const removal = JSON.parse(store.read(file)!) as Removal;
245
+ if (typeof removal.path !== 'string' || !Array.isArray(removal.outgoing)) throw new Error('Invalid branch removal receipt; retained');
246
+ if (!store.exists(removal.path)) { releaseOutgoing(removal); store.remove(file); }
247
+ else pending.push(removal.path);
248
+ }
249
+ });
233
250
  beingRemoved = pending;
234
251
  }
235
252
 
253
+ /** Whether any removal receipt is recorded now: read without the coordinator (withStateLock). */
254
+ function removalRecorded(): boolean {
255
+ return getActiveWorldStore().list(removalDir()).some((name) => name.endsWith('.json'));
256
+ }
257
+
236
258
  /** State whose removal is recorded and not yet complete: its deletion runs outside the coordinator, so nothing may pin
237
259
  * it as a parent or write into it meanwhile. Read under the coordinator, where recoverRemovals has just refreshed it. */
238
260
  let beingRemoved: string[] = [];
@@ -260,18 +282,18 @@ export function withStateRemoval<T>(path: string, remove: () => T, check?: () =>
260
282
  store.writeAtomic(receipt, JSON.stringify(removal));
261
283
  beingRemoved = [...beingRemoved, removal.path];
262
284
  return { removal, receipt };
263
- });
285
+ }, { operation: 'removal preflight' });
264
286
  // Each lock is waited on alone, taken and let go: a holder that checked before the receipt finishes, and a taker
265
287
  // after it refuses once it holds the lock (withStateLock), so the removal never holds one lock while waiting on
266
288
  // another and no lock order can deadlock it. A wait that fails leaves nothing deleted: the receipt is released.
267
289
  const held = heldBy(store);
268
290
  try {
269
- for (const lock of locksUnder(path).filter((l) => !held.has(l))) store.withLock(lock, () => undefined);
291
+ for (const lock of locksUnder(path).filter((l) => !held.has(l))) store.withLock(lock, () => undefined, { operation: 'removal preflight' });
270
292
  } catch (error) {
271
293
  withAncestryLock(() => {
272
294
  if (store.exists(receipt)) store.remove(receipt);
273
295
  beingRemoved = beingRemoved.filter((p) => p !== removal.path);
274
- });
296
+ }, { operation: 'ancestry removal recovery' });
275
297
  throw error;
276
298
  }
277
299
  const result = remove();
@@ -280,6 +302,6 @@ export function withStateRemoval<T>(path: string, remove: () => T, check?: () =>
280
302
  // Another process's recovery may have released it already; both steps are idempotent.
281
303
  releaseOutgoing(removal);
282
304
  if (store.exists(receipt)) store.remove(receipt);
283
- });
305
+ }, { operation: 'ancestry removal recovery' });
284
306
  return result;
285
307
  }
package/src/changeset.ts CHANGED
@@ -1042,7 +1042,7 @@ export type RebaseActionResult = {
1042
1042
  export type RebaseReport = { changeset: string; from: string; to: string | null; at: string; outcome: 'unchanged' | 'rebased' | 'conflict'; actions: RebaseActionResult[] };
1043
1043
 
1044
1044
  export function rebaseChangeset(changeset: Changeset, opts: { targets: RebaseTarget[]; at?: string }): { changeset: Changeset; report: RebaseReport } {
1045
- return withHistoryLocks(opts.targets.map((t) => worldPaths(t.stateService, t.controlRoot).dir), () => rebaseChangesetLocked(changeset, opts));
1045
+ return withHistoryLocks(opts.targets.map((t) => worldPaths(t.stateService, t.controlRoot).dir), () => rebaseChangesetLocked(changeset, opts), { operation: 'branch-pointer adjustment' });
1046
1046
  }
1047
1047
  function rebaseChangesetLocked(changeset: Changeset, opts: { targets: RebaseTarget[]; at?: string }): { changeset: Changeset; report: RebaseReport } {
1048
1048
  const at = opts.at ?? new Date().toISOString();
@@ -0,0 +1,172 @@
1
+ // CityHashv1.0.2 arithmetic, ported from the vendor-carried Google source.
2
+ // Source: ClickHouse/ClickHouse contrib/cityhash102/src/city.cc, SHA256
3
+ // 4bcc8f0c09e3ef38210a0c568efc0550e4cb426719d8ad74a466f152296d0df7.
4
+ // Copyright (c) 2011 Google, Inc.
5
+ //
6
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ // of this software and associated documentation files (the "Software"), to deal
8
+ // in the Software without restriction, including without limitation the rights
9
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ // copies of the Software, and to permit persons to whom the Software is
11
+ // furnished to do so, subject to the following conditions:
12
+ //
13
+ // The above copyright notice and this permission notice shall be included in
14
+ // all copies or substantial portions of the Software.
15
+ //
16
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ // THE SOFTWARE.
23
+
24
+ const k0 = 0xc3a5c85c97cb3127n, k1 = 0xb492b66fbe98f273n, k2 = 0x9ae16a3b2f90404fn, k3 = 0xc949d7c7509e6557n;
25
+ type Pair = [bigint, bigint];
26
+ const u = (value: bigint): bigint => BigInt.asUintN(64, value);
27
+ const rotate = (value: bigint, shift: number): bigint => shift === 0 ? u(value) : u((u(value) >> BigInt(shift)) | (u(value) << BigInt(64 - shift)));
28
+ const mix = (value: bigint): bigint => u(value) ^ (u(value) >> 47n);
29
+ function hash16(first: bigint, second: bigint): bigint {
30
+ const mul = 0x9ddfea08eb382d69n;
31
+ let a = u((u(first) ^ u(second)) * mul); a ^= a >> 47n;
32
+ let b = u((u(second) ^ a) * mul); b ^= b >> 47n;
33
+ return u(b * mul);
34
+ }
35
+ /** The CityHash 1.0.2 checksum used by ClickHouse native compression frames. */
36
+ export function cityHash128(bytes: Uint8Array): Pair {
37
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
38
+ const fetch = (at: number): bigint => view.getBigUint64(at, true);
39
+ const short = (at: number, length: number): bigint => {
40
+ if (length > 8) { const a = fetch(at), b = fetch(at + length - 8); return hash16(a, rotate(u(b + BigInt(length)), length)) ^ b; }
41
+ if (length >= 4) return hash16(BigInt(length) + (BigInt(view.getUint32(at, true)) << 3n), BigInt(view.getUint32(at + length - 4, true)));
42
+ if (length) { const a = bytes[at]!, b = bytes[at + (length >> 1)]!, c = bytes[at + length - 1]!; return u(mix(u(BigInt(a + (b << 8)) * k2) ^ u(BigInt(length + (c << 2)) * k3)) * k2); }
43
+ return k2;
44
+ };
45
+ const weak = (at: number, first: bigint, second: bigint): Pair => {
46
+ const w = fetch(at), x = fetch(at + 8), y = fetch(at + 16), z = fetch(at + 24);
47
+ let a = u(first + w), b = rotate(u(second + a + z), 21); const c = a;
48
+ a = u(a + x + y); b = u(b + rotate(a, 44)); return [u(a + z), u(b + c)];
49
+ };
50
+ const murmur = (at: number, length: number, seed: Pair): Pair => {
51
+ let [a, b] = seed, c = 0n, d = 0n, left = length - 16;
52
+ if (left <= 0) { a = u(mix(u(a * k1)) * k1); c = u(b * k1 + short(at, length)); d = mix(u(a + (length >= 8 ? fetch(at) : c))); }
53
+ else {
54
+ c = hash16(u(fetch(at + length - 8) + k1), a); d = hash16(u(b + BigInt(length)), u(c + fetch(at + length - 16))); a = u(a + d);
55
+ do { a ^= u(mix(u(fetch(at) * k1)) * k1); a = u(a * k1); b ^= a; c ^= u(mix(u(fetch(at + 8) * k1)) * k1); c = u(c * k1); d ^= c; at += 16; left -= 16; } while (left > 0);
56
+ }
57
+ a = hash16(a, c); b = hash16(d, b); return [a ^ b, hash16(b, a)];
58
+ };
59
+ const seeded = (at: number, length: number, seed: Pair): Pair => {
60
+ if (length < 128) return murmur(at, length, seed);
61
+ let [x, y] = seed, z = u(BigInt(length) * k1);
62
+ let v: Pair = [u(rotate(y ^ k1, 49) * k1 + fetch(at)), 0n]; v[1] = u(rotate(v[0], 42) * k1 + fetch(at + 8));
63
+ let w: Pair = [u(rotate(u(y + z), 35) * k1 + x), u(rotate(u(x + fetch(at + 88)), 53) * k1)];
64
+ const round = (): void => {
65
+ x = u(rotate(u(x + y + v[0] + fetch(at + 16)), 37) * k1); y = u(rotate(u(y + v[1] + fetch(at + 48)), 42) * k1);
66
+ x ^= w[1]; y ^= v[0]; z = rotate(z ^ w[0], 33);
67
+ v = weak(at, u(v[1] * k1), u(x + w[0])); w = weak(at + 32, u(z + w[1]), y);
68
+ [z, x] = [x, z]; at += 64;
69
+ };
70
+ do { round(); round(); length -= 128; } while (length >= 128);
71
+ y = u(y + rotate(w[0], 37) * k0 + z); x = u(x + rotate(u(v[0] + z), 49) * k0);
72
+ for (let done = 0; done < length;) { done += 32; y = u(rotate(u(y - x), 42) * k0 + v[1]); w[0] = u(w[0] + fetch(at + length - done + 16)); x = u(rotate(x, 49) * k0 + w[0]); w[0] = u(w[0] + v[0]); v = weak(at + length - done, v[0], v[1]); }
73
+ x = hash16(x, v[0]); y = hash16(y, w[0]); return [u(hash16(u(x + v[1]), w[1]) + y), hash16(u(x + w[1]), u(y + v[1]))];
74
+ };
75
+ const length = bytes.length;
76
+ if (length >= 16) return seeded(16, length - 16, [fetch(0) ^ k3, fetch(8)]);
77
+ if (length >= 8) return seeded(0, 0, [fetch(0) ^ u(BigInt(length) * k0), fetch(length - 8) ^ k1]);
78
+ return seeded(0, length, [k0, k1]);
79
+ }
80
+
81
+ // ClickHouse src/Compression/CompressionInfo.h:7,14 (SHA25614b39ae2…): both limits are1GiB.
82
+ export const MAX_COMPRESSED_BLOCK_SIZE = 0x40000000;
83
+ export const MAX_DECOMPRESSED_BLOCK_SIZE = 0x40000000;
84
+
85
+ /** LZ4 block sequences. Validate all lengths before allocating the output buffer. */
86
+ export function decodeLz4Block(input: Uint8Array, size: number): Uint8Array {
87
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_DECOMPRESSED_BLOCK_SIZE || input.length > MAX_COMPRESSED_BLOCK_SIZE)
88
+ throw new RangeError('Invalid LZ4 block size');
89
+ // lz4_Block_format.md:114–126 requires a final literal sequence, five final literals
90
+ // (except a short all-literal block), and the last match at least12 bytes before its end.
91
+ // This is the format specification's strictness; ClickHouse's decoder is not retained
92
+ // here and may accept malformed end sequences that this decoder rejects.
93
+ const walk = (output?: Uint8Array): void => {
94
+ let source = 0, target = 0, sequences = 0, finalLiterals = -1, lastMatchStart = -1;
95
+ const length = (initial: number): number => {
96
+ let result = initial;
97
+ if (initial === 15) { let value: number; do { if (source >= input.length) throw new RangeError('Truncated LZ4 length'); value = input[source++]!; result += value; } while (value === 255); }
98
+ return result;
99
+ };
100
+ while (source < input.length) {
101
+ sequences++;
102
+ const token = input[source++]!, literals = length(token >> 4);
103
+ if (literals > input.length - source || literals > size - target) throw new RangeError('Invalid LZ4 literal range');
104
+ output?.set(input.subarray(source, source + literals), target); source += literals; target += literals;
105
+ if (source === input.length) { finalLiterals = literals; break; }
106
+ if (source + 2 > input.length) throw new RangeError('Truncated LZ4 offset');
107
+ const offset = input[source++]! | (input[source++]! << 8); if (!offset || offset > target) throw new RangeError('Invalid LZ4 offset');
108
+ const match = length(token & 15) + 4; if (match > size - target) throw new RangeError('Invalid LZ4 match range');
109
+ lastMatchStart = target;
110
+ if (output) for (let i = 0; i < match; i++) { output[target] = output[target - offset]!; target++; }
111
+ else target += match;
112
+ }
113
+ if (finalLiterals < 0 || (size >= 5 && finalLiterals < 5) || (size < 5 && sequences !== 1) || (lastMatchStart >= 0 && size - lastMatchStart < 12))
114
+ throw new RangeError('Invalid LZ4 end sequence');
115
+ if (target !== size) throw new RangeError('LZ4 decoded size differs');
116
+ };
117
+ walk();
118
+ const output = new Uint8Array(size); walk(output); return output;
119
+ }
120
+ /** A valid literal-only LZ4 block. */
121
+ export function encodeLz4Literals(input: Uint8Array): Uint8Array {
122
+ if (input.length > MAX_DECOMPRESSED_BLOCK_SIZE) throw new RangeError('LZ4 block is too large');
123
+ const extension = input.length >= 15 ? Math.floor((input.length - 15) / 255) + 1 : 0;
124
+ const output = new Uint8Array(1 + extension + input.length); let at = 0;
125
+ output[at++] = Math.min(input.length, 15) << 4;
126
+ if (extension) { let left = input.length - 15; while (left >= 255) { output[at++] = 255; left -= 255; } output[at++] = left; }
127
+ output.set(input, at); return output;
128
+ }
129
+
130
+ export class ClickHouseCompressionError extends Error {
131
+ constructor(readonly code: number, message: string) { super(message); this.name = 'ClickHouseCompressionError'; }
132
+ }
133
+ /** ClickHouse compressed frame, including its sixteen-byte checksum.
134
+ * ICompressionCodec.cpp:62–69,102–116 (SHA2562cfbdbd7…) writes the9-byte header.
135
+ * CompressedReadBufferBase.cpp:193–194,255–256 (SHA2566f5af3a8…) reads low/high
136
+ * little-endian checksum words and verifies the complete header+payload before decompressing.
137
+ */
138
+ export function encodeFrame(input: Uint8Array, method: 0x82 | 0x02 = 0x82): Uint8Array {
139
+ if (!input.length || input.length > MAX_DECOMPRESSED_BLOCK_SIZE) throw new ClickHouseCompressionError(39, 'Invalid decompressed block size');
140
+ if (method !== 0x82 && method !== 0x02) throw new ClickHouseCompressionError(89, 'Unknown compression method');
141
+ const bodyLength = method === 0x82 ? 1 + (input.length >= 15 ? Math.floor((input.length - 15) / 255) + 1 : 0) + input.length : input.length;
142
+ if (bodyLength + 9 > MAX_COMPRESSED_BLOCK_SIZE) throw new ClickHouseCompressionError(39, 'Compressed block is too large');
143
+ const body = method === 0x82 ? encodeLz4Literals(input) : input;
144
+ const output = new Uint8Array(25 + body.length), view = new DataView(output.buffer);
145
+ output[16] = method; view.setUint32(17, 9 + body.length, true); view.setUint32(21, input.length, true); output.set(body, 25);
146
+ const [low, high] = cityHash128(output.subarray(16)); view.setBigUint64(0, low, true); view.setBigUint64(8, high, true);
147
+ return output;
148
+ }
149
+ /** Decode one frame; undefined means the input has not yet supplied the whole frame.
150
+ * Sizes are validated from the header before waiting for its body, and the checksum
151
+ * precedes decompression. bytesRead allows the caller to retain subsequent frames.
152
+ */
153
+ export function decodeFrame(input: Uint8Array): { data: Uint8Array; bytesRead: number } | undefined {
154
+ if (input.length < 25) return undefined;
155
+ const view = new DataView(input.buffer, input.byteOffset, input.byteLength), method = input[16]!;
156
+ // CompressedReadBufferBase.cpp:134–138 selects/refuses the codec before header sizes.
157
+ if (method !== 0x82 && method !== 0x02) throw new ClickHouseCompressionError(89, 'Unknown compression method');
158
+ const compressed = view.getUint32(17, true), size = view.getUint32(21, true);
159
+ if (compressed > MAX_COMPRESSED_BLOCK_SIZE || size > MAX_DECOMPRESSED_BLOCK_SIZE) throw new ClickHouseCompressionError(39, 'Compressed block size exceeds the vendor limit');
160
+ if (compressed < 9 || !size) throw new ClickHouseCompressionError(246, 'Corrupt compressed block header');
161
+ const bytesRead = 16 + compressed; if (input.length < bytesRead) return undefined;
162
+ const frame = input.subarray(16, bytesRead), [low, high] = cityHash128(frame);
163
+ if (low !== view.getBigUint64(0, true) || high !== view.getBigUint64(8, true)) throw new ClickHouseCompressionError(40, 'Checksum does not match');
164
+ const body = frame.subarray(9);
165
+ if (method === 0x02) {
166
+ if (body.length !== size) throw new ClickHouseCompressionError(246, 'Uncompressed block size differs');
167
+ // Own the returned bytes, as the LZ4 branch does.
168
+ return { data: body.slice(), bytesRead };
169
+ }
170
+ try { return { data: decodeLz4Block(body, size), bytesRead }; }
171
+ catch (error) { throw new ClickHouseCompressionError(246, error instanceof Error ? error.message : 'Invalid LZ4 block'); }
172
+ }
@@ -4,3 +4,5 @@
4
4
  export * from './values.ts';
5
5
  export * from './types.ts';
6
6
  export * from './sql.ts';
7
+
8
+ export * from './compression.ts';