@git-stunts/git-warp 12.1.0 → 12.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +8 -4
  2. package/bin/cli/commands/trust.js +37 -1
  3. package/bin/cli/infrastructure.js +14 -1
  4. package/bin/cli/schemas.js +4 -4
  5. package/bin/warp-graph.js +9 -2
  6. package/index.d.ts +18 -2
  7. package/package.json +1 -1
  8. package/src/domain/WarpGraph.js +4 -1
  9. package/src/domain/crdt/Dot.js +5 -0
  10. package/src/domain/crdt/LWW.js +3 -1
  11. package/src/domain/crdt/ORSet.js +63 -27
  12. package/src/domain/crdt/VersionVector.js +12 -0
  13. package/src/domain/errors/PatchError.js +27 -0
  14. package/src/domain/errors/StorageError.js +8 -0
  15. package/src/domain/errors/SyncError.js +1 -0
  16. package/src/domain/errors/TrustError.js +2 -0
  17. package/src/domain/errors/WriterError.js +5 -0
  18. package/src/domain/errors/index.js +1 -0
  19. package/src/domain/services/AuditVerifierService.js +32 -2
  20. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  21. package/src/domain/services/CheckpointService.js +12 -8
  22. package/src/domain/services/Frontier.js +18 -0
  23. package/src/domain/services/GCPolicy.js +25 -4
  24. package/src/domain/services/GraphTraversal.js +11 -50
  25. package/src/domain/services/HttpSyncServer.js +18 -29
  26. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  27. package/src/domain/services/JoinReducer.js +164 -31
  28. package/src/domain/services/MaterializedViewService.js +13 -2
  29. package/src/domain/services/PatchBuilderV2.js +210 -145
  30. package/src/domain/services/QueryBuilder.js +67 -30
  31. package/src/domain/services/SyncController.js +62 -18
  32. package/src/domain/services/SyncPayloadSchema.js +236 -0
  33. package/src/domain/services/SyncProtocol.js +102 -40
  34. package/src/domain/services/SyncTrustGate.js +146 -0
  35. package/src/domain/services/TranslationCost.js +2 -2
  36. package/src/domain/trust/TrustRecordService.js +161 -34
  37. package/src/domain/utils/CachedValue.js +34 -5
  38. package/src/domain/utils/EventId.js +4 -1
  39. package/src/domain/utils/LRUCache.js +3 -1
  40. package/src/domain/utils/RefLayout.js +4 -0
  41. package/src/domain/utils/canonicalStringify.js +48 -18
  42. package/src/domain/utils/matchGlob.js +7 -0
  43. package/src/domain/warp/PatchSession.js +30 -24
  44. package/src/domain/warp/Writer.js +12 -5
  45. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  46. package/src/domain/warp/checkpoint.methods.js +102 -16
  47. package/src/domain/warp/materialize.methods.js +47 -5
  48. package/src/domain/warp/materializeAdvanced.methods.js +52 -10
  49. package/src/domain/warp/patch.methods.js +24 -8
  50. package/src/domain/warp/query.methods.js +4 -4
  51. package/src/domain/warp/subscribe.methods.js +11 -19
  52. package/src/infrastructure/adapters/GitGraphAdapter.js +57 -54
  53. package/src/infrastructure/codecs/CborCodec.js +2 -0
  54. package/src/domain/utils/fnv1a.js +0 -20
@@ -22,15 +22,40 @@ import BitmapNeighborProvider from '../services/BitmapNeighborProvider.js';
22
22
 
23
23
  /** @typedef {import('../types/WarpPersistence.js').CorePersistence} CorePersistence */
24
24
  /** @typedef {import('../services/JoinReducer.js').WarpStateV5} WarpStateV5 */
25
+ /** @typedef {import('../types/TickReceipt.js').TickReceipt} TickReceipt */
25
26
 
26
27
  /**
27
28
  * @typedef {{ outgoing: Map<string, Array<{neighborId: string, label: string}>>, incoming: Map<string, Array<{neighborId: string, label: string}>> }} AdjacencyMap
28
- * @typedef {{ state: WarpStateV5, stateHash: string, adjacency: AdjacencyMap }} MaterializedResult
29
+ * @typedef {{ state: WarpStateV5, stateHash: string|null, adjacency: AdjacencyMap }} MaterializedResult
29
30
  */
30
31
 
31
32
  import { buildWriterRef } from '../utils/RefLayout.js';
