@git-stunts/git-warp 12.2.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 (45) hide show
  1. package/README.md +8 -6
  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 +4 -1
  6. package/index.d.ts +17 -1
  7. package/package.json +1 -1
  8. package/src/domain/WarpGraph.js +1 -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 +30 -23
  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/WriterError.js +5 -0
  16. package/src/domain/errors/index.js +1 -0
  17. package/src/domain/services/AuditVerifierService.js +32 -2
  18. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  19. package/src/domain/services/CheckpointService.js +10 -1
  20. package/src/domain/services/GCPolicy.js +25 -4
  21. package/src/domain/services/GraphTraversal.js +3 -1
  22. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  23. package/src/domain/services/JoinReducer.js +141 -31
  24. package/src/domain/services/MaterializedViewService.js +13 -2
  25. package/src/domain/services/PatchBuilderV2.js +181 -142
  26. package/src/domain/services/QueryBuilder.js +4 -0
  27. package/src/domain/services/SyncController.js +12 -31
  28. package/src/domain/services/SyncProtocol.js +75 -32
  29. package/src/domain/trust/TrustRecordService.js +50 -36
  30. package/src/domain/utils/CachedValue.js +34 -5
  31. package/src/domain/utils/EventId.js +4 -1
  32. package/src/domain/utils/LRUCache.js +3 -1
  33. package/src/domain/utils/RefLayout.js +4 -0
  34. package/src/domain/utils/canonicalStringify.js +48 -18
  35. package/src/domain/utils/matchGlob.js +7 -0
  36. package/src/domain/warp/PatchSession.js +30 -24
  37. package/src/domain/warp/Writer.js +5 -0
  38. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  39. package/src/domain/warp/checkpoint.methods.js +36 -7
  40. package/src/domain/warp/materialize.methods.js +44 -5
  41. package/src/domain/warp/materializeAdvanced.methods.js +50 -10
  42. package/src/domain/warp/patch.methods.js +19 -11
  43. package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
  44. package/src/infrastructure/codecs/CborCodec.js +2 -0
  45. package/src/domain/utils/fnv1a.js +0 -20
@@ -167,8 +167,28 @@ export async function _loadLatestCheckpoint() {
167
167
 
168
168
  try {
169
169
  return await loadCheckpoint(this._persistence, checkpointSha, { codec: this._codec });
170
- } catch {
171
- return null;
170
+ } catch (err) {
171
+ // "Not found" conditions (missing tree entries, missing blobs) are expected
172
+ // when a checkpoint ref exists but the objects have been pruned or are
173
+ // unreachable. In that case, fall back to full replay by returning null.
174
+ // Decode/corruption errors (e.g., CBOR parse failure, schema mismatch)
175
+ // should propagate so callers see the real problem.
176
+ // These string-contains checks match specific error messages from the
177
+ // persistence layer and codec:
178
+ // "missing" — git cat-file on pruned/unreachable objects
179
+ // "not found" — readTree entry lookup failures
180
+ // "ENOENT" — filesystem-level missing path (bare repo edge case)
181
+ // "non-empty string" — readRef/getNodeInfo called with empty/null SHA
182
+ const msg = err instanceof Error ? err.message : '';
183
+ if (
184
+ msg.includes('missing') ||
185
+ msg.includes('not found') ||
186
+ msg.includes('ENOENT') ||
187
+ msg.includes('non-empty string')
188
+ ) {
189
+ return null;
190
+ }
191
+ throw err;
172
192
  }
173
193
  }
174
194
 
