@git-stunts/git-warp 11.5.0 → 11.5.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.
package/README.md CHANGED
@@ -55,6 +55,10 @@ const result = await graph.query()
55
55
 
56
56
  ## How It Works
57
57
 
58
+ <p align="center">
59
+ <img src="docs/diagrams/fig-data-storage.svg" alt="WARP data storage — invisible to normal Git workflows" width="700">
60
+ </p>
61
+
58
62
  ### The Multi-Writer Problem (and How It's Solved)
59
63
 
60
64
  Multiple people (or machines, or processes) can write to the same graph **simultaneously, without any coordination**. There's no central server, no locking, no "wait your turn."
@@ -73,6 +77,10 @@ Every operation gets a unique **EventId** — `(lamport, writerId, patchSha, opI
73
77
 
74
78
  ## Multi-Writer Collaboration
75
79
 
80
+ <p align="center">
81
+ <img src="docs/diagrams/fig-multi-writer.svg" alt="Multi-writer convergence — independent chains, deterministic merge" width="700">
82
+ </p>
83
+
76
84
  Writers operate independently on the same Git repository. Sync happens through standard Git transport (push/pull) or the built-in HTTP sync protocol.
77
85
 
78
86
  ```javascript
@@ -457,6 +465,10 @@ When a seek cursor is active, `query`, `info`, `materialize`, and `history` auto
457
465
 
458
466
  ## Architecture
459
467
 
468
+ <p align="center">
469
+ <img src="docs/diagrams/fig-architecture.svg" alt="Hexagonal architecture — dependency rule: arrows point inward only" width="700">
470
+ </p>
471
+
460
472
  The codebase follows hexagonal architecture with ports and adapters:
461
473
 
462
474
  **Ports** define abstract interfaces for infrastructure:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@git-stunts/git-warp",
3
- "version": "11.5.0",
3
+ "version": "11.5.1",
4
4
  "description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -18,12 +18,12 @@ import defaultCrypto from './utils/defaultCrypto.js';
18
18
  import defaultClock from './utils/defaultClock.js';
19
19
  import LogicalTraversal from './services/LogicalTraversal.js';
20
20
  import LRUCache from './utils/LRUCache.js';
21
+ import SyncController from './services/SyncController.js';
21
22
  import { wireWarpMethods } from './warp/_wire.js';
22
23
  import * as queryMethods from './warp/query.methods.js';
23
24
  import * as subscribeMethods from './warp/subscribe.methods.js';
24
25
  import * as provenanceMethods from './warp/provenance.methods.js';
25
26
  import * as forkMethods from './warp/fork.methods.js';
26
- import * as syncMethods from './warp/sync.methods.js';
27
27
  import * as checkpointMethods from './warp/checkpoint.methods.js';
28
28
  import * as patchMethods from './warp/patch.methods.js';
29
29
  import * as materializeMethods from './warp/materialize.methods.js';
@@ -172,6 +172,9 @@ export default class WarpGraph {
172
172
 
173
173
  /** @type {number} */
174
174
  this._auditSkipCount = 0;
175
+
176
+ /** @type {SyncController} */
177
+ this._syncController = new SyncController(this);
175
178
  }
176
179
 
177
180
  /**
@@ -410,9 +413,26 @@ wireWarpMethods(WarpGraph, [
410
413
  subscribeMethods,
411
414
  provenanceMethods,
412
415
  forkMethods,
413
- syncMethods,
414
416
  checkpointMethods,
415
417
  patchMethods,
416
418
  materializeMethods,
417
419
  materializeAdvancedMethods,
418
420
  ]);
421
+
422
+ // ── Sync methods: direct delegation to SyncController (no stub file) ────────
423
+ const syncDelegates = /** @type {const} */ ([
424
+ 'getFrontier', 'hasFrontierChanged', 'status',
425
+ 'createSyncRequest', 'processSyncRequest', 'applySyncResponse',
426
+ 'syncNeeded', 'syncWith', 'serve',
427
+ ]);
428
+ for (const method of syncDelegates) {
429
+ Object.defineProperty(WarpGraph.prototype, method, {
430
+ // eslint-disable-next-line object-shorthand -- function keyword needed for `this` binding
431
+ value: /** @this {WarpGraph} @param {*[]} args */ function (...args) {
432
+ return this._syncController[method](...args);
433
+ },
434
+ writable: true,
435
+ configurable: true,
436
+ enumerable: false,
437
+ });
438
+ }
@@ -4,6 +4,7 @@ import nullLogger from '../utils/nullLogger.js';
4
4
  import LRUCache from '../utils/LRUCache.js';