32
33
  import { decodePatchMessage, detectMessageKind } from '../services/WarpMessageCodec.js';
33
34
 
35
+ /**
36
+ * Creates a shallow-frozen public view of materialized state.
37
+ *
38
+ * @param {WarpStateV5} state
39
+ * @returns {WarpStateV5}
40
+ */
41
+ function freezePublicState(state) {
42
+ return Object.freeze({ ...state });
43
+ }
44
+
45
+ /**
46
+ * Creates a shallow-frozen public materialization result with receipts.
47
+ *
48
+ * @param {WarpStateV5} state
49
+ * @param {TickReceipt[]} receipts
50
+ * @returns {{state: WarpStateV5, receipts: TickReceipt[]}}
51
+ */
52
+ function freezePublicStateWithReceipts(state, receipts) {
53
+ return Object.freeze({
54
+ state: freezePublicState(state),
55
+ receipts,
56
+ });
57
+ }
58
+
34
59
  /**
35
60
  * Resolves the effective ceiling from options and instance state.
36
61
  *
@@ -107,11 +132,23 @@ export function _buildAdjacency(state) {
107
132
  *
108
133
  * @this {import('../WarpGraph.js').default}
109
134
  * @param {import('../services/JoinReducer.js').WarpStateV5} state
110
- * @param {import('../types/PatchDiff.js').PatchDiff} [diff] - Optional diff for incremental index
135
+ * @param {import('../types/PatchDiff.js').PatchDiff|{diff?: import('../types/PatchDiff.js').PatchDiff|null}} [optionsOrDiff]
136
+ * Either a PatchDiff (legacy positional form) or options object.
111
137
  * @returns {Promise<MaterializedResult>}
112
138
  * @private
113
139
  */
