@git-stunts/git-warp 11.5.0 → 12.0.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 (51) hide show
  1. package/README.md +145 -1
  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 +49 -12
  9. package/package.json +2 -2
  10. package/src/domain/WarpGraph.js +62 -2
  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/BitmapIndexReader.js +32 -10
  15. package/src/domain/services/BitmapNeighborProvider.js +178 -0
  16. package/src/domain/services/CheckpointMessageCodec.js +3 -3
  17. package/src/domain/services/CheckpointService.js +77 -12
  18. package/src/domain/services/GraphTraversal.js +1239 -0
  19. package/src/domain/services/IncrementalIndexUpdater.js +765 -0
  20. package/src/domain/services/JoinReducer.js +310 -46
  21. package/src/domain/services/LogicalBitmapIndexBuilder.js +323 -0
  22. package/src/domain/services/LogicalIndexBuildService.js +108 -0
  23. package/src/domain/services/LogicalIndexReader.js +315 -0
  24. package/src/domain/services/LogicalTraversal.js +321 -202
  25. package/src/domain/services/MaterializedViewService.js +379 -0
  26. package/src/domain/services/ObserverView.js +138 -47
  27. package/src/domain/services/PatchBuilderV2.js +3 -3
  28. package/src/domain/services/PropertyIndexBuilder.js +64 -0
  29. package/src/domain/services/PropertyIndexReader.js +111 -0
  30. package/src/domain/services/SyncController.js +576 -0
  31. package/src/domain/services/TemporalQuery.js +128 -14
  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/roaring.js +14 -3
  38. package/src/domain/utils/shardKey.js +40 -0
  39. package/src/domain/utils/toBytes.js +17 -0
  40. package/src/domain/utils/validateShardOid.js +13 -0
  41. package/src/domain/warp/_internal.js +0 -9
  42. package/src/domain/warp/_wiredMethods.d.ts +8 -2
  43. package/src/domain/warp/checkpoint.methods.js +21 -5
  44. package/src/domain/warp/materialize.methods.js +17 -5
  45. package/src/domain/warp/materializeAdvanced.methods.js +142 -3
  46. package/src/domain/warp/query.methods.js +78 -12
  47. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +26 -5
  48. package/src/ports/BlobPort.js +1 -1
  49. package/src/ports/NeighborProviderPort.js +59 -0
  50. package/src/ports/SeekCachePort.js +4 -3
  51. package/src/domain/warp/sync.methods.js +0 -554
@@ -26,7 +26,7 @@
26
26
  * @see Paper IV - Echo and the WARP Core (CTL* temporal logic on histories)
27
27
  */
28
28
 
29
- import { createEmptyStateV5, join as joinPatch } from './JoinReducer.js';
29
+ import { createEmptyStateV5, cloneStateV5, join as joinPatch } from './JoinReducer.js';
30
30
  import { decodePropKey } from './KeyCodec.js';
31
31
  import { orsetContains } from '../crdt/ORSet.js';
32
32
 
@@ -83,6 +83,64 @@ function extractNodeSnapshot(state, nodeId) {
83
83
  return { id: nodeId, exists, props };
84
84
  }
85
85
 