5
5
  import { getRoaringBitmap32 } from '../utils/roaring.js';
6
6
  import { canonicalStringify } from '../utils/canonicalStringify.js';
7
+ import { isValidShardOid } from '../utils/validateShardOid.js';
7
8
 
8
9
  /** @typedef {import('../../ports/IndexStoragePort.js').default} IndexStoragePort */
9
10
  /** @typedef {import('../types/WarpPersistence.js').IndexStorage} IndexStorage */
@@ -50,24 +51,24 @@ const computeChecksum = async (data, version, crypto) => {
50
51
  * - {@link ShardCorruptionError} for invalid shard format
51
52
  * - {@link ShardValidationError} for version or checksum mismatches
52
53
  *
53
- * In non-strict mode (default), validation failures are logged as warnings
54
+ * In non-strict mode (strict: false), validation failures are logged as warnings
54
55
  * and an empty shard is returned for graceful degradation.
55
56
  *
56
57
  * **Note**: Storage errors (e.g., `storage.readBlob` failures) always throw
57
58
  * {@link ShardLoadError} regardless of strict mode.
58
59
  *
59
60
  * @example
60
- * // Non-strict mode (default) - graceful degradation on validation errors
61
+ * // Strict mode (default) - throws on any validation failure
61
62
  * const reader = new BitmapIndexReader({ storage });
62
63
  * reader.setup(shardOids);
63
64
  * const parents = await reader.getParents('abc123...');
64
65
  *
65
66
  * @example
66
- * // Strict mode - throws on any validation failure
67
- * const strictReader = new BitmapIndexReader({ storage, strict: true });
68
- * strictReader.setup(shardOids);
67
+ * // Non-strict mode - graceful degradation on validation errors
68
+ * const lenientReader = new BitmapIndexReader({ storage, strict: false });
69
+ * lenientReader.setup(shardOids);
69
70
  * try {
70
- * const parents = await strictReader.getParents('abc123...');
71
+ * const parents = await lenientReader.getParents('abc123...');
71
72
  * } catch (err) {
72
73
  * if (err instanceof ShardValidationError) {
73
74
  * console.error('Shard validation failed:', err.field, err.expected, err.actual);
@@ -83,14 +84,14 @@ export default class BitmapIndexReader {
83
84
  * Creates a BitmapIndexReader instance.
84
85
  * @param {Object} options
85
86
  * @param {IndexStoragePort} options.storage - Storage adapter for reading index data
86
- * @param {boolean} [options.strict=false] - If true, throw errors on validation failures; if false, log warnings and return empty shards
87
+ * @param {boolean} [options.strict=true] - If true, throw errors on validation failures; if false, log warnings and return empty shards
87
88
  * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for structured logging.
88
89
  * Defaults to NoOpLogger (no logging).
89
90
  * @param {number} [options.maxCachedShards=100] - Maximum number of shards to keep in the LRU cache.
90
91
  * When exceeded, least recently used shards are evicted to free memory.
91
92
  * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - CryptoPort instance for checksum verification.
92
93
  */
93
- constructor({ storage, strict = false, logger = nullLogger, maxCachedShards = DEFAULT_MAX_CACHED_SHARDS, crypto } = /** @type {{ storage: IndexStoragePort, strict?: boolean, logger?: LoggerPort, maxCachedShards?: number, crypto?: CryptoPort }} */ ({})) {
94
+ constructor({ storage, strict = true, logger = nullLogger, maxCachedShards = DEFAULT_MAX_CACHED_SHARDS, crypto } = /** @type {{ storage: IndexStoragePort, strict?: boolean, logger?: LoggerPort, maxCachedShards?: number, crypto?: CryptoPort }} */ ({})) {
94
95
  if (!storage) {
95
96
  throw new Error('BitmapIndexReader requires a storage adapter');
96
97
  }
@@ -132,8 +133,29 @@ export default class BitmapIndexReader {
132
133
  * const parents = await reader.getParents('abcd1234...'); // loads meta_ab, shards_rev_ab
133
134
  */
134
135
  setup(shardOids) {
135
- this.shardOids = new Map(Object.entries(shardOids));
136
- this._idToShaCache = null; // Clear cache when shards change
136
+ const entries = Object.entries(shardOids);
137
+ /** @type {[string, string][]} */
138
+ const validEntries = [];
139
+ for (const [path, oid] of entries) {
140
+ if (isValidShardOid(oid)) {
141
+ validEntries.push([path, oid]);
142
+ } else if (this.strict) {
143
+ throw new ShardCorruptionError('Invalid shard OID', {
144
+ shardPath: path,
145
+ oid,
146
+ reason: 'invalid_oid',
147
+ });
148
+ } else {
149
+ this.logger.warn('Skipping shard with invalid OID', {
150
+ operation: 'setup',
151
+ shardPath: path,
152
+ oid,
153
+ reason: 'invalid_oid',
154
+ });
155
+ }
156
+ }
157
+ this.shardOids = new Map(validEntries);
158
+ this._idToShaCache = null;
137
159
  this.loadedShards.clear();
138
160
  }
139
161
 
@@ -342,48 +342,55 @@ function foldPatchDot(frontier, writer, lamport) {
342
342
  }
343
343
 
344
344
  /**
345
- * Joins a patch into state, applying all operations in order.
346
- *
347
- * This is the primary function for incorporating a single patch into WARP state.
348
- * It iterates through all operations in the patch, creates EventIds for causality
349
- * tracking, and applies each operation using `applyOpV2`.
350
- *
351
- * **Receipt Collection Mode**:
352
- * When `collectReceipts` is true, this function also computes the outcome of each
353
- * operation (applied, redundant, or superseded) and returns a TickReceipt for
354
- * provenance tracking. This has a small performance cost, so it's disabled by default.
355
- *
356
- * **Warning**: This function mutates `state` in place. For immutable operations,
357
- * clone the state first using `cloneStateV5()`.
345
+ * Merges a patch's context into state and folds the patch dot.
346
+ * @param {WarpStateV5} state
347
+ * @param {Object} patch
348
+ * @param {string} patch.writer
349
+ * @param {number} patch.lamport
350
+ * @param {Map<string, number>|{[x: string]: number}} patch.context
351
+ */
352
+ function updateFrontierFromPatch(state, patch) {
353
+ const contextVV = patch.context instanceof Map
354
+ ? patch.context
355
+ : vvDeserialize(patch.context || {});
356
+ state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
357
+ foldPatchDot(state.observedFrontier, patch.writer, patch.lamport);
358
+ }
359
+
360
+ /**
361
+ * Applies a patch to state without receipt collection (zero overhead).
358
362
  *
359
- * @param {WarpStateV5} state - The state to mutate. Modified in place.
363
+ * @param {WarpStateV5} state - The state to mutate in place
360
364
  * @param {Object} patch - The patch to apply
361
- * @param {string} patch.writer - Writer ID who created this patch
362
- * @param {number} patch.lamport - Lamport timestamp of this patch
363
- * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>} patch.ops - Array of operations to apply
364
- * @param {Map<string, number>|{[x: string]: number}} patch.context - Version vector context (Map or serialized form)
365
- * @param {string} patchSha - The Git SHA of the patch commit (used for EventId creation)
366
- * @param {boolean} [collectReceipts=false] - When true, computes and returns receipt data
367
- * @returns {WarpStateV5|{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}}
368
- * Returns mutated state directly when collectReceipts is false;
369
- * returns {state, receipt} object when collectReceipts is true
365
+ * @param {string} patch.writer
366
+ * @param {number} patch.lamport
367
+ * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>} patch.ops
368
+ * @param {Map<string, number>|{[x: string]: number}} patch.context
369
+ * @param {string} patchSha - Git SHA of the patch commit
370
+ * @returns {WarpStateV5} The mutated state
370
371
  */
371
- export function join(state, patch, patchSha, collectReceipts) {
372
- // ZERO-COST: when collectReceipts is falsy, skip all receipt logic
373
- if (!collectReceipts) {
374
- for (let i = 0; i < patch.ops.length; i++) {
375
- const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
376
- applyOpV2(state, patch.ops[i], eventId);
377
- }
378
- const contextVV = patch.context instanceof Map
379
- ? patch.context
380
- : vvDeserialize(patch.context);
381
- state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
382
- foldPatchDot(state.observedFrontier, patch.writer, patch.lamport);
383
- return state;
372
+ export function applyFast(state, patch, patchSha) {
373
+ for (let i = 0; i < patch.ops.length; i++) {
374
+ const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
375
+ applyOpV2(state, patch.ops[i], eventId);
384
376
  }
377
+ updateFrontierFromPatch(state, patch);
378
+ return state;
379
+ }
385
380
 
386
- // Receipt-enabled path
381
+ /**
382
+ * Applies a patch to state with receipt collection for provenance tracking.
383
+ *
384
+ * @param {WarpStateV5} state - The state to mutate in place
385
+ * @param {Object} patch - The patch to apply
386
+ * @param {string} patch.writer
387
+ * @param {number} patch.lamport
388
+ * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>} patch.ops
389
+ * @param {Map<string, number>|{[x: string]: number}} patch.context
390
+ * @param {string} patchSha - Git SHA of the patch commit
391
+ * @returns {{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}}
392
+ */
393
+ export function applyWithReceipt(state, patch, patchSha) {
387
394
  /** @type {import('../types/TickReceipt.js').OpOutcome[]} */
388
395
  const opResults = [];
389
396
  for (let i = 0; i < patch.ops.length; i++) {
@@ -433,11 +440,7 @@ export function join(state, patch, patchSha, collectReceipts) {
433
440
  opResults.push(entry);
434
441
  }
435
442
 
436
- const contextVV = patch.context instanceof Map
437
- ? patch.context
438
- : vvDeserialize(patch.context);
439
- state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
440
- foldPatchDot(state.observedFrontier, patch.writer, patch.lamport);
443
+ updateFrontierFromPatch(state, patch);
441
444
 
442
445
  const receipt = createTickReceipt({
443
446
  patchSha,
@@ -449,6 +452,39 @@ export function join(state, patch, patchSha, collectReceipts) {
449
452
  return { state, receipt };
450
453
  }
451
454
 
455
+ /**
456
+ * Joins a patch into state, applying all operations in order.
457
+ *
458
+ * This is the primary function for incorporating a single patch into WARP state.
459
+ * It iterates through all operations in the patch, creates EventIds for causality
460
+ * tracking, and applies each operation using `applyOpV2`.
461
+ *
462
+ * **Receipt Collection Mode**:
463
+ * When `collectReceipts` is true, this function also computes the outcome of each
464
+ * operation (applied, redundant, or superseded) and returns a TickReceipt for
465
+ * provenance tracking. This has a small performance cost, so it's disabled by default.
466
+ *
467
+ * **Warning**: This function mutates `state` in place. For immutable operations,
468
+ * clone the state first using `cloneStateV5()`.
469
+ *
470
+ * @param {WarpStateV5} state - The state to mutate. Modified in place.
471
+ * @param {Object} patch - The patch to apply
472
+ * @param {string} patch.writer - Writer ID who created this patch
473
+ * @param {number} patch.lamport - Lamport timestamp of this patch
474
+ * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>} patch.ops - Array of operations to apply
475
+ * @param {Map<string, number>|{[x: string]: number}} patch.context - Version vector context (Map or serialized form)
476
+ * @param {string} patchSha - The Git SHA of the patch commit (used for EventId creation)
477
+ * @param {boolean} [collectReceipts=false] - When true, computes and returns receipt data
478
+ * @returns {WarpStateV5|{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}}
479
+ * Returns mutated state directly when collectReceipts is false;
480
+ * returns {state, receipt} object when collectReceipts is true
481
+ */
482
+ export function join(state, patch, patchSha, collectReceipts) {
483
+ return collectReceipts
484
+ ? applyWithReceipt(state, patch, patchSha)
485
+ : applyFast(state, patch, patchSha);
486
+ }
487
+
452
488
  /**
453
489
  * Joins two V5 states together using CRDT merge semantics.
454
490
  *
@@ -560,14 +596,14 @@ export function reduceV5(patches, initialState, options) {
560
596
  if (options && options.receipts) {
561
597
  const receipts = [];
562
598
  for (const { patch, sha } of patches) {
563
- const result = /** @type {{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}} */ (join(state, patch, sha, true));
599
+ const result = applyWithReceipt(state, patch, sha);
564
600
  receipts.push(result.receipt);
565
601
  }
566
602
  return { state, receipts };
567
603
  }
568
604
 
569
605
  for (const { patch, sha } of patches) {
570
- join(state, patch, sha);
606
+ applyFast(state, patch, sha);
571
607
  }
572
608
  return state;
573
609
  }