114
- export async function _setMaterializedState(state, diff) {
140
+ export async function _setMaterializedState(state, optionsOrDiff) {
141
+ /** @type {import('../types/PatchDiff.js').PatchDiff|undefined} */
142
+ let diff;
143
+ if (
144
+ optionsOrDiff &&
145
+ typeof optionsOrDiff === 'object' &&
146
+ Object.prototype.hasOwnProperty.call(optionsOrDiff, 'diff')
147
+ ) {
148
+ diff = /** @type {{diff?: import('../types/PatchDiff.js').PatchDiff|null}} */ (optionsOrDiff).diff ?? undefined;
149
+ } else {
150
+ diff = /** @type {import('../types/PatchDiff.js').PatchDiff|undefined} */ (optionsOrDiff ?? undefined);
151
+ }
115
152
  this._cachedState = state;
116
153
  this._stateDirty = false;
117
154
  this._versionVector = vvClone(state.observedFrontier);
@@ -167,6 +204,7 @@ export function _buildView(state, stateHash, diff) {
167
204
  this._propertyReader = result.propertyReader;
168
205
  this._cachedViewHash = stateHash;
169
206
  this._cachedIndexTree = result.tree;
207
+ this._indexDegraded = false;
170
208
 
171
209
  const provider = new BitmapNeighborProvider({ logicalIndex: result.logicalIndex });
172
210
  if (this._materializedGraph) {
@@ -176,6 +214,7 @@ export function _buildView(state, stateHash, diff) {
176
214
  this._logger?.warn('[warp] index build failed, falling back to linear scan', {
177
215
  error: /** @type {Error} */ (err).message,
178
216
  });
217
+ this._indexDegraded = true;
179
218
  this._logicalIndex = null;
180
219
  this._propertyReader = null;
181
220
  this._cachedIndexTree = null;
@@ -220,7 +259,7 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
220
259
  cf.size === frontier.size &&
221
260
  [...frontier].every(([w, sha]) => cf.get(w) === sha)
222
261
  ) {
223
- return this._cachedState;
262
+ return freezePublicState(this._cachedState);
224
263
  }
225
264
 
226
265
  const writerIds = [...frontier.keys()];
@@ -234,9 +273,9 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
234
273
  this._cachedFrontier = frontier;
235
274
  this._logTiming('materialize', t0, { metrics: '0 patches (ceiling)' });
236
275
  if (collectReceipts) {
237
- return { state, receipts: [] };
276
+ return freezePublicStateWithReceipts(state, []);
238
277
  }
239
- return state;
278
+ return freezePublicState(state);
240
279
  }
241
280
 
242
281
  // Persistent cache check — skip when collectReceipts is requested
@@ -257,7 +296,7 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
257
296
  await this._restoreIndexFromCache(cached.indexTreeOid);
258
297
  }
259
298
  this._logTiming('materialize', t0, { metrics: `cache hit (ceiling=${ceiling})` });
260
- return state;
299
+ return freezePublicState(state);
261
300
  } catch {
262
301
  // Corrupted payload — self-heal by removing the bad entry
263
302
  try { await this._seekCache.delete(cacheKey); } catch { /* best-effort */ }
@@ -320,9 +359,12 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
320
359
  this._logTiming('materialize', t0, { metrics: `${allPatches.length} patches (ceiling=${ceiling})` });
321
360
 
322
361
  if (collectReceipts) {
323
- return { state, receipts: /** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts) };
362
+ return freezePublicStateWithReceipts(
363
+ state,
364
+ /** @type {TickReceipt[]} */ (receipts),
365
+ );
324
366
  }
325
- return state;
367
+ return freezePublicState(state);
326
368
  }
327
369
 
328
370
  /**
@@ -457,7 +499,7 @@ export async function materializeAt(checkpointSha) {
457
499
  codec: this._codec,
458
500
  });
459
501
  await this._setMaterializedState(state);
460
- return state;
502
+ return freezePublicState(state);
461
503
  }
462
504
 
463
505
  /**
@@ -10,9 +10,9 @@
10
10
 
11
11
  import { QueryError, E_NO_STATE_MSG, E_STALE_STATE_MSG } from './_internal.js';
12
12
  import { PatchBuilderV2 } from '../services/PatchBuilderV2.js';
13
- import { joinStates, join as joinPatch } from '../services/JoinReducer.js';
13
+ import { joinStates, applyWithDiff, applyWithReceipt } from '../services/JoinReducer.js';
14
14
  import { orsetElements } from '../crdt/ORSet.js';
15
- import { vvIncrement } from '../crdt/VersionVector.js';
15
+ import { vvIncrement, vvClone } from '../crdt/VersionVector.js';
16
16
  import { buildWriterRef, buildWritersPrefix, parseWriterIdFromRef } from '../utils/RefLayout.js';
17
17
  import { decodePatchMessage, detectMessageKind } from '../services/WarpMessageCodec.js';
18
18
  import { Writer } from './Writer.js';
@@ -220,15 +220,16 @@ export async function _onPatchCommitted(writerId, { patch: committed, sha } = {}
220
220
  // Only when the cache is clean — applying a patch to stale state would be incorrect
221
221
  if (this._cachedState && !this._stateDirty && committed && sha) {
222
222
  let tickReceipt = null;
223
+ /** @type {import('../types/PatchDiff.js').PatchDiff|null} */
224
+ let diff = null;
223
225
  if (this._auditService) {
224
- const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}} */ (
225
- joinPatch(this._cachedState, /** @type {Parameters<typeof joinPatch>[1]} */ (committed), sha, true)
226
- );
226
+ const result = applyWithReceipt(this._cachedState, committed, sha);
227
227
  tickReceipt = result.receipt;
228
228
  } else {
229
- joinPatch(this._cachedState, /** @type {Parameters<typeof joinPatch>[1]} */ (committed), sha);
229
+ const result = applyWithDiff(this._cachedState, committed, sha);
230
+ diff = result.diff;
230
231
  }
231
- await this._setMaterializedState(this._cachedState);
232
+ await this._setMaterializedState(this._cachedState, { diff });
232
233
  // Update provenance index with new patch
