@git-stunts/git-warp 11.5.1 → 12.1.0

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 (49) hide show
  1. package/README.md +137 -10
  2. package/bin/cli/commands/registry.js +4 -0
  3. package/bin/cli/commands/reindex.js +41 -0
  4. package/bin/cli/commands/verify-index.js +59 -0
  5. package/bin/cli/infrastructure.js +7 -2
  6. package/bin/cli/schemas.js +19 -0
  7. package/bin/cli/types.js +2 -0
  8. package/index.d.ts +52 -15
  9. package/package.json +3 -2
  10. package/src/domain/WarpGraph.js +40 -0
  11. package/src/domain/errors/ShardIdOverflowError.js +28 -0
  12. package/src/domain/errors/index.js +1 -0
  13. package/src/domain/services/AdjacencyNeighborProvider.js +140 -0
  14. package/src/domain/services/BitmapNeighborProvider.js +178 -0
  15. package/src/domain/services/CheckpointMessageCodec.js +3 -3
  16. package/src/domain/services/CheckpointService.js +77 -12
  17. package/src/domain/services/GraphTraversal.js +1239 -0
  18. package/src/domain/services/IncrementalIndexUpdater.js +765 -0
  19. package/src/domain/services/JoinReducer.js +233 -5
  20. package/src/domain/services/LogicalBitmapIndexBuilder.js +323 -0
  21. package/src/domain/services/LogicalIndexBuildService.js +108 -0
  22. package/src/domain/services/LogicalIndexReader.js +315 -0
  23. package/src/domain/services/LogicalTraversal.js +321 -202
  24. package/src/domain/services/MaterializedViewService.js +379 -0
  25. package/src/domain/services/ObserverView.js +132 -69
  26. package/src/domain/services/PatchBuilderV2.js +3 -3
  27. package/src/domain/services/PropertyIndexBuilder.js +64 -0
  28. package/src/domain/services/PropertyIndexReader.js +111 -0
  29. package/src/domain/services/QueryBuilder.js +15 -44
  30. package/src/domain/services/TemporalQuery.js +128 -14
  31. package/src/domain/services/TranslationCost.js +8 -24
  32. package/src/domain/types/PatchDiff.js +90 -0
  33. package/src/domain/types/WarpTypesV2.js +4 -4
  34. package/src/domain/utils/MinHeap.js +45 -17
  35. package/src/domain/utils/canonicalCbor.js +36 -0
  36. package/src/domain/utils/fnv1a.js +20 -0
  37. package/src/domain/utils/matchGlob.js +51 -0
  38. package/src/domain/utils/roaring.js +14 -3
  39. package/src/domain/utils/shardKey.js +40 -0
  40. package/src/domain/utils/toBytes.js +17 -0
  41. package/src/domain/warp/_wiredMethods.d.ts +7 -1
  42. package/src/domain/warp/checkpoint.methods.js +21 -5
  43. package/src/domain/warp/materialize.methods.js +17 -5
  44. package/src/domain/warp/materializeAdvanced.methods.js +142 -3
  45. package/src/domain/warp/query.methods.js +83 -15
  46. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +26 -5
  47. package/src/ports/BlobPort.js +1 -1
  48. package/src/ports/NeighborProviderPort.js +59 -0
  49. package/src/ports/SeekCachePort.js +4 -3
