@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 +12 -0
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +22 -2
- package/src/domain/services/BitmapIndexReader.js +32 -10
- package/src/domain/services/JoinReducer.js +80 -44
- package/src/domain/services/SyncController.js +576 -0
- package/src/domain/utils/validateShardOid.js +13 -0
- package/src/domain/warp/_internal.js +0 -9
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/sync.methods.js +0 -554
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
package/src/domain/WarpGraph.js
CHANGED
|
@@ -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 (
|
|
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
|
-
* //
|
|
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
|
-
* //
|
|
67
|
-
* const
|
|
68
|
-
*
|
|
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
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
363
|
+
* @param {WarpStateV5} state - The state to mutate in place
|
|
360
364
|
* @param {Object} patch - The patch to apply
|
|
361
|
-
* @param {string} patch.writer
|
|
362
|
-
* @param {number} patch.lamport
|
|
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
|
|
364
|
-
* @param {Map<string, number>|{[x: string]: number}} patch.context
|
|
365
|
-
* @param {string} patchSha -
|
|
366
|
-
* @
|
|
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
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
606
|
+
applyFast(state, patch, sha);
|
|
571
607
|
}
|
|
572
608
|
return state;
|
|
573
609
|
}
|