233
234
  if (this._provenanceIndex) {
234
235
  this._provenanceIndex.addPatch(sha, /** @type {string[]|undefined} */ (committed.reads), /** @type {string[]|undefined} */ (committed.writes));
@@ -247,6 +248,7 @@ export async function _onPatchCommitted(writerId, { patch: committed, sha } = {}
247
248
  }
248
249
  } else {
249
250
  this._stateDirty = true;
251
+ this._cachedViewHash = null;
250
252
  if (this._auditService) {
251
253
  this._auditSkipCount++;
252
254
  this._logger?.warn('[warp:audit]', {
@@ -527,8 +529,22 @@ export function join(otherState) {
527
529
  !this._frontierEquals(this._cachedState.observedFrontier, mergedState.observedFrontier),
528
530
  };
529
531
 
530
- // Update cached state
532
+ // Install merged state as canonical (B108 — cache coherence fix)
531
533
  this._cachedState = mergedState;
534
+ this._versionVector = vvClone(mergedState.observedFrontier);
535
+
536
+ // Build adjacency synchronously (crypto hash deferred to next _buildView)
537
+ const adjacency = this._buildAdjacency(mergedState);
538
+ this._materializedGraph = { state: mergedState, stateHash: null, adjacency };
539
+
540
+ // Clear index caches — queries degrade to linear scan until next _buildView
541
+ this._logicalIndex = null;
542
+ this._propertyReader = null;
543
+ this._cachedViewHash = null;
544
+ this._cachedIndexTree = null;
545
+
546
+ // State IS fresh — don't force rematerialization
547
+ this._stateDirty = false;
532
548
 
533
549
  return { state: mergedState, receipt };
534
550
  }
@@ -319,9 +319,9 @@ export function query() {
319
319
  */
320
320
  export async function observer(name, config) {
321
321
  /** @param {unknown} m */
322
- const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
322
+ const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.length > 0 && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
323
323
  if (!config || !isValidMatch(config.match)) {
324
- throw new Error('observer config.match must be a string or array of strings');
324
+ throw new Error('observer config.match must be a non-empty string or non-empty array of strings');
325
325
  }
326
326
  await this._ensureFreshState();
327
327
  return new ObserverView({ name, config, graph: this });
@@ -332,11 +332,11 @@ export async function observer(name, config) {
332
332
  *
333
333
  * @this {import('../WarpGraph.js').default}
334
334
  * @param {Object} configA - Observer configuration for A
335
- * @param {string} configA.match - Glob pattern for visible nodes
335
+ * @param {string|string[]} configA.match - Glob pattern(s) for visible nodes
336
336
  * @param {string[]} [configA.expose] - Property keys to include
337
337
  * @param {string[]} [configA.redact] - Property keys to exclude
338
338
  * @param {Object} configB - Observer configuration for B
339
- * @param {string} configB.match - Glob pattern for visible nodes
339
+ * @param {string|string[]} configB.match - Glob pattern(s) for visible nodes
340
340
  * @param {string[]} [configB.expose] - Property keys to include
341
341
  * @param {string[]} [configB.redact] - Property keys to exclude
342
342
  * @returns {Promise<{cost: number, breakdown: {nodeLoss: number, edgeLoss: number, propLoss: number}}>}
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { diffStates, isEmptyDiff } from '../services/StateDiff.js';
9
+ import { matchGlob } from '../utils/matchGlob.js';
9
10
 
10
11
  /**
11
12
  * Subscribes to graph changes.
@@ -101,13 +102,13 @@ export function subscribe({ onChange, onError, replay = false }) {
101
102
  * be at least 1000ms.
102
103
  *
103
104
  * @this {import('../WarpGraph.js').default}
104
- * @param {string} pattern - Glob pattern (e.g., 'user:*', 'order:123', '*')
105
+ * @param {string|string[]} pattern - Glob pattern(s) (e.g., 'user:*', 'order:123', '*')
105
106
  * @param {Object} options - Watch options
106
107
  * @param {(diff: import('../services/StateDiff.js').StateDiffResult) => void} options.onChange - Called with filtered diff when matching changes occur
107
108
  * @param {(error: Error) => void} [options.onError] - Called if onChange throws an error
108
109
  * @param {number} [options.poll] - Poll interval in ms (min 1000); checks frontier and auto-materializes
109
110
  * @returns {{unsubscribe: () => void}} Subscription handle
110
- * @throws {Error} If pattern is not a string
111
+ * @throws {Error} If pattern is not a string or array of strings
111
112
  * @throws {Error} If onChange is not a function
112
113
  * @throws {Error} If poll is provided but less than 1000
113
114
  *
@@ -130,31 +131,22 @@ export function subscribe({ onChange, onError, replay = false }) {
130
131
  * unsubscribe();
131
132
  */
132
133
  export function watch(pattern, { onChange, onError, poll }) {
133
- if (typeof pattern !== 'string') {
134
- throw new Error('pattern must be a string');
134
+ const isValidPattern = (/** @type {string|string[]} */ p) => typeof p === 'string' || (Array.isArray(p) && p.length > 0 && p.every(i => typeof i === 'string'));
135
+ if (!isValidPattern(pattern)) {
136
+ throw new Error('pattern must be a non-empty string or non-empty array of strings');
135
137
  }
136
138
  if (typeof onChange !== 'function') {
137
139
  throw new Error('onChange must be a function');
138
140
  }
139
141
  if (poll !== undefined) {
140
- if (typeof poll !== 'number' || poll < 1000) {
141
- throw new Error('poll must be a number >= 1000');
142
+ if (typeof poll !== 'number' || !Number.isFinite(poll) || poll < 1000) {
143
+ throw new Error('poll must be a finite number >= 1000');
142
144
  }
143
145
  }
144
146
 
145
- // Pattern matching: same logic as QueryBuilder.match()
146
- // Pre-compile pattern matcher once for performance
147
+ // Pattern matching logic
147
148
  /** @type {(nodeId: string) => boolean} */
148
- let matchesPattern;
149
- if (pattern === '*') {
150
- matchesPattern = () => true;
151
- } else if (pattern.includes('*')) {
152
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
153
- const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
154
- matchesPattern = (/** @type {string} */ nodeId) => regex.test(nodeId);
155
- } else {
156
- matchesPattern = (/** @type {string} */ nodeId) => nodeId === pattern;
157
- }
149
+ const matchesPattern = (nodeId) => matchGlob(pattern, nodeId);
158
150
 
159
151
  // Filtered onChange that only passes matching changes
160
152
  const filteredOnChange = (/** @type {import('../services/StateDiff.js').StateDiffResult} */ diff) => {
@@ -194,7 +186,7 @@ export function watch(pattern, { onChange, onError, poll }) {
194
186
  /** @type {ReturnType<typeof setInterval>|null} */
195
187
  let pollIntervalId = null;
196
188
  let pollInFlight = false;
197
- if (poll) {
189
+ if (poll !== undefined) {
198
190
  pollIntervalId = setInterval(() => {
199
191
  if (pollInFlight) {
200
192
  return;
@@ -135,29 +135,6 @@ function isDanglingObjectError(err) {
135
135
  );
136
136
  }
137
137
 
138
- /**
139
- * Checks whether a Git ref exists without resolving it.
140
- * @param {function(Object): Promise<string>} execute - The git command executor function
141
- * @param {string} ref - The ref to check (e.g., 'refs/warp/events/writers/alice')
142
- * @returns {Promise<boolean>} True if the ref exists, false otherwise
143
- * @throws {Error} If the git command fails for reasons other than a missing ref
144
- */
145
- async function refExists(execute, ref) {
146
- try {
147
- await execute({ args: ['show-ref', '--verify', '--quiet', ref] });
148
- return true;
149
- } catch (err) {
150
- const gitErr = /** @type {GitError} */ (err);
151
- if (getExitCode(gitErr) === 1) {
152
- return false;
153
- }
154
- if (isDanglingObjectError(gitErr)) {
155
- return false;
156
- }
157
- throw err;
158
- }
159
- }
160
-
161
138
  /**
162
139
  * Concrete implementation of {@link GraphPersistencePort} using Git plumbing commands.
163
140
  *
@@ -262,26 +239,37 @@ export default class GitGraphAdapter extends GraphPersistencePort {
262
239
  }
263
240
 
264
241
  /**
265
- * Creates a commit pointing to the empty tree.
266
- * @param {Object} options
267
- * @param {string} options.message - The commit message (typically CBOR-encoded patch data)
268
- * @param {string[]} [options.parents=[]] - Parent commit SHAs
269
- * @param {boolean} [options.sign=false] - Whether to GPG-sign the commit
270
- * @returns {Promise<string>} The SHA of the created commit
271
- * @throws {Error} If any parent OID is invalid
242
+ * Shared helper for commit creation. Validates parents, builds args, and
243
+ * executes `git commit-tree` with retry.
244
+ * @param {{ tree: string, parents: string[], message: string, sign: boolean }} opts
245
+ * @returns {Promise<string>} The created commit SHA
246
+ * @private
272
247
  */
273
- async commitNode({ message, parents = [], sign = false }) {
248
+ async _createCommit({ tree, parents, message, sign }) {
274
249
  for (const p of parents) {
275
250
  this._validateOid(p);
276
251
  }
277
252
  const parentArgs = parents.flatMap(p => ['-p', p]);
278
253
  const signArgs = sign ? ['-S'] : [];
279
- const args = ['commit-tree', this.emptyTree, ...parentArgs, ...signArgs, '-m', message];
254
+ const args = ['commit-tree', tree, ...parentArgs, ...signArgs, '-m', message];
280
255
 
281
256
  const oid = await this._executeWithRetry({ args });
282
257
  return oid.trim();
283
258
  }
284
259
 
260
+ /**
261
+ * Creates a commit pointing to the empty tree.
262
+ * @param {Object} options
263
+ * @param {string} options.message - The commit message (typically CBOR-encoded patch data)
264
+ * @param {string[]} [options.parents=[]] - Parent commit SHAs
265
+ * @param {boolean} [options.sign=false] - Whether to GPG-sign the commit
266
+ * @returns {Promise<string>} The SHA of the created commit
267
+ * @throws {Error} If any parent OID is invalid
268
+ */
269
+ async commitNode({ message, parents = [], sign = false }) {
270
+ return await this._createCommit({ tree: this.emptyTree, parents, message, sign });
271
+ }
272
+
285
273
  /**
286
274
  * Creates a commit pointing to a custom tree (not the empty tree).
287
275
  * Used for WARP patch commits that have attachment trees.
@@ -294,15 +282,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
294
282
  */
295
283
  async commitNodeWithTree({ treeOid, parents = [], message, sign = false }) {
296
284
  this._validateOid(treeOid);
297
- for (const p of parents) {
298
- this._validateOid(p);
299
- }
300
- const parentArgs = parents.flatMap(p => ['-p', p]);
301
- const signArgs = sign ? ['-S'] : [];
302
- const args = ['commit-tree', treeOid, ...parentArgs, ...signArgs, '-m', message];
303
-
304
- const oid = await this._executeWithRetry({ args });
305
- return oid.trim();
285
+ return await this._createCommit({ tree: treeOid, parents, message, sign });
306
286
  }
307
287
 
308
288
  /**
@@ -402,8 +382,13 @@ export default class GitGraphAdapter extends GraphPersistencePort {
402
382
  // -z flag ensures NUL-terminated output and ignores i18n.logOutputEncoding config
403
383
  const args = ['log', '-z', `-${limit}`];
404
384
  if (format) {
405
- // Strip NUL bytes from format - git -z flag handles NUL termination automatically
406
- // Node.js child_process rejects args containing null bytes
385
+ // Strip NUL (\x00) bytes from the caller-supplied format string.
386
+ // Why: Git's -z flag uses NUL as the record terminator in its output.
387
+ // If a format string contains literal NUL bytes (e.g. from %x00 expansion
388
+ // or caller-constructed strings), they corrupt the NUL-delimited output
389
+ // stream, causing downstream parsers to split records at the wrong
390
+ // boundaries. Additionally, Node.js child_process rejects argv entries
391
+ // that contain null bytes, so passing them through would throw.
407
392
  // eslint-disable-next-line no-control-regex
408
393
  const cleanFormat = format.replace(/\x00/g, '');
409
394
  args.push(`--format=${cleanFormat}`);
@@ -415,6 +400,8 @@ export default class GitGraphAdapter extends GraphPersistencePort {
415
400
  /**
416
401
  * Validates that a ref is safe to use in git commands.
417
402
  * Delegates to shared validation in adapterValidation.js.
403
+ *
404
+ * Instance method for port interface conformance and test mockability.
418
405
  * @param {string} ref - The ref to validate
419
406
  * @throws {Error} If ref contains invalid characters, is too long, or starts with -/--
420
407
  * @private
@@ -451,7 +438,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
451
438
 
452
439
  /**
453
440
  * Reads a tree and returns a map of path to content.
454
- * Processes blobs sequentially to avoid spawning too many concurrent reads.
441
+ * Reads blobs in batches of 16 to balance concurrency against fd/process limits.
455
442
  * @param {string} treeOid - The tree OID to read
456
443
  * @returns {Promise<Record<string, Buffer>>} Map of file path to blob content
457
444
  */
@@ -459,9 +446,16 @@ export default class GitGraphAdapter extends GraphPersistencePort {
459
446
  const oids = await this.readTreeOids(treeOid);
460
447
  /** @type {Record<string, Buffer>} */
461
448
  const files = {};
462
- // Process sequentially to avoid spawning thousands of concurrent readBlob calls
463
- for (const [path, oid] of Object.entries(oids)) {
464
- files[path] = await this.readBlob(oid);
449
+ const entries = Object.entries(oids);
450
+ const BATCH_SIZE = 16;
451
+ for (let i = 0; i < entries.length; i += BATCH_SIZE) {
452
+ const batch = entries.slice(i, i + BATCH_SIZE);
453
+ const results = await Promise.all(
454
+ batch.map(([, oid]) => this.readBlob(oid))
455
+ );
456
+ for (let j = 0; j < batch.length; j++) {
457
+ files[batch[j][0]] = results[j];
458
+ }
465
459
  }
466
460
  return files;
467
461
  }
@@ -539,20 +533,21 @@ export default class GitGraphAdapter extends GraphPersistencePort {
539
533
  */
540
534
  async readRef(ref) {
541
535
  this._validateRef(ref);
542
- const exists = await refExists(this._executeWithRetry.bind(this), ref);
543
- if (!exists) {
544
- return null;
545
- }
546
536
  try {
537
+ // --verify ensures exactly one revision is resolved; --quiet suppresses
538
+ // error messages and makes exit code 1 (not 128) the indicator for
539
+ // "ref does not exist", simplifying downstream handling.
547
540
  const oid = await this._executeWithRetry({
548
- args: ['rev-parse', ref]
541
+ args: ['rev-parse', '--verify', '--quiet', ref]
549
542
  });
550
543
  return oid.trim();
551
544
  } catch (err) {
552
545
  const gitErr = /** @type {GitError} */ (err);
546
+ // Exit code 1: ref does not exist (normal with --verify --quiet)
553
547
  if (getExitCode(gitErr) === 1) {
554
548
  return null;
555
549
  }
550
+ // Exit code 128 with dangling-object stderr: ref exists but target is missing
556
551
  if (isDanglingObjectError(gitErr)) {
557
552
  return null;
558
553
  }
@@ -575,8 +570,8 @@ export default class GitGraphAdapter extends GraphPersistencePort {
575
570
  async compareAndSwapRef(ref, newOid, expectedOid) {
576
571
  this._validateRef(ref);
577
572
  this._validateOid(newOid);
578
- // null means "ref must not exist" → use zero OID
579
- const oldArg = expectedOid || '0'.repeat(newOid.length);
573
+ // null means "ref must not exist" → use zero OID (always 40 chars for SHA-1)
574
+ const oldArg = expectedOid || '0'.repeat(40);
580
575
  if (expectedOid) {
581
576
  this._validateOid(expectedOid);
582
577
  }
@@ -602,6 +597,10 @@ export default class GitGraphAdapter extends GraphPersistencePort {
602
597
  /**
603
598
  * Validates that an OID is safe to use in git commands.
604
599
  * Delegates to shared validation in adapterValidation.js.
600
+ *
601
+ * Exists as a method (rather than inlining the import) so tests can
602
+ * spy/stub validation independently and so future adapters sharing
603
+ * the same port interface can override validation rules.
605
604
  * @param {string} oid - The OID to validate
606
605
  * @throws {Error} If OID is invalid
607
606
  * @private
@@ -613,6 +612,8 @@ export default class GitGraphAdapter extends GraphPersistencePort {
613
612
  /**
614
613
  * Validates that a limit is a safe positive integer.
615
614
  * Delegates to shared validation in adapterValidation.js.
615
+ *
616
+ * Instance method for port interface conformance and test mockability.
616
617
  * @param {number} limit - The limit to validate
617
618
  * @throws {Error} If limit is invalid
618
619
  * @private
@@ -759,6 +760,8 @@ export default class GitGraphAdapter extends GraphPersistencePort {
759
760
  /**
760
761
  * Validates that a config key is safe to use in git commands.
761
762
  * Delegates to shared validation in adapterValidation.js.
763
+ *
764
+ * Instance method for port interface conformance and test mockability.
762
765
  * @param {string} key - The config key to validate
763
766
  * @throws {Error} If key is invalid
764
767
  * @private
@@ -96,6 +96,8 @@ function isPlainObject(value) {
96
96
  function sortPlainObject(obj) {
97
97
  /** @type {Record<string, unknown>} */
98
98
  const sorted = {};
99
+ // Key sort ensures deterministic CBOR encoding regardless of insertion order.
100
+ // Required for content-addressed storage where byte-identical encoding is critical.
99
101
  const keys = Object.keys(obj).sort();
100
102
  for (const key of keys) {
101
103
  sorted[key] = sortKeys(obj[key]);
@@ -1,20 +0,0 @@
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
- }