86
+ /**
87
+ * Evaluates checkpoint boundary semantics for `always()`.
88
+ *
89
+ * @param {Object} params
90
+ * @param {import('./JoinReducer.js').WarpStateV5} params.state
91
+ * @param {string} params.nodeId
92
+ * @param {Function} params.predicate
93
+ * @param {number|null} params.checkpointMaxLamport
94
+ * @param {number} params.since
95
+ * @returns {{ nodeEverExisted: boolean, shouldReturn: boolean, returnValue: boolean }}
96
+ * @private
97
+ */
98
+ function evaluateAlwaysCheckpointBoundary({
99
+ state,
100
+ nodeId,
101
+ predicate,
102
+ checkpointMaxLamport,
103
+ since,
104
+ }) {
105
+ if (checkpointMaxLamport !== since) {
106
+ return { nodeEverExisted: false, shouldReturn: false, returnValue: false };
107
+ }
108
+ const snapshot = extractNodeSnapshot(state, nodeId);
109
+ if (!snapshot.exists) {
110
+ return { nodeEverExisted: false, shouldReturn: false, returnValue: false };
111
+ }
112
+ if (!predicate(snapshot)) {
113
+ return { nodeEverExisted: true, shouldReturn: true, returnValue: false };
114
+ }
115
+ return { nodeEverExisted: true, shouldReturn: false, returnValue: false };
116
+ }
117
+
118
+ /**
119
+ * Evaluates checkpoint boundary semantics for `eventually()`.
120
+ *
121
+ * @param {Object} params
122
+ * @param {import('./JoinReducer.js').WarpStateV5} params.state
123
+ * @param {string} params.nodeId
124
+ * @param {Function} params.predicate
125
+ * @param {number|null} params.checkpointMaxLamport
126
+ * @param {number} params.since
127
+ * @returns {boolean}
128
+ * @private
129
+ */
130
+ function evaluateEventuallyCheckpointBoundary({
131
+ state,
132
+ nodeId,
133
+ predicate,
134
+ checkpointMaxLamport,
135
+ since,
136
+ }) {
137
+ if (checkpointMaxLamport !== since) {
138
+ return false;
139
+ }
140
+ const snapshot = extractNodeSnapshot(state, nodeId);
141
+ return snapshot.exists && predicate(snapshot);
142
+ }
143
+
86
144
  /**
87
145
  * TemporalQuery provides temporal logic operators over graph history.
88
146
  *
@@ -94,10 +152,14 @@ export class TemporalQuery {
94
152
  * @param {Object} options
95
153
  * @param {Function} options.loadAllPatches - Async function that returns
96
154
  * all patches as Array<{ patch, sha }> in causal order.
155
+ * @param {Function} [options.loadCheckpoint] - Async function returning
156
+ * { state: WarpStateV5, maxLamport: number } or null.
97
157
  */