@@ -8,10 +8,18 @@
8
8
  class MinHeap {
9
9
  /**
10
10
  * Creates an empty MinHeap.
11
+ *
12
+ * @param {Object} [options] - Configuration options
13
+ * @param {((a: T, b: T) => number)} [options.tieBreaker] - Comparator invoked when two
14
+ * entries have equal priority. Negative return = a wins (comes out first).
15
+ * When omitted, equal-priority extraction order is unspecified (heap-natural).
11
16
  */
12
- constructor() {
17
+ constructor(options) {
18
+ const { tieBreaker } = options || {};
13
19
  /** @type {Array<{item: T, priority: number}>} */
14
- this.heap = [];
20
+ this._heap = [];
21
+ /** @type {((a: T, b: T) => number) | undefined} */
22
+ this._tieBreaker = tieBreaker;
15
23
  }
16
24
 
17
25
  /**
@@ -22,8 +30,8 @@ class MinHeap {
22
30
  * @returns {void}
23
31
  */
24
32
  insert(item, priority) {
25
- this.heap.push({ item, priority });
26
- this._bubbleUp(this.heap.length - 1);
33
+ this._heap.push({ item, priority });
34
+ this._bubbleUp(this._heap.length - 1);
27
35
  }
28
36
 
29
37
  /**
@@ -32,11 +40,11 @@ class MinHeap {
32
40
  * @returns {T | undefined} The item with lowest priority, or undefined if empty
33
41
  */
34
42
  extractMin() {
35
- if (this.heap.length === 0) { return undefined; }
36
- if (this.heap.length === 1) { return /** @type {{item: T, priority: number}} */ (this.heap.pop()).item; }
43
+ if (this._heap.length === 0) { return undefined; }
44
+ if (this._heap.length === 1) { return /** @type {{item: T, priority: number}} */ (this._heap.pop()).item; }
37
45
 
38
- const min = this.heap[0];
39
- this.heap[0] = /** @type {{item: T, priority: number}} */ (this.heap.pop());
46
+ const min = this._heap[0];
47
+ this._heap[0] = /** @type {{item: T, priority: number}} */ (this._heap.pop());
40
48
  this._bubbleDown(0);
41
49
  return min.item;
42
50
  }
@@ -47,7 +55,7 @@ class MinHeap {
47
55
  * @returns {boolean} True if empty
48
56
  */
49
57
  isEmpty() {
50
- return this.heap.length === 0;
58
+ return this._heap.length === 0;
51
59
  }
52
60
 
53
61
  /**
@@ -56,7 +64,7 @@ class MinHeap {
56
64
  * @returns {number} Number of items
57
65
  */
58
66
  size() {
59
- return this.heap.length;
67
+ return this._heap.length;
60
68
  }
61
69
 
62
70
  /**
@@ -65,7 +73,27 @@ class MinHeap {
65
73
  * @returns {number} The minimum priority value, or Infinity if empty
66
74
  */
67
75
  peekPriority() {
68
- return this.heap.length > 0 ? this.heap[0].priority : Infinity;
76
+ return this._heap.length > 0 ? this._heap[0].priority : Infinity;
77
+ }
78
+
79
+ /**
80
+ * Compares two heap entries. Returns negative if a should come before b.
81
+ *
82
+ * @private
83
+ * @param {number} idxA - Index of first entry
84
+ * @param {number} idxB - Index of second entry
85
+ * @returns {number} Negative if a < b, positive if a > b, zero if equal
86
+ */
87
+ _compare(idxA, idxB) {
88
+ const a = this._heap[idxA];
89
+ const b = this._heap[idxB];
90
+ if (a.priority !== b.priority) {
91
+ return a.priority - b.priority;
92
+ }
93
+ if (this._tieBreaker) {
94
+ return this._tieBreaker(a.item, b.item);
95
+ }
96
+ return 0;
69
97
  }
70
98
 
71
99
  /**
@@ -78,8 +106,8 @@ class MinHeap {
78
106
  let current = pos;
79
107
  while (current > 0) {
80
108
  const parentIndex = Math.floor((current - 1) / 2);
81
- if (this.heap[parentIndex].priority <= this.heap[current].priority) { break; }
82
- [this.heap[parentIndex], this.heap[current]] = [this.heap[current], this.heap[parentIndex]];
109
+ if (this._compare(parentIndex, current) <= 0) { break; }
110
+ [this._heap[parentIndex], this._heap[current]] = [this._heap[current], this._heap[parentIndex]];
83
111
  current = parentIndex;
84
112
  }
85
113
  }
@@ -91,22 +119,22 @@ class MinHeap {
91
119
  * @param {number} pos - Starting index
92
120
  */
93
121
  _bubbleDown(pos) {
94
- const {length} = this.heap;
122
+ const {length} = this._heap;
95
123
  let current = pos;
96
124
  while (true) {
97
125
  const leftChild = 2 * current + 1;
98
126
  const rightChild = 2 * current + 2;
99
127
  let smallest = current;
100
128
 
101
- if (leftChild < length && this.heap[leftChild].priority < this.heap[smallest].priority) {
129
+ if (leftChild < length && this._compare(leftChild, smallest) < 0) {
102
130
  smallest = leftChild;
103
131
  }
104
- if (rightChild < length && this.heap[rightChild].priority < this.heap[smallest].priority) {
132
+ if (rightChild < length && this._compare(rightChild, smallest) < 0) {
105
133
  smallest = rightChild;
106
134
  }
107
135
  if (smallest === current) { break; }
108
136
 
109
- [this.heap[current], this.heap[smallest]] = [this.heap[smallest], this.heap[current]];
137
+ [this._heap[current], this._heap[smallest]] = [this._heap[smallest], this._heap[current]];
110
138
  current = smallest;
111
139
  }
112
140
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Canonical CBOR encoding/decoding.
3
+ *
4
+ * Delegates to defaultCodec which already sorts keys recursively
5
+ * and handles Maps, null-prototype objects, and arrays.
6
+ *
7
+ * Deterministic output relies on cbor-x's key-sorting behaviour,
8
+ * which approximates RFC 7049 Section 3.9 (Canonical CBOR) by sorting
9
+ * map keys in length-first lexicographic order. This is sufficient for
10
+ * content-addressed equality within the WARP system but should not be
11
+ * assumed to match other canonical CBOR implementations byte-for-byte.
12
+ *
13
+ * @module domain/utils/canonicalCbor
14
+ */
15
+
16
+ import defaultCodec from './defaultCodec.js';
17
+
18
+ /**
19
+ * Encodes a value to canonical CBOR bytes with sorted keys.
20
+ *
21
+ * @param {unknown} value - The value to encode
22
+ * @returns {Uint8Array} CBOR-encoded bytes
23
+ */
24
+ export function encodeCanonicalCbor(value) {
25
+ return defaultCodec.encode(value);
26
+ }
27
+
28
+ /**
29
+ * Decodes CBOR bytes to a value.
30
+ *
31
+ * @param {Buffer|Uint8Array} buffer - CBOR bytes
32
+ * @returns {unknown} Decoded value
33
+ */
34
+ export function decodeCanonicalCbor(buffer) {
35
+ return defaultCodec.decode(buffer);
36
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * FNV-1a 32-bit hash function.
3
+ *
4
+ * Used for shard key computation when the input is not a hex SHA.
5
+ * Uses Math.imul for correct 32-bit multiplication semantics.
6
+ *
7
+ * @note Callers with non-ASCII node IDs should normalize to NFC before
8
+ * hashing to ensure consistent shard placement.
9
+ *
10
+ * @param {string} str - Input string
11
+ * @returns {number} Unsigned 32-bit FNV-1a hash
12
+ */
13
+ export default function fnv1a(str) {
14
+ let hash = 0x811c9dc5; // FNV offset basis
15
+ for (let i = 0; i < str.length; i++) {
16
+ hash ^= str.charCodeAt(i);
17
+ hash = Math.imul(hash, 0x01000193); // FNV prime
18
+ }
19
+ return hash >>> 0; // Ensure unsigned
20
+ }
@@ -0,0 +1,51 @@
1
+ /** @type {Map<string, RegExp>} Module-level cache for compiled glob regexes. */
2
+ const globRegexCache = new Map();
3
+
4
+ /**
5
+ * Escapes special regex characters in a string so it can be used as a literal match.
6
+ *
7
+ * @param {string} value - The string to escape
8
+ * @returns {string} The escaped string safe for use in a RegExp
9
+ * @private
10
+ */
11
+ function escapeRegex(value) {
12
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ }
14
+
15
+ /**
16
+ * Tests whether a string matches a glob-style pattern or an array of patterns.
17
+ *
18
+ * Supports:
19
+ * - `*` as the default pattern, matching all strings
20
+ * - Wildcard `*` anywhere in the pattern, matching zero or more characters
21
+ * - Literal match when pattern contains no wildcards
22
+ * - Array of patterns: returns true if ANY pattern matches (OR semantics)
23
+ *
24
+ * @param {string|string[]} pattern - The glob pattern(s) to match against
25
+ * @param {string} str - The string to test
26
+ * @returns {boolean} True if the string matches any of the patterns
27
+ */
28
+ export function matchGlob(pattern, str) {
29
+ if (Array.isArray(pattern)) {
30
+ return pattern.some((p) => matchGlob(p, str));
31
+ }
32
+
33
+ if (pattern === '*') {
34
+ return true;
35
+ }
36
+
37
+ if (typeof pattern !== 'string') {
38
+ return false;
39
+ }
40
+
41
+ if (!pattern.includes('*')) {
42
+ return pattern === str;
43
+ }
44
+
45
+ let regex = globRegexCache.get(pattern);
46
+ if (!regex) {
47
+ regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
48
+ globRegexCache.set(pattern, regex);
49
+ }
50
+ return regex.test(str);
51
+ }
@@ -50,6 +50,7 @@ const NOT_CHECKED = Symbol('NOT_CHECKED');
50
50
  * @typedef {Object} RoaringBitmapSubset
51
51
  * @property {number} size
52
52
  * @property {function(number): void} add
53
+ * @property {function(number): void} remove
53
54
  * @property {function(number): boolean} has
54
55
  * @property {function(Iterable<number>): void} orInPlace
55
56
  * @property {function(boolean): Uint8Array} serialize
@@ -63,6 +64,13 @@ const NOT_CHECKED = Symbol('NOT_CHECKED');
63
64
  */
64
65
  let roaringModule = null;
65
66
 
67
+ /**
68
+ * Captures module initialization failure so callers can see the root cause.
69
+ * @type {unknown}
70
+ * @private
71
+ */
72
+ let initError = null;
73
+
66
74
  /**
67
75
  * Cached result of native availability check.
68
76
  * `NOT_CHECKED` means not yet checked, `null` means indeterminate.
@@ -83,7 +91,8 @@ let nativeAvailability = NOT_CHECKED;
83
91
  */
84
92
  function loadRoaring() {
85
93
  if (!roaringModule) {
86
- throw new Error('Roaring module not loaded. Call initRoaring() first or ensure top-level await import completed.');
94
+ const cause = initError instanceof Error ? ` Caused by: ${initError.message}` : '';
95
+ throw new Error(`Roaring module not loaded. Call initRoaring() first or ensure top-level await import completed.${cause}`);
87
96
  }
88
97
  return roaringModule;
89
98
  }
@@ -99,6 +108,7 @@ function loadRoaring() {
99
108
  export async function initRoaring(mod) {
100
109
  if (mod) {
101
110
  roaringModule = mod;
111
+ initError = null;
102
112
  return;
103
113
  }
104
114
  if (!roaringModule) {
@@ -113,8 +123,9 @@ export async function initRoaring(mod) {
113
123
  // Auto-initialize on module load (top-level await)
114
124
  try {
115
125
  await initRoaring();
116
- } catch {
117
- // Roaring may not be installed; functions will throw on use
126
+ } catch (err) {
127
+ // Roaring may not be installed; keep root cause for downstream diagnostics.
128
+ initError = err;
118
129
  }
119
130
 
120
131
  /**
@@ -0,0 +1,40 @@
1
+ const HEX_RE = /^[0-9a-fA-F]{40}$|^[0-9a-fA-F]{64}$/;
2
+
3
+ const encoder = new TextEncoder();
4
+
5
+ /**
6
+ * FNV-1a 32-bit over raw bytes (Uint8Array).
7
+ *
8
+ * @param {Uint8Array} bytes
9
+ * @returns {number} Unsigned 32-bit hash
10
+ */
11
+ function fnv1aBytes(bytes) {
12
+ let hash = 0x811c9dc5;
13
+ for (let i = 0; i < bytes.length; i++) {
14
+ hash ^= bytes[i];
15
+ hash = Math.imul(hash, 0x01000193);
16
+ }
17
+ return hash >>> 0;
18
+ }
19
+
20
+ /**
21
+ * Computes a 2-character hex shard key for a given ID.
22
+ *
23
+ * For hex SHAs (exactly 40 or 64 hex chars), uses the first two characters (lowercased).
24
+ * For all other strings, computes FNV-1a hash over UTF-8 bytes and takes the low byte.
25
+ *
26
+ * Returns '00' for null, undefined, or non-string inputs (graceful fallback).
27
+ *
28
+ * @param {string} id - Node ID or SHA
29
+ * @returns {string} 2-character lowercase hex shard key (e.g. 'ab', '0f')
30
+ */
31
+ export default function computeShardKey(id) {
32
+ if (id === null || id === undefined || typeof id !== 'string') {
33
+ return '00';
34
+ }
35
+ if (HEX_RE.test(id)) {
36
+ return id.substring(0, 2).toLowerCase();
37
+ }
38
+ const hash = fnv1aBytes(encoder.encode(id));
39
+ return (hash & 0xff).toString(16).padStart(2, '0');
40
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Normalizes a decoded byte payload to a Uint8Array.
3
+ *
4
+ * CBOR decoders may yield Buffer, Uint8Array, or plain number[] depending
5
+ * on runtime and codec implementation (e.g. cbor-x on Node vs Deno).
6
+ * This helper ensures Roaring bitmap deserialization and other binary APIs
7
+ * always receive a Uint8Array.
8
+ *
9
+ * @param {Uint8Array|ArrayLike<number>} value
10
+ * @returns {Uint8Array}
11
+ */
12
+ export default function toBytes(value) {
13
+ if (value instanceof Uint8Array) {
14
+ return value;
15
+ }
16
+ return Uint8Array.from(value);
17
+ }
@@ -151,6 +151,7 @@ interface CheckpointData {
151
151
  stateHash: string;
152
152
  schema: number;
153
153
  provenanceIndex?: unknown;
154
+ indexShardOids?: Record<string, string>;
154
155
  }
155
156
 
156
157
  export {};
@@ -247,8 +248,13 @@ declare module '../WarpGraph.js' {
247
248
  // ── materializeAdvanced.methods.js ────────────────────────────────────
248
249
  _resolveCeiling(options?: { ceiling?: number | null }): number | null;
249
250
  _buildAdjacency(state: WarpStateV5): { outgoing: Map<string, Array<{ neighborId: string; label: string }>>; incoming: Map<string, Array<{ neighborId: string; label: string }>> };
250
- _setMaterializedState(state: WarpStateV5): Promise<{ state: WarpStateV5; stateHash: string; adjacency: unknown }>;
251
+ _buildView(state: WarpStateV5, stateHash: string, diff?: import('../types/PatchDiff.js').PatchDiff): void;
252
+ _setMaterializedState(state: WarpStateV5, diff?: import('../types/PatchDiff.js').PatchDiff): Promise<{ state: WarpStateV5; stateHash: string; adjacency: unknown }>;
251
253
  _materializeWithCeiling(ceiling: number, collectReceipts: boolean, t0: number): Promise<WarpStateV5 | { state: WarpStateV5; receipts: TickReceipt[] }>;
254
+ _persistSeekCacheEntry(cacheKey: string, buf: Buffer, state: WarpStateV5): Promise<void>;
255
+ _restoreIndexFromCache(indexTreeOid: string): Promise<void>;
252
256
  materializeAt(checkpointSha: string): Promise<WarpStateV5>;
257
+ verifyIndex(options?: { seed?: number; sampleRate?: number }): { passed: number; failed: number; errors: Array<{ nodeId: string; direction: string; expected: string[]; actual: string[] }> };
258
+ invalidateIndex(): void;
253
259
  }
254
260
  }
@@ -60,7 +60,22 @@ export async function createCheckpoint() {
60
60
  this._checkpointing = prevCheckpointing;
61
61
  }
62
62
 
63
- // 4. Call CheckpointService.create() with provenance index if available
63
+ // 4. Reuse cached index tree or rebuild from view service
64
+ let indexTree = this._cachedIndexTree;
65
+ if (!indexTree && this._viewService) {
66
+ try {
67
+ const { tree } = this._viewService.build(state);
68
+ indexTree = tree;
69
+ } catch (err) {
70
+ const message = err instanceof Error ? err.message : String(err);
71
+ this._logger?.warn('[warp] checkpoint index build failed; saving checkpoint without index', {
72
+ error: message,
73
+ });
74
+ indexTree = null;
75
+ }
76
+ }
77
+
78
+ // 5. Create checkpoint commit with provenance index + index tree
64
79
  /** @type {CorePersistence} */
65
80
  const persistence = this._persistence;
66
81
  const checkpointSha = await createCheckpointCommit({
@@ -72,15 +87,16 @@ export async function createCheckpoint() {
72
87
  provenanceIndex: this._provenanceIndex || undefined,
73
88
  crypto: this._crypto,
74
89
  codec: this._codec,
90
+ indexTree: indexTree || undefined,
75
91
  });
76
92
 
77
- // 5. Update checkpoint ref
93
+ // 6. Update checkpoint ref
78
94
  const checkpointRef = buildCheckpointRef(this._graphName);
79
95
  await this._persistence.updateRef(checkpointRef, checkpointSha);
80
96
 
81
97
  this._logTiming('createCheckpoint', t0);
82
98
 
83
- // 6. Return checkpoint SHA
99
+ // 7. Return checkpoint SHA
84
100
  return checkpointSha;
85
101
  } catch (err) {
86
102
  this._logTiming('createCheckpoint', t0, { error: /** @type {Error} */ (err) });
@@ -137,7 +153,7 @@ export async function syncCoverage() {
137
153
  * Loads the latest checkpoint for this graph.
138
154
  *
139
155
  * @this {import('../WarpGraph.js').default}
140
- * @returns {Promise<{state: import('../services/JoinReducer.js').WarpStateV5, frontier: Map<string, string>, stateHash: string, schema: number, provenanceIndex?: import('../services/ProvenanceIndex.js').ProvenanceIndex}|null>} The checkpoint or null
156
+ * @returns {Promise<{state: import('../services/JoinReducer.js').WarpStateV5, frontier: Map<string, string>, stateHash: string, schema: number, provenanceIndex?: import('../services/ProvenanceIndex.js').ProvenanceIndex, indexShardOids?: Record<string, string>|null}|null>} The checkpoint or null
141
157
  * @private
142
158
  */
143
159
  export async function _loadLatestCheckpoint() {
@@ -197,7 +213,7 @@ export async function _loadPatchesSince(checkpoint) {
197
213
  */
198
214
  export async function _validateMigrationBoundary() {
199
215
  const checkpoint = await this._loadLatestCheckpoint();
200
- if (checkpoint?.schema === 2 || checkpoint?.schema === 3) {
216
+ if (checkpoint?.schema === 2 || checkpoint?.schema === 3 || checkpoint?.schema === 4) {
201
217
  return; // Already migrated
202
218
  }
203
219
 
@@ -51,6 +51,7 @@ function scanPatchesForMaxLamport(graph, patches) {
51
51
  }
52
52
  }
53
53
 
54
+
54
55
  /**
55
56
  * Materializes the current graph state.
56
57
  *
@@ -99,10 +100,13 @@ export async function materialize(options) {
99
100
  let state;
100
101
  /** @type {import('../types/TickReceipt.js').TickReceipt[]|undefined} */
101
102
  let receipts;
103
+ /** @type {import('../types/PatchDiff.js').PatchDiff|undefined} */
104
+ let diff;
102
105
  let patchCount = 0;
106
+ const wantDiff = !collectReceipts && !!this._cachedIndexTree;
103
107
 
104
108
  // If checkpoint exists, use incremental materialization
105
- if (checkpoint?.schema === 2 || checkpoint?.schema === 3) {
109
+ if (checkpoint?.schema === 2 || checkpoint?.schema === 3 || checkpoint?.schema === 4) {
106
110
  const patches = await this._loadPatchesSince(checkpoint);
107
111
  // Update max observed Lamport so _nextLamport() issues globally-monotonic ticks.
108
112
  // Read the checkpoint frontier's tip commit messages to capture the pre-checkpoint max,
@@ -115,6 +119,10 @@ export async function materialize(options) {
115
119
  const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}} */ (reduceV5(/** @type {Parameters<typeof reduceV5>[0]} */ (patches), checkpoint.state, { receipts: true }));
116
120
  state = result.state;
117
121
  receipts = result.receipts;
122
+ } else if (wantDiff) {
123
+ const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, diff: import('../types/PatchDiff.js').PatchDiff}} */ (reduceV5(/** @type {Parameters<typeof reduceV5>[0]} */ (patches), checkpoint.state, { trackDiff: true }));
124
+ state = result.state;
125
+ diff = result.diff;
118
126
  } else {
119
127
  state = /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {Parameters<typeof reduceV5>[0]} */ (patches), checkpoint.state));
120
128
  }
@@ -164,6 +172,10 @@ export async function materialize(options) {
164
172
  const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}} */ (reduceV5(/** @type {Parameters<typeof reduceV5>[0]} */ (allPatches), undefined, { receipts: true }));
165
173
  state = result.state;
166
174
  receipts = result.receipts;
175
+ } else if (wantDiff) {
176
+ const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, diff: import('../types/PatchDiff.js').PatchDiff}} */ (reduceV5(/** @type {Parameters<typeof reduceV5>[0]} */ (allPatches), undefined, { trackDiff: true }));
177
+ state = result.state;
178
+ diff = result.diff;
167
179
  } else {
168
180
  state = /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {Parameters<typeof reduceV5>[0]} */ (allPatches)));
169
181
  }
@@ -178,7 +190,7 @@ export async function materialize(options) {
178
190
  }
179
191
  }
180
192
 
181
- await this._setMaterializedState(state);
193
+ await this._setMaterializedState(state, diff);
182
194
  this._provenanceDegraded = false;
183
195
  this._cachedCeiling = null;
184
196
  this._cachedFrontier = null;
@@ -202,9 +214,9 @@ export async function materialize(options) {
202
214
  // Also handles deferred replay for subscribers added with replay: true before cached state
203
215
  if (this._subscribers.length > 0) {
204
216
  const hasPendingReplay = this._subscribers.some(s => s.pendingReplay);
205
- const diff = diffStates(this._lastNotifiedState, state);
206
- if (!isEmptyDiff(diff) || hasPendingReplay) {
207
- this._notifySubscribers(diff, state);
217
+ const stateDelta = diffStates(this._lastNotifiedState, state);
218
+ if (!isEmptyDiff(stateDelta) || hasPendingReplay) {
219
+ this._notifySubscribers(stateDelta, state);
208
220
  }
209
221
  }
210
222
  // Clone state to prevent eager path mutations from affecting the baseline