@@ -188,9 +208,11 @@ export async function _loadPatchesSince(checkpoint) {
188
208
  const checkpointSha = checkpoint.frontier?.get(writerId) || null;
189
209
  const patches = await this._loadWriterPatches(writerId, checkpointSha);
190
210
 
191
- // Validate each patch against checkpoint frontier
192
- for (const { sha } of patches) {
193
- await this._validatePatchAgainstCheckpoint(writerId, sha, checkpoint);
211
+ // Validate ancestry once at the writer tip; chain-order patches are then
212
+ // transitively valid between checkpointSha and tipSha.
213
+ if (patches.length > 0) {
214
+ const tipSha = patches[patches.length - 1].sha;
215
+ await this._validatePatchAgainstCheckpoint(writerId, tipSha, checkpoint);
194
216
  }
195
217
 
196
218
  for (const p of patches) {
@@ -228,10 +250,16 @@ export async function _validateMigrationBoundary() {
228
250
  }
229
251
 
230
252
  /**
231
- * Checks if there are any schema:1 patches in the graph.
253
+ * Checks whether any writer tip contains a schema:1 patch.
254
+ *
255
+ * **Heuristic only** — inspects the most recent patch per writer (the tip),
256
+ * not the full history chain. Older schema:1 patches buried deeper in a
257
+ * writer's chain will NOT be detected. This is acceptable because migration
258
+ * typically writes a new tip, so a schema:2+ tip implies the writer has
259
+ * been migrated.
232
260
  *
233
261
  * @this {import('../WarpGraph.js').default}
234
- * @returns {Promise<boolean>} True if schema:1 patches exist
262
+ * @returns {Promise<boolean>} True if any writer tip is schema:1 (or omits `schema`, treated as legacy v1)
235
263
  * @private
236
264
  */
237
265
  export async function _hasSchema1Patches() {
@@ -312,6 +340,7 @@ export function _maybeRunGC(state) {
312
340
  if (preGcFingerprint !== postGcFingerprint) {
313
341
  // Frontier changed — discard compacted state, mark dirty
314
342
  this._stateDirty = true;
343
+ this._cachedViewHash = null;
315
344
  if (this._logger) {
316
345
  this._logger.warn(
317
346
  'Auto-GC discarded: frontier changed during compaction (concurrent write)',
@@ -51,6 +51,30 @@ function scanPatchesForMaxLamport(graph, patches) {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Creates a shallow-frozen public view of materialized state.
56
+ *
57
+ * @param {import('../services/JoinReducer.js').WarpStateV5} state
58
+ * @returns {import('../services/JoinReducer.js').WarpStateV5}
59
+ */
60
+ function freezePublicState(state) {
61
+ return Object.freeze({ ...state });
62
+ }
63
+
64
+ /**
65
+ * Creates a shallow-frozen public result for receipt-enabled materialization.
66
+ *
67
+ * @param {import('../services/JoinReducer.js').WarpStateV5} state
68
+ * @param {import('../types/TickReceipt.js').TickReceipt[]} receipts
69
+ * @returns {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}}
70
+ */
71
+ function freezePublicStateWithReceipts(state, receipts) {
72
+ return Object.freeze({
73
+ state: freezePublicState(state),
74
+ receipts,
75
+ });
76
+ }
77
+
54
78
 
55
79
  /**
56
80
  * Materializes the current graph state.
@@ -90,7 +114,12 @@ export async function materialize(options) {
90
114
  try {
91
115
  // When ceiling is active, delegate to ceiling-aware path (with its own cache)
92
116
  if (ceiling !== null) {
93
- return await this._materializeWithCeiling(ceiling, !!collectReceipts, t0);
117
+ const result = await this._materializeWithCeiling(ceiling, !!collectReceipts, t0);
118
+ if (collectReceipts) {
119
+ const withReceipts = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}} */ (result);
120
+ return freezePublicStateWithReceipts(withReceipts.state, withReceipts.receipts);
121
+ }
122
+ return freezePublicState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (result));
94
123
  }
95
124
 
96
125
  // Check for checkpoint
@@ -190,7 +219,7 @@ export async function materialize(options) {
190
219
  }
191
220
  }
192
221
 
193
- await this._setMaterializedState(state, diff);
222
+ await this._setMaterializedState(state, { diff });
194
223
  this._provenanceDegraded = false;
195
224
  this._cachedCeiling = null;
196
225
  this._cachedFrontier = null;
@@ -225,9 +254,12 @@ export async function materialize(options) {
225
254
  this._logTiming('materialize', t0, { metrics: `${patchCount} patches` });
226
255
 
227
256
  if (collectReceipts) {
228
- return { state, receipts: /** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts) };
257
+ return freezePublicStateWithReceipts(
258
+ /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state),
259
+ /** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts),
260
+ );
229
261
  }
230
- return state;
262
+ return freezePublicState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
231
263
  } catch (err) {
232
264
  this._logTiming('materialize', t0, { error: /** @type {Error} */ (err) });
233
265
  throw err;
@@ -245,7 +277,14 @@ export async function _materializeGraph() {
245
277
  if (!this._stateDirty && this._materializedGraph) {
246
278
  return this._materializedGraph;
247
279
  }
248
- const state = await this.materialize();
280
+ const materialized = await this.materialize();
281
+ const state = this._stateDirty
282
+ ? /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (materialized)
283
+ : (this._cachedState
284
+ || /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (materialized));
285
+ if (!state) {
286
+ return /** @type {object} */ (this._materializedGraph);
287
+ }
249
288
  if (!this._materializedGraph || this._materializedGraph.state !== state) {
250
289
  await this._setMaterializedState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
251
290
  }
@@ -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);
@@ -222,7 +259,7 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
222
259
  cf.size === frontier.size &&
223
260
  [...frontier].every(([w, sha]) => cf.get(w) === sha)
224
261
  ) {
225
- return this._cachedState;
262
+ return freezePublicState(this._cachedState);
226
263
  }
227
264
 
228
265
  const writerIds = [...frontier.keys()];
@@ -236,9 +273,9 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
236
273
  this._cachedFrontier = frontier;
237
274
  this._logTiming('materialize', t0, { metrics: '0 patches (ceiling)' });
238
275
  if (collectReceipts) {
239
- return { state, receipts: [] };
276
+ return freezePublicStateWithReceipts(state, []);
240
277
  }
241
- return state;
278
+ return freezePublicState(state);
242
279
  }
243
280
 
244
281
  // Persistent cache check — skip when collectReceipts is requested
@@ -259,7 +296,7 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
259
296
  await this._restoreIndexFromCache(cached.indexTreeOid);
260
297
  }
261
298
  this._logTiming('materialize', t0, { metrics: `cache hit (ceiling=${ceiling})` });
262
- return state;
299
+ return freezePublicState(state);
263
300
  } catch {
264
301
  // Corrupted payload — self-heal by removing the bad entry
265
302
  try { await this._seekCache.delete(cacheKey); } catch { /* best-effort */ }
@@ -322,9 +359,12 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
322
359
  this._logTiming('materialize', t0, { metrics: `${allPatches.length} patches (ceiling=${ceiling})` });
323
360
 
324
361
  if (collectReceipts) {
325
- return { state, receipts: /** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts) };
362
+ return freezePublicStateWithReceipts(
363
+ state,
364
+ /** @type {TickReceipt[]} */ (receipts),
365
+ );
326
366
  }
327
- return state;
367
+ return freezePublicState(state);
328
368
  }
329
369
 
330
370
  /**
@@ -459,7 +499,7 @@ export async function materializeAt(checkpointSha) {
459
499
  codec: this._codec,
460
500
  });
461
501
  await this._setMaterializedState(state);
462
- return state;
502
+ return freezePublicState(state);
463
503
  }
464
504
 
465
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,16 +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);
532
535
 
533
- // Invalidate derived caches (C1) join changes underlying state
534
- this._materializedGraph = null;
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
535
541
  this._logicalIndex = null;
536
542
  this._propertyReader = null;
537
543
  this._cachedViewHash = null;
538
544
  this._cachedIndexTree = null;
539
- this._stateDirty = true;
545
+
546
+ // State IS fresh — don't force rematerialization
547
+ this._stateDirty = false;
540
548
 
541
549
  return { state: mergedState, receipt };
542
550
  }
@@ -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
  }
@@ -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
- }