98
- constructor({ loadAllPatches }) {
158
+ constructor({ loadAllPatches, loadCheckpoint }) {
99
159
  /** @type {Function} */
100
160
  this._loadAllPatches = loadAllPatches;
161
+ /** @type {Function|null} */
162
+ this._loadCheckpoint = loadCheckpoint || null;
101
163
  }
102
164
 
103
165
  /**
@@ -128,19 +190,27 @@ export class TemporalQuery {
128
190
  const since = options.since ?? 0;
129
191
  const allPatches = await this._loadAllPatches();
130
192
 
131
- const state = createEmptyStateV5();
132
- let nodeEverExisted = false;
193
+ const { state, startIdx, checkpointMaxLamport } = await this._resolveStart(allPatches, since);
194
+ const boundary = evaluateAlwaysCheckpointBoundary({
195
+ state,
196
+ nodeId,
197
+ predicate,
198
+ checkpointMaxLamport,
199
+ since,
200
+ });
201
+ if (boundary.shouldReturn) {
202
+ return boundary.returnValue;
203
+ }
204
+ let { nodeEverExisted } = boundary;
133
205
 
134
- for (const { patch, sha } of allPatches) {
135
- // Apply the patch to state
206
+ for (let i = startIdx; i < allPatches.length; i++) {
207
+ const { patch, sha } = allPatches[i];
136
208
  joinPatch(state, patch, sha);
137
209
 
138
- // Skip patches before the `since` threshold
139
210
  if (patch.lamport < since) {
140
211
  continue;
141
212
  }
142
213
 
143
- // Extract node snapshot at this tick
144
214
  const snapshot = extractNodeSnapshot(state, nodeId);
145
215
 
146
216
  if (snapshot.exists) {
@@ -151,7 +221,6 @@ export class TemporalQuery {
151
221
  }
152
222
  }
153
223
 
154
- // If the node never existed in the range, return false
155
224
  return nodeEverExisted;
156
225
  }
157
226
 
@@ -181,18 +250,26 @@ export class TemporalQuery {
181
250
  const since = options.since ?? 0;
182
251
  const allPatches = await this._loadAllPatches();
183
252
 
184
- const state = createEmptyStateV5();
253
+ const { state, startIdx, checkpointMaxLamport } = await this._resolveStart(allPatches, since);
185
254
 
186
- for (const { patch, sha } of allPatches) {
187
- // Apply the patch to state
255
+ if (evaluateEventuallyCheckpointBoundary({
256
+ state,
257
+ nodeId,
258
+ predicate,
259
+ checkpointMaxLamport,
260
+ since,
261
+ })) {
262
+ return true;
263
+ }
264
+
265
+ for (let i = startIdx; i < allPatches.length; i++) {
266
+ const { patch, sha } = allPatches[i];
188
267
  joinPatch(state, patch, sha);
189
268
 
190
- // Skip patches before the `since` threshold
191
269
  if (patch.lamport < since) {
192
270
  continue;
193
271
  }
194
272
 
195
- // Extract node snapshot at this tick
196
273
  const snapshot = extractNodeSnapshot(state, nodeId);
197
274
 
198
275
  if (snapshot.exists && predicate(snapshot)) {
@@ -202,4 +279,41 @@ export class TemporalQuery {
202
279
 
203
280
  return false;
204
281
  }
282
+
283
+ /**
284
+ * Resolves the initial state and start index for temporal replay.
285
+ *
286
+ * When `since > 0` and a checkpoint is available with
287
+ * `maxLamport <= since`, uses the checkpoint state and skips
288
+ * patches already covered by it. Otherwise falls back to an
289
+ * empty state starting from index 0.
290
+ *
291
+ * **Checkpoint `maxLamport` invariant**: The checkpoint's `maxLamport` value
292
+ * MUST represent a fully-closed Lamport tick — i.e. ALL patches with
293
+ * `lamport <= maxLamport` are included in the checkpoint state. The
294
+ * `findIndex` below uses strict `>` to locate the first patch *after* the
295
+ * checkpoint boundary. If a checkpoint were created mid-tick (some but not
296
+ * all patches at a given Lamport value included), this would silently skip
297
+ * the remaining same-tick patches. Checkpoint creators MUST guarantee the
298
+ * all-or-nothing inclusion property for any given Lamport tick.
299
+ *
300
+ * @param {Array<{patch: {lamport: number, [k: string]: unknown}, sha: string}>} allPatches
301
+ * @param {number} since - Minimum Lamport tick
302
+ * @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, startIdx: number, checkpointMaxLamport: number|null}>}
303
+ * @private
304
+ */
305
+ async _resolveStart(allPatches, since) {
306
+ if (since > 0 && this._loadCheckpoint) {
307
+ const ck = /** @type {{ state: import('./JoinReducer.js').WarpStateV5, maxLamport: number } | null} */ (await this._loadCheckpoint());
308
+ if (ck && ck.state && ck.maxLamport <= since) {
309
+ const idx = allPatches.findIndex(
310
+ ({ patch }) => patch.lamport > ck.maxLamport,
311
+ );
312
+ const startIdx = idx < 0 ? allPatches.length : idx;
313
+ // Replay mutates state in-place; isolate checkpoint provider caches from query runs.
314
+ return { state: cloneStateV5(ck.state), startIdx, checkpointMaxLamport: ck.maxLamport };
315
+ }
316
+ }
317
+ return { state: createEmptyStateV5(), startIdx: 0, checkpointMaxLamport: null };
318
+ }
205
319
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * PatchDiff — captures alive-ness transitions during patch application.
3
+ *
4
+ * A diff entry is produced only when the alive-ness state of a node or edge
5
+ * actually changes, or when an LWW property winner changes. Redundant ops
6
+ * (e.g. NodeAdd on an already-alive node) produce no diff entries.
7
+ *
8
+ * @module domain/types/PatchDiff
9
+ */
10
+
11
+ /**
12
+ * @typedef {Object} EdgeDiffEntry
13
+ * @property {string} from - Source node ID
14
+ * @property {string} to - Target node ID
15
+ * @property {string} label - Edge label
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} PropDiffEntry
20
+ * @property {string} nodeId - Node (or edge-prop owner) ID
21
+ * @property {string} key - Property key
22
+ * @property {unknown} value - New LWW winner value
23
+ * @property {unknown} prevValue - Previous LWW winner value (undefined if none)
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} PatchDiff
28
+ * @property {string[]} nodesAdded - Nodes that transitioned not-alive → alive
29
+ * @property {string[]} nodesRemoved - Nodes that transitioned alive → not-alive
30
+ * @property {EdgeDiffEntry[]} edgesAdded - Edges that transitioned not-alive → alive
31
+ * @property {EdgeDiffEntry[]} edgesRemoved - Edges that transitioned alive → not-alive
32
+ * @property {PropDiffEntry[]} propsChanged - Properties whose LWW winner actually changed
33
+ */
34
+
35
+ /**
36
+ * Creates an empty PatchDiff.
37
+ *
38
+ * @returns {PatchDiff}
39
+ */
40
+ export function createEmptyDiff() {
41
+ return {
42
+ nodesAdded: [],
43
+ nodesRemoved: [],
44
+ edgesAdded: [],
45
+ edgesRemoved: [],
46
+ propsChanged: [],
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Merges two PatchDiff objects into a net diff by cancelling out
52
+ * contradictory add/remove pairs.
53
+ *
54
+ * - A node that appears in `a.nodesAdded` and `b.nodesRemoved` (or vice-versa)
55
+ * is dropped from both lists (the transitions cancel out).
56
+ * - Same logic applies to edges (keyed by `from\0to\0label`).
57
+ * - For `propsChanged`, only the last entry per `(nodeId, key)` is kept.
58
+ *
59
+ * @param {PatchDiff} a
60
+ * @param {PatchDiff} b
61
+ * @returns {PatchDiff}
62
+ */
63
+ export function mergeDiffs(a, b) {
64
+ const allAdded = a.nodesAdded.concat(b.nodesAdded);
65
+ const allRemoved = a.nodesRemoved.concat(b.nodesRemoved);
66
+ const removedSet = new Set(allRemoved);
67
+ const addedSet = new Set(allAdded);
68
+ const nodesAdded = allAdded.filter((id) => !removedSet.has(id));
69
+ const nodesRemoved = allRemoved.filter((id) => !addedSet.has(id));
70
+
71
+ /** @param {EdgeDiffEntry} e */
72
+ const edgeKey = (e) => `${e.from}\0${e.to}\0${e.label}`;
73
+ const allEdgesAdded = a.edgesAdded.concat(b.edgesAdded);
74
+ const allEdgesRemoved = a.edgesRemoved.concat(b.edgesRemoved);
75
+ const edgeRemovedSet = new Set(allEdgesRemoved.map(edgeKey));
76
+ const edgeAddedSet = new Set(allEdgesAdded.map(edgeKey));
77
+ const edgesAdded = allEdgesAdded.filter((e) => !edgeRemovedSet.has(edgeKey(e)));
78
+ const edgesRemoved = allEdgesRemoved.filter((e) => !edgeAddedSet.has(edgeKey(e)));
79
+
80
+ // For props, deduplicate by keeping the last entry per (nodeId, key).
81
+ const allProps = a.propsChanged.concat(b.propsChanged);
82
+ /** @type {Map<string, PropDiffEntry>} */
83
+ const propMap = new Map();
84
+ for (const entry of allProps) {
85
+ propMap.set(`${entry.nodeId}\0${entry.key}`, entry);
86
+ }
87
+ const propsChanged = [...propMap.values()];
88
+
89
+ return { nodesAdded, nodesRemoved, edgesAdded, edgesRemoved, propsChanged };
90
+ }
@@ -50,7 +50,7 @@
50
50
  * @typedef {Object} OpV2NodeRemove
51
51
  * @property {'NodeRemove'} type - Operation type discriminator
52
52
  * @property {NodeId} node - Node ID to remove
53
- * @property {Dot[]} observedDots - Dots being removed (add events observed)
53
+ * @property {string[]} observedDots - Encoded dot strings being removed (add events observed)
54
54
  */
55
55
 
56
56
  /**
@@ -70,7 +70,7 @@
70
70
  * @property {NodeId} from - Source node ID
71
71
  * @property {NodeId} to - Target node ID
72
72
  * @property {string} label - Edge label/type
73
- * @property {Dot[]} observedDots - Dots being removed (add events observed)
73
+ * @property {string[]} observedDots - Encoded dot strings being removed (add events observed)
74
74
  */
75
75
 
76
76
  /**
@@ -121,7 +121,7 @@ export function createNodeAddV2(node, dot) {
121
121
  /**
122
122
  * Creates a NodeRemove operation with observed dots
123
123
  * @param {NodeId} node - Node ID to remove
124
- * @param {Dot[]} observedDots - Dots being removed
124
+ * @param {string[]} observedDots - Encoded dot strings being removed
125
125
  * @returns {OpV2NodeRemove} NodeRemove operation
126
126
  */
127
127
  export function createNodeRemoveV2(node, observedDots) {
@@ -145,7 +145,7 @@ export function createEdgeAddV2(from, to, label, dot) {
145
145
  * @param {NodeId} from - Source node ID
146
146
  * @param {NodeId} to - Target node ID
147
147
  * @param {string} label - Edge label
148
- * @param {Dot[]} observedDots - Dots being removed
148
+ * @param {string[]} observedDots - Encoded dot strings being removed
149
149
  * @returns {OpV2EdgeRemove} EdgeRemove operation
150
150
  */
151
151
  export function createEdgeRemoveV2(from, to, label, observedDots) {
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Validates a shard Object ID (hex string, 4-64 chars).
3
+ *
4
+ * The 4-character minimum accommodates abbreviated OIDs used in test
5
+ * fixtures and short internal IDs. Full Git SHA-1 OIDs are 40 chars;
6
+ * SHA-256 OIDs are 64 chars.
7
+ *
8
+ * @param {string} oid - The OID to validate
9
+ * @returns {boolean} True if oid is a valid hex string of 4-64 characters
10
+ */
11
+ export function isValidShardOid(oid) {
12
+ return typeof oid === 'string' && /^[0-9a-fA-F]{4,64}$/.test(oid);
13
+ }
@@ -10,17 +10,8 @@
10
10
  // ── Error constructors ──────────────────────────────────────────────────────
11
11
  export { default as QueryError } from '../errors/QueryError.js';
12
12
  export { default as ForkError } from '../errors/ForkError.js';
13
- export { default as SyncError } from '../errors/SyncError.js';
14
- export { default as OperationAbortedError } from '../errors/OperationAbortedError.js';
15
13
 
16
14
  // ── Shared constants ────────────────────────────────────────────────────────
17
15
  export const DEFAULT_ADJACENCY_CACHE_SIZE = 3;
18
16
  export const E_NO_STATE_MSG = 'No materialized state. Call materialize() before querying, or use autoMaterialize: true (the default). See https://github.com/git-stunts/git-warp#materialization';
19
17
  export const E_STALE_STATE_MSG = 'State is stale (patches written since last materialize). Call materialize() to refresh. See https://github.com/git-stunts/git-warp#materialization';
20
-
21
- // ── Sync constants ──────────────────────────────────────────────────────────
22
- export const DEFAULT_SYNC_SERVER_MAX_BYTES = 4 * 1024 * 1024;
23
- export const DEFAULT_SYNC_WITH_RETRIES = 3;
24
- export const DEFAULT_SYNC_WITH_BASE_DELAY_MS = 250;
25
- export const DEFAULT_SYNC_WITH_MAX_DELAY_MS = 2000;
26
- export const DEFAULT_SYNC_WITH_TIMEOUT_MS = 10_000;