@git-stunts/git-warp 12.2.0 → 12.3.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 (59) hide show
  1. package/README.md +9 -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/presenters/text.js +10 -3
  6. package/bin/warp-graph.js +4 -1
  7. package/index.d.ts +17 -1
  8. package/package.json +1 -1
  9. package/src/domain/WarpGraph.js +1 -1
  10. package/src/domain/crdt/Dot.js +5 -0
  11. package/src/domain/crdt/LWW.js +3 -1
  12. package/src/domain/crdt/ORSet.js +33 -23
  13. package/src/domain/crdt/VersionVector.js +12 -0
  14. package/src/domain/errors/PatchError.js +27 -0
  15. package/src/domain/errors/StorageError.js +8 -0
  16. package/src/domain/errors/WriterError.js +5 -0
  17. package/src/domain/errors/index.js +1 -0
  18. package/src/domain/services/AuditReceiptService.js +2 -1
  19. package/src/domain/services/AuditVerifierService.js +33 -2
  20. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  21. package/src/domain/services/BoundaryTransitionRecord.js +1 -0
  22. package/src/domain/services/CheckpointMessageCodec.js +5 -0
  23. package/src/domain/services/CheckpointService.js +29 -2
  24. package/src/domain/services/GCPolicy.js +25 -4
  25. package/src/domain/services/GraphTraversal.js +3 -1
  26. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  27. package/src/domain/services/JoinReducer.js +311 -75
  28. package/src/domain/services/KeyCodec.js +48 -0
  29. package/src/domain/services/MaterializedViewService.js +14 -3
  30. package/src/domain/services/MessageSchemaDetector.js +35 -5
  31. package/src/domain/services/OpNormalizer.js +79 -0
  32. package/src/domain/services/PatchBuilderV2.js +240 -160
  33. package/src/domain/services/QueryBuilder.js +4 -0
  34. package/src/domain/services/SyncAuthService.js +3 -0
  35. package/src/domain/services/SyncController.js +12 -31
  36. package/src/domain/services/SyncProtocol.js +76 -32
  37. package/src/domain/services/WarpMessageCodec.js +2 -0
  38. package/src/domain/trust/TrustCrypto.js +8 -5
  39. package/src/domain/trust/TrustRecordService.js +50 -36
  40. package/src/domain/types/TickReceipt.js +6 -4
  41. package/src/domain/types/WarpTypesV2.js +77 -5
  42. package/src/domain/utils/CachedValue.js +34 -5
  43. package/src/domain/utils/EventId.js +4 -1
  44. package/src/domain/utils/LRUCache.js +3 -1
  45. package/src/domain/utils/RefLayout.js +4 -0
  46. package/src/domain/utils/canonicalStringify.js +48 -18
  47. package/src/domain/utils/defaultClock.js +1 -0
  48. package/src/domain/utils/matchGlob.js +7 -0
  49. package/src/domain/warp/PatchSession.js +30 -24
  50. package/src/domain/warp/Writer.js +12 -1
  51. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  52. package/src/domain/warp/checkpoint.methods.js +36 -7
  53. package/src/domain/warp/fork.methods.js +1 -1
  54. package/src/domain/warp/materialize.methods.js +44 -5
  55. package/src/domain/warp/materializeAdvanced.methods.js +50 -10
  56. package/src/domain/warp/patch.methods.js +21 -11
  57. package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
  58. package/src/infrastructure/codecs/CborCodec.js +2 -0
  59. package/src/domain/utils/fnv1a.js +0 -20
@@ -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]', {
@@ -289,6 +291,7 @@ export async function writer(writerId) {
289
291
  onDeleteWithData: this._onDeleteWithData,
290
292
  onCommitSuccess: /** @type {(result: {patch: Object, sha: string}) => void} */ (/** @type {unknown} */ ((/** @type {{patch?: import('../types/WarpTypesV2.js').PatchV2, sha?: string}} */ opts) => this._onPatchCommitted(resolvedWriterId, opts))),
291
293
  codec: this._codec,
294
+ logger: this._logger || undefined,
292
295
  });
293
296
  }
294
297
 
@@ -347,6 +350,7 @@ export async function createWriter(opts = {}) {
347
350
  onDeleteWithData: this._onDeleteWithData,
348
351
  onCommitSuccess: /** @type {(result: {patch: Object, sha: string}) => void} */ (/** @type {unknown} */ ((/** @type {{patch?: import('../types/WarpTypesV2.js').PatchV2, sha?: string}} */ commitOpts) => this._onPatchCommitted(freshWriterId, commitOpts))),
349
352
  codec: this._codec,
353
+ logger: this._logger || undefined,
350
354
  });
351
355
  }
352
356
 
@@ -527,16 +531,22 @@ export function join(otherState) {
527
531
  !this._frontierEquals(this._cachedState.observedFrontier, mergedState.observedFrontier),
528
532
  };
529
533
 
530
- // Update cached state
534
+ // Install merged state as canonical (B108 — cache coherence fix)
531
535
  this._cachedState = mergedState;
536
+ this._versionVector = vvClone(mergedState.observedFrontier);
532
537
 
533
- // Invalidate derived caches (C1) join changes underlying state
534
- this._materializedGraph = null;
538
+ // Build adjacency synchronously (crypto hash deferred to next _buildView)
539
+ const adjacency = this._buildAdjacency(mergedState);
540
+ this._materializedGraph = { state: mergedState, stateHash: null, adjacency };
541
+
542
+ // Clear index caches — queries degrade to linear scan until next _buildView
535
543
  this._logicalIndex = null;
536
544
  this._propertyReader = null;
537
545
  this._cachedViewHash = null;
538
546
  this._cachedIndexTree = null;
539
- this._stateDirty = true;
547
+
548
+ // State IS fresh — don't force rematerialization
549
+ this._stateDirty = false;
540
550
 
541
551
  return { state: mergedState, receipt };
542
552
  }
@@ -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
- }