@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.
- package/README.md +8 -6
- package/bin/cli/commands/trust.js +37 -1
- package/bin/cli/infrastructure.js +14 -1
- package/bin/cli/schemas.js +4 -4
- package/bin/warp-graph.js +4 -1
- package/index.d.ts +17 -1
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +1 -1
- package/src/domain/crdt/Dot.js +5 -0
- package/src/domain/crdt/LWW.js +3 -1
- package/src/domain/crdt/ORSet.js +30 -23
- package/src/domain/crdt/VersionVector.js +12 -0
- package/src/domain/errors/PatchError.js +27 -0
- package/src/domain/errors/StorageError.js +8 -0
- package/src/domain/errors/WriterError.js +5 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditVerifierService.js +32 -2
- package/src/domain/services/BitmapIndexBuilder.js +14 -9
- package/src/domain/services/CheckpointService.js +10 -1
- package/src/domain/services/GCPolicy.js +25 -4
- package/src/domain/services/GraphTraversal.js +3 -1
- package/src/domain/services/IncrementalIndexUpdater.js +179 -36
- package/src/domain/services/JoinReducer.js +141 -31
- package/src/domain/services/MaterializedViewService.js +13 -2
- package/src/domain/services/PatchBuilderV2.js +181 -142
- package/src/domain/services/QueryBuilder.js +4 -0
- package/src/domain/services/SyncController.js +12 -31
- package/src/domain/services/SyncProtocol.js +75 -32
- package/src/domain/trust/TrustRecordService.js +50 -36
- package/src/domain/utils/CachedValue.js +34 -5
- package/src/domain/utils/EventId.js +4 -1
- package/src/domain/utils/LRUCache.js +3 -1
- package/src/domain/utils/RefLayout.js +4 -0
- package/src/domain/utils/canonicalStringify.js +48 -18
- package/src/domain/utils/matchGlob.js +7 -0
- package/src/domain/warp/PatchSession.js +30 -24
- package/src/domain/warp/Writer.js +5 -0
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/checkpoint.methods.js +36 -7
- package/src/domain/warp/materialize.methods.js +44 -5
- package/src/domain/warp/materializeAdvanced.methods.js +50 -10
- package/src/domain/warp/patch.methods.js +19 -11
- package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
- package/src/infrastructure/codecs/CborCodec.js +2 -0
- package/src/domain/utils/fnv1a.js +0 -20
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
import defaultCodec from '../utils/defaultCodec.js';
|
|
40
40
|
import nullLogger from '../utils/nullLogger.js';
|
|
41
41
|
import { decodePatchMessage, assertOpsCompatible, SCHEMA_V3 } from './WarpMessageCodec.js';
|
|
42
|
-
import { join, cloneStateV5 } from './JoinReducer.js';
|
|
42
|
+
import { join, cloneStateV5, isKnownOp } from './JoinReducer.js';
|
|
43
|
+
import SchemaUnsupportedError from '../errors/SchemaUnsupportedError.js';
|
|
43
44
|
import { cloneFrontier, updateFrontier } from './Frontier.js';
|
|
44
45
|
import { vvDeserialize } from '../crdt/VersionVector.js';
|
|
45
46
|
|
|
@@ -81,6 +82,33 @@ function normalizePatch(patch) {
|
|
|
81
82
|
return patch;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Converts a frontier Map to a plain object for JSON serialization.
|
|
87
|
+
*
|
|
88
|
+
* @param {Map<string, string>} map - Frontier as Map<writerId, sha>
|
|
89
|
+
* @returns {{ [x: string]: string }} Plain object representation
|
|
90
|
+
* @private
|
|
91
|
+
*/
|
|
92
|
+
function frontierToObject(map) {
|
|
93
|
+
/** @type {{ [x: string]: string }} */
|
|
94
|
+
const obj = {};
|
|
95
|
+
for (const [writerId, sha] of map) {
|
|
96
|
+
obj[writerId] = sha;
|
|
97
|
+
}
|
|
98
|
+
return obj;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Converts a frontier plain object back to a Map.
|
|
103
|
+
*
|
|
104
|
+
* @param {{ [x: string]: string }} obj - Frontier as plain object
|
|
105
|
+
* @returns {Map<string, string>} Frontier as Map<writerId, sha>
|
|
106
|
+
* @private
|
|
107
|
+
*/
|
|
108
|
+
function objectToFrontier(obj) {
|
|
109
|
+
return new Map(Object.entries(obj));
|
|
110
|
+
}
|
|
111
|
+
|
|
84
112
|
/**
|
|
85
113
|
* Loads a patch from a commit.
|
|
86
114
|
*
|
|
@@ -252,7 +280,8 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
|
|
|
252
280
|
newWritersForLocal.push(writerId);
|
|
253
281
|
} else if (localSha !== remoteSha) {
|
|
254
282
|
// Different heads - local needs patches from its head to remote head
|
|
255
|
-
//
|
|
283
|
+
// Direction is intentionally deferred: ancestry is verified by
|
|
284
|
+
// isAncestor() pre-check or loadPatchRange() in processSyncRequest()
|
|
256
285
|
needFromRemote.set(writerId, { from: localSha, to: remoteSha });
|
|
257
286
|
}
|
|
258
287
|
// If localSha === remoteSha, already in sync for this writer
|
|
@@ -339,16 +368,9 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
|
|
|
339
368
|
* // Send over HTTP: await fetch(url, { body: JSON.stringify(request) })
|
|
340
369
|
*/
|
|
341
370
|
export function createSyncRequest(frontier) {
|
|
342
|
-
// Convert Map to plain object for serialization
|
|
343
|
-
/** @type {{ [x: string]: string }} */
|
|
344
|
-
const frontierObj = {};
|
|
345
|
-
for (const [writerId, sha] of frontier) {
|
|
346
|
-
frontierObj[writerId] = sha;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
371
|
return {
|
|
350
372
|
type: /** @type {'sync-request'} */ ('sync-request'),
|
|
351
|
-
frontier:
|
|
373
|
+
frontier: frontierToObject(frontier),
|
|
352
374
|
};
|
|
353
375
|
}
|
|
354
376
|
|
|
@@ -393,8 +415,7 @@ export function createSyncRequest(frontier) {
|
|
|
393
415
|
export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec, logger } = /** @type {{ codec?: import('../../ports/CodecPort.js').default, logger?: import('../../ports/LoggerPort.js').default }} */ ({})) {
|
|
394
416
|
const log = logger || nullLogger;
|
|
395
417
|
|
|
396
|
-
|
|
397
|
-
const remoteFrontier = new Map(Object.entries(request.frontier));
|
|
418
|
+
const remoteFrontier = objectToFrontier(request.frontier);
|
|
398
419
|
|
|
399
420
|
// Compute what the requester needs
|
|
400
421
|
const delta = computeSyncDelta(remoteFrontier, localFrontier);
|
|
@@ -406,6 +427,29 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
|
|
|
406
427
|
|
|
407
428
|
for (const [writerId, range] of delta.needFromRemote) {
|
|
408
429
|
try {
|
|
430
|
+
// Pre-check ancestry to avoid expensive chain walk (B107 / S3 fix).
|
|
431
|
+
// If the persistence layer provides isAncestor, use it to detect
|
|
432
|
+
// divergence early without walking the full commit chain.
|
|
433
|
+
const hasIsAncestor = typeof /** @type {{isAncestor?: (...args: unknown[]) => unknown}} */ (persistence).isAncestor === 'function';
|
|
434
|
+
if (range.from && hasIsAncestor) {
|
|
435
|
+
const isAnc = await /** @type {{isAncestor: (a: string, b: string) => Promise<boolean>}} */ (/** @type {unknown} */ (persistence)).isAncestor(range.from, range.to);
|
|
436
|
+
if (!isAnc) {
|
|
437
|
+
const entry = {
|
|
438
|
+
writerId,
|
|
439
|
+
reason: 'E_SYNC_DIVERGENCE',
|
|
440
|
+
localSha: range.to,
|
|
441
|
+
remoteSha: range.from,
|
|
442
|
+
};
|
|
443
|
+
skippedWriters.push(entry);
|
|
444
|
+
log.warn('Sync divergence detected — skipping writer', {
|
|
445
|
+
code: 'E_SYNC_DIVERGENCE',
|
|
446
|
+
graphName,
|
|
447
|
+
...entry,
|
|
448
|
+
});
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
409
453
|
const writerPatches = await loadPatchRange(
|
|
410
454
|
persistence,
|
|
411
455
|
graphName,
|
|
@@ -440,16 +484,9 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
|
|
|
440
484
|
}
|
|
441
485
|
}
|
|
442
486
|
|
|
443
|
-
// Convert local frontier to plain object
|
|
444
|
-
/** @type {{ [x: string]: string }} */
|
|
445
|
-
const frontierObj = {};
|
|
446
|
-
for (const [writerId, sha] of localFrontier) {
|
|
447
|
-
frontierObj[writerId] = sha;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
487
|
return {
|
|
451
488
|
type: /** @type {'sync-response'} */ ('sync-response'),
|
|
452
|
-
frontier:
|
|
489
|
+
frontier: frontierToObject(localFrontier),
|
|
453
490
|
patches,
|
|
454
491
|
skippedWriters,
|
|
455
492
|
};
|
|
@@ -503,7 +540,10 @@ export function applySyncResponse(response, state, frontier) {
|
|
|
503
540
|
const newFrontier = cloneFrontier(frontier);
|
|
504
541
|
let applied = 0;
|
|
505
542
|
|
|
506
|
-
//
|
|
543
|
+
// Patches arrive pre-grouped by writer from the sync response. This
|
|
544
|
+
// re-grouping is defensive — it handles edge cases where patches from
|
|
545
|
+
// multiple writers arrive interleaved (e.g., from a relay that merges
|
|
546
|
+
// streams).
|
|
507
547
|
const patchesByWriter = new Map();
|
|
508
548
|
for (const { writerId, sha, patch } of response.patches) {
|
|
509
549
|
if (!patchesByWriter.has(writerId)) {
|
|
@@ -518,10 +558,19 @@ export function applySyncResponse(response, state, frontier) {
|
|
|
518
558
|
for (const { sha, patch } of writerPatches) {
|
|
519
559
|
// Normalize patch context (in case it came from network serialization)
|
|
520
560
|
const normalizedPatch = normalizePatch(patch);
|
|
521
|
-
// Guard: reject patches
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
561
|
+
// Guard: reject patches with genuinely unknown op types (B106 / C2 fix).
|
|
562
|
+
// This prevents silent data loss when a newer writer sends ops we
|
|
563
|
+
// don't recognise — fail closed rather than silently ignoring.
|
|
564
|
+
for (const op of normalizedPatch.ops) {
|
|
565
|
+
if (!isKnownOp(op)) {
|
|
566
|
+
throw new SchemaUnsupportedError(
|
|
567
|
+
`Patch ${sha} contains unknown op type: ${op.type}`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Guard: reject patches exceeding our maximum supported schema version.
|
|
572
|
+
// isKnownOp() above checks op-type recognition; this checks the schema
|
|
573
|
+
// version ceiling. Currently SCHEMA_V3 is the max.
|
|
525
574
|
assertOpsCompatible(normalizedPatch.ops, SCHEMA_V3);
|
|
526
575
|
// Apply patch to state
|
|
527
576
|
join(newState, /** @type {Parameters<typeof join>[1]} */ (normalizedPatch), sha);
|
|
@@ -609,15 +658,9 @@ export function syncNeeded(localFrontier, remoteFrontier) {
|
|
|
609
658
|
* }
|
|
610
659
|
*/
|
|
611
660
|
export function createEmptySyncResponse(frontier) {
|
|
612
|
-
/** @type {{ [x: string]: string }} */
|
|
613
|
-
const frontierObj = {};
|
|
614
|
-
for (const [writerId, sha] of frontier) {
|
|
615
|
-
frontierObj[writerId] = sha;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
661
|
return {
|
|
619
662
|
type: /** @type {'sync-response'} */ ('sync-response'),
|
|
620
|
-
frontier:
|
|
663
|
+
frontier: frontierToObject(frontier),
|
|
621
664
|
patches: [],
|
|
622
665
|
};
|
|
623
666
|
}
|
|
@@ -26,6 +26,10 @@ const MAX_CAS_ATTEMPTS = 3;
|
|
|
26
26
|
* @property {boolean} [skipSignatureVerify=false] - Skip signature verification (for testing)
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{ok: true, records: Array<Record<string, unknown>>} | {ok: false, error: Error}} ReadRecordsResult
|
|
31
|
+
*/
|
|
32
|
+
|
|
29
33
|
export class TrustRecordService {
|
|
30
34
|
/**
|
|
31
35
|
* @param {Object} options
|
|
@@ -102,55 +106,65 @@ export class TrustRecordService {
|
|
|
102
106
|
* @param {string} graphName
|
|
103
107
|
* @param {Object} [options]
|
|
104
108
|
* @param {string} [options.tip] - Override tip commit (for pinned reads)
|
|
105
|
-
* @returns {Promise<
|
|
109
|
+
* @returns {Promise<ReadRecordsResult>}
|
|
106
110
|
*/
|
|
107
111
|
async readRecords(graphName, options = {}) {
|
|
108
112
|
const ref = buildTrustRecordRef(graphName);
|
|
109
113
|
let tip = options.tip ?? null;
|
|
110
114
|
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
tip = await this._persistence.readRef(ref);
|
|
114
|
-
} catch (err) {
|
|
115
|
-
// Distinguish "ref not found" from operational error (J15)
|
|
116
|
-
if (err instanceof Error && (err.message?.includes('not found') || err.message?.includes('does not exist'))) {
|
|
117
|
-
return [];
|
|
118
|
-
}
|
|
119
|
-
throw new TrustError(
|
|
120
|
-
`Failed to read trust chain ref: ${err instanceof Error ? err.message : String(err)}`,
|
|
121
|
-
{ code: 'E_TRUST_READ_FAILED' },
|
|
122
|
-
);
|
|
123
|
-
}
|
|
115
|
+
try {
|
|
124
116
|
if (!tip) {
|
|
125
|
-
|
|
117
|
+
try {
|
|
118
|
+
tip = await this._persistence.readRef(ref);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Distinguish "ref not found" from operational error (J15)
|
|
121
|
+
if (err instanceof Error && (err.message?.includes('not found') || err.message?.includes('does not exist'))) {
|
|
122
|
+
return { ok: true, records: [] };
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: new TrustError(
|
|
127
|
+
`Failed to read trust chain ref: ${err instanceof Error ? err.message : String(err)}`,
|
|
128
|
+
{ code: 'E_TRUST_READ_FAILED' },
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (!tip) {
|
|
133
|
+
return { ok: true, records: [] };
|
|
134
|
+
}
|
|
126
135
|
}
|
|
127
|
-
}
|
|
128
136
|
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
const records = [];
|
|
138
|
+
let current = tip;
|
|
131
139
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
while (current) {
|
|
141
|
+
const info = await this._persistence.getNodeInfo(current);
|
|
142
|
+
const entries = await this._persistence.readTreeOids(
|
|
143
|
+
await this._persistence.getCommitTree(current),
|
|
144
|
+
);
|
|
145
|
+
const blobOid = entries['record.cbor'];
|
|
146
|
+
if (!blobOid) {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
const record = /** @type {Record<string, unknown>} */ (this._codec.decode(
|
|
150
|
+
await this._persistence.readBlob(blobOid),
|
|
151
|
+
));
|
|
144
152
|
|
|
145
|
-
|
|
153
|
+
records.unshift(record);
|
|
146
154
|
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
if (info.parents.length === 0) {
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
current = info.parents[0];
|
|
149
159
|
}
|
|
150
|
-
current = info.parents[0];
|
|
151
|
-
}
|
|
152
160
|
|
|
153
|
-
|
|
161
|
+
return { ok: true, records };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
154
168
|
}
|
|
155
169
|
|
|
156
170
|
/**
|
|
@@ -54,6 +54,12 @@ class CachedValue {
|
|
|
54
54
|
/** @type {T|null} */
|
|
55
55
|
this._value = null;
|
|
56
56
|
|
|
57
|
+
/** @type {Promise<T>|null} */
|
|
58
|
+
this._inflight = null;
|
|
59
|
+
|
|
60
|
+
/** @type {number} */
|
|
61
|
+
this._generation = 0;
|
|
62
|
+
|
|
57
63
|
/** @type {number} */
|
|
58
64
|
this._cachedAt = 0;
|
|
59
65
|
|
|
@@ -71,12 +77,33 @@ class CachedValue {
|
|
|
71
77
|
return /** @type {T} */ (this._value);
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this._cachedAtIso = this._clock.timestamp();
|
|
80
|
+
if (this._inflight) {
|
|
81
|
+
return await this._inflight;
|
|
82
|
+
}
|
|
78
83
|
|
|
79
|
-
|
|
84
|
+
const generation = this._generation;
|
|
85
|
+
|
|
86
|
+
this._inflight = Promise.resolve(this._compute()).then(
|
|
87
|
+
(value) => {
|
|
88
|
+
// Ignore stale in-flight completion if cache was invalidated mid-flight.
|
|
89
|
+
if (generation !== this._generation) {
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
this._value = value;
|
|
93
|
+
this._cachedAt = this._clock.now();
|
|
94
|
+
this._cachedAtIso = this._clock.timestamp();
|
|
95
|
+
this._inflight = null;
|
|
96
|
+
return value;
|
|
97
|
+
},
|
|
98
|
+
(err) => {
|
|
99
|
+
if (generation === this._generation) {
|
|
100
|
+
this._inflight = null;
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return await this._inflight;
|
|
80
107
|
}
|
|
81
108
|
|
|
82
109
|
/**
|
|
@@ -99,7 +126,9 @@ class CachedValue {
|
|
|
99
126
|
* Invalidates the cached value, forcing recomputation on next get().
|
|
100
127
|
*/
|
|
101
128
|
invalidate() {
|
|
129
|
+
this._generation += 1;
|
|
102
130
|
this._value = null;
|
|
131
|
+
this._inflight = null;
|
|
103
132
|
this._cachedAt = 0;
|
|
104
133
|
this._cachedAtIso = null;
|
|
105
134
|
}
|
|
@@ -49,6 +49,9 @@ export function createEventId(lamport, writerId, patchSha, opIndex) {
|
|
|
49
49
|
* Compares two EventIds lexicographically.
|
|
50
50
|
* Order: lamport -> writerId -> patchSha -> opIndex
|
|
51
51
|
*
|
|
52
|
+
* SHA tiebreaker uses lexicographic string comparison. This is arbitrary but
|
|
53
|
+
* deterministic — the specific order doesn't matter as long as all writers agree.
|
|
54
|
+
*
|
|
52
55
|
* @param {EventId} a
|
|
53
56
|
* @param {EventId} b
|
|
54
57
|
* @returns {number} -1 if a < b, 0 if equal, 1 if a > b
|
|
@@ -64,7 +67,7 @@ export function compareEventIds(a, b) {
|
|
|
64
67
|
return a.writerId < b.writerId ? -1 : 1;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
// 3. Compare patchSha as string
|
|
70
|
+
// 3. Compare patchSha as string (lexicographic — arbitrary but deterministic)
|
|
68
71
|
if (a.patchSha !== b.patchSha) {
|
|
69
72
|
return a.patchSha < b.patchSha ? -1 : 1;
|
|
70
73
|
}
|
|
@@ -34,7 +34,9 @@ class LRUCache {
|
|
|
34
34
|
if (!this._cache.has(key)) {
|
|
35
35
|
return undefined;
|
|
36
36
|
}
|
|
37
|
-
//
|
|
37
|
+
// Delete-reinsert maintains insertion order in the underlying Map, which
|
|
38
|
+
// serves as the LRU eviction order. This is O(1) amortized in V8's Map
|
|
39
|
+
// implementation despite appearing wasteful (2x Map ops per get).
|
|
38
40
|
const value = /** @type {V} */ (this._cache.get(key));
|
|
39
41
|
this._cache.delete(key);
|
|
40
42
|
this._cache.set(key, value);
|
|
@@ -376,6 +376,10 @@ export function buildTrustRecordRef(graphName) {
|
|
|
376
376
|
/**
|
|
377
377
|
* Parses and extracts the writer ID from a writer ref path.
|
|
378
378
|
*
|
|
379
|
+
* Returns null for any non-writer ref, including malformed refs. Callers that
|
|
380
|
+
* need to distinguish "not a writer ref" from "malformed ref" should validate
|
|
381
|
+
* the ref format separately before calling this method.
|
|
382
|
+
*
|
|
379
383
|
* @param {string} refPath - The full ref path
|
|
380
384
|
* @returns {string|null} The writer ID, or null if the path is not a valid writer ref
|
|
381
385
|
*
|
|
@@ -7,10 +7,24 @@
|
|
|
7
7
|
* - Array elements that are undefined/function/symbol become "null"
|
|
8
8
|
* - Object properties with undefined/function/symbol values are omitted
|
|
9
9
|
*
|
|
10
|
+
* Throws TypeError on circular references rather than stack-overflowing.
|
|
11
|
+
*
|
|
10
12
|
* @param {unknown} value - Any JSON-serializable value
|
|
11
13
|
* @returns {string} Canonical JSON string with sorted keys
|
|
12
14
|
*/
|
|
13
15
|
export function canonicalStringify(value) {
|
|
16
|
+
return _canonicalStringify(value, new WeakSet());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Internal recursive helper with cycle detection.
|
|
21
|
+
*
|
|
22
|
+
* @param {unknown} value - Any JSON-serializable value
|
|
23
|
+
* @param {WeakSet<object>} seen - Set of already-visited objects for cycle detection
|
|
24
|
+
* @returns {string} Canonical JSON string with sorted keys
|
|
25
|
+
* @private
|
|
26
|
+
*/
|
|
27
|
+
function _canonicalStringify(value, seen) {
|
|
14
28
|
if (value === undefined) {
|
|
15
29
|
return 'null';
|
|
16
30
|
}
|
|
@@ -18,26 +32,42 @@ export function canonicalStringify(value) {
|
|
|
18
32
|
return 'null';
|
|
19
33
|
}
|
|
20
34
|
if (Array.isArray(value)) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
if (seen.has(value)) {
|
|
36
|
+
throw new TypeError('Circular reference detected in canonicalStringify');
|
|
37
|
+
}
|
|
38
|
+
seen.add(value);
|
|
39
|
+
try {
|
|
40
|
+
// Map elements: undefined/function/symbol -> "null", others recurse
|
|
41
|
+
const elements = value.map(el => {
|
|
42
|
+
if (el === undefined || typeof el === 'function' || typeof el === 'symbol') {
|
|
43
|
+
return 'null';
|
|
44
|
+
}
|
|
45
|
+
return _canonicalStringify(el, seen);
|
|
46
|
+
});
|
|
47
|
+
return `[${elements.join(',')}]`;
|
|
48
|
+
} finally {
|
|
49
|
+
seen.delete(value);
|
|
50
|
+
}
|
|
29
51
|
}
|
|
30
52
|
if (typeof value === 'object') {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
if (seen.has(value)) {
|
|
54
|
+
throw new TypeError('Circular reference detected in canonicalStringify');
|
|
55
|
+
}
|
|
56
|
+
seen.add(value);
|
|
57
|
+
try {
|
|
58
|
+
const obj = /** @type {Record<string, unknown>} */ (value);
|
|
59
|
+
// Filter out keys with undefined/function/symbol values, then sort
|
|
60
|
+
const keys = Object.keys(obj)
|
|
61
|
+
.filter(k => {
|
|
62
|
+
const v = obj[k];
|
|
63
|
+
return v !== undefined && typeof v !== 'function' && typeof v !== 'symbol';
|
|
64
|
+
})
|
|
65
|
+
.sort();
|
|
66
|
+
const pairs = keys.map(k => `${JSON.stringify(k)}:${_canonicalStringify(obj[k], seen)}`);
|
|
67
|
+
return `{${pairs.join(',')}}`;
|
|
68
|
+
} finally {
|
|
69
|
+
seen.delete(value);
|
|
70
|
+
}
|
|
41
71
|
}
|
|
42
72
|
return JSON.stringify(value);
|
|
43
73
|
}
|
|
@@ -46,6 +46,13 @@ export function matchGlob(pattern, str) {
|
|
|
46
46
|
if (!regex) {
|
|
47
47
|
regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
|
|
48
48
|
globRegexCache.set(pattern, regex);
|
|
49
|
+
// Prevent unbounded cache growth. 1000 entries is generous for typical
|
|
50
|
+
// usage; a full clear is simpler and cheaper than LRU for a regex cache.
|
|
51
|
+
// Evict after insert so the just-compiled regex survives the clear.
|
|
52
|
+
if (globRegexCache.size >= 1000) {
|
|
53
|
+
globRegexCache.clear();
|
|
54
|
+
globRegexCache.set(pattern, regex);
|
|
55
|
+
}
|
|
49
56
|
}
|
|
50
57
|
return regex.test(str);
|
|
51
58
|
}
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* @see WARP Writer Spec v1
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { buildWriterRef } from '../utils/RefLayout.js';
|
|
13
12
|
import WriterError from '../errors/WriterError.js';
|
|
13
|
+
import { buildWriterRef } from '../utils/RefLayout.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Fluent patch session for building and committing graph mutations.
|
|
@@ -60,7 +60,7 @@ export class PatchSession {
|
|
|
60
60
|
*
|
|
61
61
|
* @param {string} nodeId - The node ID to add
|
|
62
62
|
* @returns {this} This session for chaining
|
|
63
|
-
* @throws {
|
|
63
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
64
64
|
*/
|
|
65
65
|
addNode(nodeId) {
|
|
66
66
|
this._ensureNotCommitted();
|
|
@@ -75,7 +75,7 @@ export class PatchSession {
|
|
|
75
75
|
*
|
|
76
76
|
* @param {string} nodeId - The node ID to remove
|
|
77
77
|
* @returns {this} This session for chaining
|
|
78
|
-
* @throws {
|
|
78
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
79
79
|
*/
|
|
80
80
|
removeNode(nodeId) {
|
|
81
81
|
this._ensureNotCommitted();
|
|
@@ -90,7 +90,7 @@ export class PatchSession {
|
|
|
90
90
|
* @param {string} to - Target node ID
|
|
91
91
|
* @param {string} label - Edge label/type
|
|
92
92
|
* @returns {this} This session for chaining
|
|
93
|
-
* @throws {
|
|
93
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
94
94
|
*/
|
|
95
95
|
addEdge(from, to, label) {
|
|
96
96
|
this._ensureNotCommitted();
|
|
@@ -107,7 +107,7 @@ export class PatchSession {
|
|
|
107
107
|
* @param {string} to - Target node ID
|
|
108
108
|
* @param {string} label - Edge label/type
|
|
109
109
|
* @returns {this} This session for chaining
|
|
110
|
-
* @throws {
|
|
110
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
111
111
|
*/
|
|
112
112
|
removeEdge(from, to, label) {
|
|
113
113
|
this._ensureNotCommitted();
|
|
@@ -122,7 +122,7 @@ export class PatchSession {
|
|
|
122
122
|
* @param {string} key - Property key
|
|
123
123
|
* @param {unknown} value - Property value (must be JSON-serializable)
|
|
124
124
|
* @returns {this} This session for chaining
|
|
125
|
-
* @throws {
|
|
125
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
126
126
|
*/
|
|
127
127
|
setProperty(nodeId, key, value) {
|
|
128
128
|
this._ensureNotCommitted();
|
|
@@ -139,7 +139,7 @@ export class PatchSession {
|
|
|
139
139
|
* @param {string} key - Property key
|
|
140
140
|
* @param {unknown} value - Property value (must be JSON-serializable)
|
|
141
141
|
* @returns {this} This session for chaining
|
|
142
|
-
* @throws {
|
|
142
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
143
143
|
*/
|
|
144
144
|
// eslint-disable-next-line max-params -- direct delegate matching PatchBuilderV2 signature
|
|
145
145
|
setEdgeProperty(from, to, label, key, value) {
|
|
@@ -154,7 +154,7 @@ export class PatchSession {
|
|
|
154
154
|
* @param {string} nodeId - The node ID to attach content to
|
|
155
155
|
* @param {Buffer|string} content - The content to attach
|
|
156
156
|
* @returns {Promise<this>} This session for chaining
|
|
157
|
-
* @throws {
|
|
157
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
158
158
|
*/
|
|
159
159
|
async attachContent(nodeId, content) {
|
|
160
160
|
this._ensureNotCommitted();
|
|
@@ -170,7 +170,7 @@ export class PatchSession {
|
|
|
170
170
|
* @param {string} label - Edge label/type
|
|
171
171
|
* @param {Buffer|string} content - The content to attach
|
|
172
172
|
* @returns {Promise<this>} This session for chaining
|
|
173
|
-
* @throws {
|
|
173
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
174
174
|
*/
|
|
175
175
|
// eslint-disable-next-line max-params -- direct delegate matching PatchBuilderV2 signature
|
|
176
176
|
async attachEdgeContent(from, to, label, content) {
|
|
@@ -199,6 +199,7 @@ export class PatchSession {
|
|
|
199
199
|
* @example
|
|
200
200
|
* const sha = await patch.commit();
|
|
201
201
|
*/
|
|
202
|
+
// eslint-disable-next-line complexity -- maps multiple commit-failure modes into stable WriterError codes
|
|
202
203
|
async commit() {
|
|
203
204
|
this._ensureNotCommitted();
|
|
204
205
|
|
|
@@ -207,19 +208,6 @@ export class PatchSession {
|
|
|
207
208
|
throw new WriterError('EMPTY_PATCH', 'Cannot commit empty patch: no operations added');
|
|
208
209
|
}
|
|
209
210
|
|
|
210
|
-
const writerRef = buildWriterRef(this._graphName, this._writerId);
|
|
211
|
-
|
|
212
|
-
// Pre-commit CAS check: verify ref hasn't moved
|
|
213
|
-
const currentHead = await this._persistence.readRef(writerRef);
|
|
214
|
-
if (currentHead !== this._expectedOldHead) {
|
|
215
|
-
throw new WriterError(
|
|
216
|
-
'WRITER_REF_ADVANCED',
|
|
217
|
-
`Writer ref ${writerRef} has advanced since beginPatch(). ` +
|
|
218
|
-
`Expected ${this._expectedOldHead || '(none)'}, found ${currentHead || '(none)'}. ` +
|
|
219
|
-
`Call beginPatch() again to retry.`
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
211
|
try {
|
|
224
212
|
// Delegate to PatchBuilderV2.commit() which handles the git operations
|
|
225
213
|
const sha = await this._builder.commit();
|
|
@@ -228,6 +216,21 @@ export class PatchSession {
|
|
|
228
216
|
} catch (err) {
|
|
229
217
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
230
218
|
const cause = err instanceof Error ? err : undefined;
|
|
219
|
+
const casError = /** @type {{code?: unknown, expectedSha?: unknown, actualSha?: unknown}|null} */ (
|
|
220
|
+
(err && typeof err === 'object') ? err : null
|
|
221
|
+
);
|
|
222
|
+
if (casError?.code === 'WRITER_CAS_CONFLICT') {
|
|
223
|
+
const writerRef = buildWriterRef(this._graphName, this._writerId);
|
|
224
|
+
const expectedSha = typeof casError.expectedSha === 'string' ? casError.expectedSha : this._expectedOldHead;
|
|
225
|
+
const actualSha = typeof casError.actualSha === 'string' ? casError.actualSha : null;
|
|
226
|
+
throw new WriterError(
|
|
227
|
+
'WRITER_REF_ADVANCED',
|
|
228
|
+
`Writer ref ${writerRef} has advanced since beginPatch(). ` +
|
|
229
|
+
`Expected ${expectedSha || '(none)'}, found ${actualSha || '(none)'}. ` +
|
|
230
|
+
'Call beginPatch() again to retry.',
|
|
231
|
+
cause
|
|
232
|
+
);
|
|
233
|
+
}
|
|
231
234
|
if (errMsg.includes('Concurrent commit detected') ||
|
|
232
235
|
errMsg.includes('has advanced')) {
|
|
233
236
|
throw new WriterError('WRITER_REF_ADVANCED', errMsg, cause);
|
|
@@ -246,12 +249,15 @@ export class PatchSession {
|
|
|
246
249
|
|
|
247
250
|
/**
|
|
248
251
|
* Ensures the session hasn't been committed yet.
|
|
249
|
-
* @throws {
|
|
252
|
+
* @throws {WriterError} SESSION_COMMITTED if already committed
|
|
250
253
|
* @private
|
|
251
254
|
*/
|
|
252
255
|
_ensureNotCommitted() {
|
|
253
256
|
if (this._committed) {
|
|
254
|
-
throw new
|
|
257
|
+
throw new WriterError(
|
|
258
|
+
'SESSION_COMMITTED',
|
|
259
|
+
'PatchSession already committed. Call beginPatch() to create a new session.',
|
|
260
|
+
);
|
|
255
261
|
}
|
|
256
262
|
}
|
|
257
263
|
}
|
|
@@ -186,6 +186,11 @@ export class Writer {
|
|
|
186
186
|
'commitPatch() is not reentrant. Use beginPatch() for nested or concurrent patches.',
|
|
187
187
|
);
|
|
188
188
|
}
|
|
189
|
+
// The `_commitInProgress` flag prevents concurrent commits from the same
|
|
190
|
+
// Writer instance. The finally block unconditionally resets it to ensure
|
|
191
|
+
// the writer remains usable after a failed commit. Error classification
|
|
192
|
+
// (CAS failure vs corruption vs I/O) is handled by the caller via the
|
|
193
|
+
// thrown error type.
|
|
189
194
|
this._commitInProgress = true;
|
|
190
195
|
try {
|
|
191
196
|
const patch = await this.beginPatch();
|
|
@@ -249,7 +249,7 @@ declare module '../WarpGraph.js' {
|
|
|
249
249
|
_resolveCeiling(options?: { ceiling?: number | null }): number | null;
|
|
250
250
|
_buildAdjacency(state: WarpStateV5): { outgoing: Map<string, Array<{ neighborId: string; label: string }>>; incoming: Map<string, Array<{ neighborId: string; label: string }>> };
|
|
251
251
|
_buildView(state: WarpStateV5, stateHash: string, diff?: import('../types/PatchDiff.js').PatchDiff): void;
|
|
252
|
-
_setMaterializedState(state: WarpStateV5, diff?: import('../types/PatchDiff.js').PatchDiff): Promise<{ state: WarpStateV5; stateHash: string; adjacency: unknown }>;
|
|
252
|
+
_setMaterializedState(state: WarpStateV5, optionsOrDiff?: import('../types/PatchDiff.js').PatchDiff | { diff?: import('../types/PatchDiff.js').PatchDiff | null }): Promise<{ state: WarpStateV5; stateHash: string; adjacency: unknown }>;
|
|
253
253
|
_materializeWithCeiling(ceiling: number, collectReceipts: boolean, t0: number): Promise<WarpStateV5 | { state: WarpStateV5; receipts: TickReceipt[] }>;
|
|
254
254
|
_persistSeekCacheEntry(cacheKey: string, buf: Buffer, state: WarpStateV5): Promise<void>;
|
|
255
255
|
_restoreIndexFromCache(indexTreeOid: string): Promise<void>;
|