@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
@@ -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
- // Note: We assume remote is ahead; the caller should verify ancestry
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: frontierObj,
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
- // Convert incoming frontier from object to Map
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: frontierObj,
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
- // Group patches by writer to ensure proper ordering
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 containing ops we don't understand.
522
- // Currently SCHEMA_V3 is the max, so this is a no-op for this
523
- // codebase. If a future schema adds new op types, this check
524
- // will prevent silent data loss until the reader is upgraded.
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: frontierObj,
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<Array<Record<string, unknown>>>}
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
- if (!tip) {
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
- return [];
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
- const records = [];
130
- let current = tip;
137
+ const records = [];
138
+ let current = tip;
131
139
 
132
- while (current) {
133
- const info = await this._persistence.getNodeInfo(current);
134
- const entries = await this._persistence.readTreeOids(
135
- await this._persistence.getCommitTree(current),
136
- );
137
- const blobOid = entries['record.cbor'];
138
- if (!blobOid) {
139
- break;
140
- }
141
- const record = /** @type {Record<string, unknown>} */ (this._codec.decode(
142
- await this._persistence.readBlob(blobOid),
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
- records.unshift(record);
153
+ records.unshift(record);
146
154
 
147
- if (info.parents.length === 0) {
148
- break;
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
- return records;
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
- const value = await this._compute();
75
- this._value = value;
76
- this._cachedAt = this._clock.now();
77
- this._cachedAtIso = this._clock.timestamp();
80
+ if (this._inflight) {
81
+ return await this._inflight;
82
+ }
78
83
 
79
- return value;
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
- // Move to end (most recently used) by deleting and re-inserting
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
- // Map elements: undefined/function/symbol -> "null", others recurse
22
- const elements = value.map(el => {
23
- if (el === undefined || typeof el === 'function' || typeof el === 'symbol') {
24
- return 'null';
25
- }
26
- return canonicalStringify(el);
27
- });
28
- return `[${elements.join(',')}]`;
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
- const obj = /** @type {Record<string, unknown>} */ (value);
32
- // Filter out keys with undefined/function/symbol values, then sort
33
- const keys = Object.keys(obj)
34
- .filter(k => {
35
- const v = obj[k];
36
- return v !== undefined && typeof v !== 'function' && typeof v !== 'symbol';
37
- })
38
- .sort();
39
- const pairs = keys.map(k => `${JSON.stringify(k)}:${canonicalStringify(obj[k])}`);
40
- return `{${pairs.join(',')}}`;
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 {Error} If this session has already been committed
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 {Error} If this session has already been committed
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 {Error} If this session has already been committed
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 {Error} If this session has already been committed
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 {Error} If this session has already been committed
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 {Error} If this session has already been committed
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 {Error} If this session has already been committed
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 {Error} If this session has already been committed
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 {Error} If already committed
252
+ * @throws {WriterError} SESSION_COMMITTED if already committed
250
253
  * @private
251
254
  */
252
255
  _ensureNotCommitted() {
253
256
  if (this._committed) {
254
- throw new Error('PatchSession already committed. Call beginPatch() to create a new session.');
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>;