@git-stunts/git-warp 12.1.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 (54) hide show
  1. package/README.md +8 -4
  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 +9 -2
  6. package/index.d.ts +18 -2
  7. package/package.json +1 -1
  8. package/src/domain/WarpGraph.js +4 -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 +63 -27
  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/SyncError.js +1 -0
  16. package/src/domain/errors/TrustError.js +2 -0
  17. package/src/domain/errors/WriterError.js +5 -0
  18. package/src/domain/errors/index.js +1 -0
  19. package/src/domain/services/AuditVerifierService.js +32 -2
  20. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  21. package/src/domain/services/CheckpointService.js +12 -8
  22. package/src/domain/services/Frontier.js +18 -0
  23. package/src/domain/services/GCPolicy.js +25 -4
  24. package/src/domain/services/GraphTraversal.js +11 -50
  25. package/src/domain/services/HttpSyncServer.js +18 -29
  26. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  27. package/src/domain/services/JoinReducer.js +164 -31
  28. package/src/domain/services/MaterializedViewService.js +13 -2
  29. package/src/domain/services/PatchBuilderV2.js +210 -145
  30. package/src/domain/services/QueryBuilder.js +67 -30
  31. package/src/domain/services/SyncController.js +62 -18
  32. package/src/domain/services/SyncPayloadSchema.js +236 -0
  33. package/src/domain/services/SyncProtocol.js +102 -40
  34. package/src/domain/services/SyncTrustGate.js +146 -0
  35. package/src/domain/services/TranslationCost.js +2 -2
  36. package/src/domain/trust/TrustRecordService.js +161 -34
  37. package/src/domain/utils/CachedValue.js +34 -5
  38. package/src/domain/utils/EventId.js +4 -1
  39. package/src/domain/utils/LRUCache.js +3 -1
  40. package/src/domain/utils/RefLayout.js +4 -0
  41. package/src/domain/utils/canonicalStringify.js +48 -18
  42. package/src/domain/utils/matchGlob.js +7 -0
  43. package/src/domain/warp/PatchSession.js +30 -24
  44. package/src/domain/warp/Writer.js +12 -5
  45. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  46. package/src/domain/warp/checkpoint.methods.js +102 -16
  47. package/src/domain/warp/materialize.methods.js +47 -5
  48. package/src/domain/warp/materializeAdvanced.methods.js +52 -10
  49. package/src/domain/warp/patch.methods.js +24 -8
  50. package/src/domain/warp/query.methods.js +4 -4
  51. package/src/domain/warp/subscribe.methods.js +11 -19
  52. package/src/infrastructure/adapters/GitGraphAdapter.js +57 -54
  53. package/src/infrastructure/codecs/CborCodec.js +2 -0
  54. package/src/domain/utils/fnv1a.js +0 -20
@@ -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
  }
@@ -128,12 +128,14 @@ export class Writer {
128
128
  const commitMessage = await this._persistence.showNode(expectedOldHead);
129
129
  const kind = detectMessageKind(commitMessage);
130
130
  if (kind === 'patch') {
131
- try {
132
- const patchInfo = decodePatchMessage(commitMessage);
133
- lamport = patchInfo.lamport + 1;
134
- } catch {
135
- // Malformed message, start at 1
131
+ const patchInfo = decodePatchMessage(commitMessage);
132
+ if (typeof patchInfo.lamport !== 'number' || !Number.isFinite(patchInfo.lamport) || patchInfo.lamport < 1) {
133
+ throw new WriterError(
134
+ 'E_LAMPORT_CORRUPT',
135
+ `Malformed Lamport timestamp in commit ${expectedOldHead}: ${JSON.stringify(patchInfo.lamport)}`,
136
+ );
136
137
  }
138
+ lamport = patchInfo.lamport + 1;
137
139
  }
138
140
  }
139
141
 
@@ -184,6 +186,11 @@ export class Writer {
184
186
  'commitPatch() is not reentrant. Use beginPatch() for nested or concurrent patches.',
185
187
  );
186
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.
187
194
  this._commitInProgress = true;
188
195
  try {
189
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>;
@@ -9,12 +9,13 @@
9
9
 
10
10
  import { QueryError, E_NO_STATE_MSG } from './_internal.js';
11
11
  import { buildWriterRef, buildCheckpointRef, buildCoverageRef } from '../utils/RefLayout.js';
12
- import { createFrontier, updateFrontier } from '../services/Frontier.js';
12
+ import { createFrontier, updateFrontier, frontierFingerprint } from '../services/Frontier.js';
13
13
  import { loadCheckpoint, create as createCheckpointCommit } from '../services/CheckpointService.js';
14
14
  import { decodePatchMessage, detectMessageKind, encodeAnchorMessage } from '../services/WarpMessageCodec.js';
15
15
  import { shouldRunGC, executeGC } from '../services/GCPolicy.js';
16
16
  import { collectGCMetrics } from '../services/GCMetrics.js';
17
17
  import { computeAppliedVV } from '../services/CheckpointSerializerV5.js';
18
+ import { cloneStateV5 } from '../services/JoinReducer.js';
18
19
 
19
20
  /** @typedef {import('../types/WarpPersistence.js').CorePersistence} CorePersistence */
20
21
 
@@ -166,8 +167,28 @@ export async function _loadLatestCheckpoint() {
166
167
 
167
168
  try {
168
169
  return await loadCheckpoint(this._persistence, checkpointSha, { codec: this._codec });
169
- } catch {
170
- return null;
170
+ } catch (err) {
171
+ // "Not found" conditions (missing tree entries, missing blobs) are expected
172
+ // when a checkpoint ref exists but the objects have been pruned or are
173
+ // unreachable. In that case, fall back to full replay by returning null.
174
+ // Decode/corruption errors (e.g., CBOR parse failure, schema mismatch)
175
+ // should propagate so callers see the real problem.
176
+ // These string-contains checks match specific error messages from the
177
+ // persistence layer and codec:
178
+ // "missing" — git cat-file on pruned/unreachable objects
179
+ // "not found" — readTree entry lookup failures
180
+ // "ENOENT" — filesystem-level missing path (bare repo edge case)
181
+ // "non-empty string" — readRef/getNodeInfo called with empty/null SHA
182
+ const msg = err instanceof Error ? err.message : '';
183
+ if (
184
+ msg.includes('missing') ||
185
+ msg.includes('not found') ||
186
+ msg.includes('ENOENT') ||
187
+ msg.includes('non-empty string')
188
+ ) {
189
+ return null;
190
+ }
191
+ throw err;
171
192
  }
172
193
  }
173
194
 
@@ -187,9 +208,11 @@ export async function _loadPatchesSince(checkpoint) {
187
208
  const checkpointSha = checkpoint.frontier?.get(writerId) || null;
188
209
  const patches = await this._loadWriterPatches(writerId, checkpointSha);
189
210
 
190
- // Validate each patch against checkpoint frontier
191
- for (const { sha } of patches) {
192
- await this._validatePatchAgainstCheckpoint(writerId, sha, checkpoint);
211
+ // Validate ancestry once at the writer tip; chain-order patches are then
212
+ // transitively valid between checkpointSha and tipSha.
213
+ if (patches.length > 0) {
214
+ const tipSha = patches[patches.length - 1].sha;
215
+ await this._validatePatchAgainstCheckpoint(writerId, tipSha, checkpoint);
193
216
  }
194
217
 
195
218
  for (const p of patches) {
@@ -227,10 +250,16 @@ export async function _validateMigrationBoundary() {
227
250
  }
228
251
 
229
252
  /**
230
- * Checks if there are any schema:1 patches in the graph.
253
+ * Checks whether any writer tip contains a schema:1 patch.
254
+ *
255
+ * **Heuristic only** — inspects the most recent patch per writer (the tip),
256
+ * not the full history chain. Older schema:1 patches buried deeper in a
257
+ * writer's chain will NOT be detected. This is acceptable because migration
258
+ * typically writes a new tip, so a schema:2+ tip implies the writer has
259
+ * been migrated.
231
260
  *
232
261
  * @this {import('../WarpGraph.js').default}
233
- * @returns {Promise<boolean>} True if schema:1 patches exist
262
+ * @returns {Promise<boolean>} True if any writer tip is schema:1 (or omits `schema`, treated as legacy v1)
234
263
  * @private
235
264
  */
236
265
  export async function _hasSchema1Patches() {
@@ -267,6 +296,12 @@ export async function _hasSchema1Patches() {
267
296
  * Post-materialize GC check. Warn by default; execute only when enabled.
268
297
  * GC failure never breaks materialize.
269
298
  *
299
+ * Uses clone-then-swap pattern for snapshot isolation (B63):
300
+ * 1. Snapshot frontier fingerprint before GC
301
+ * 2. Clone state, run executeGC on clone
302
+ * 3. Compare frontier after GC — if changed, discard clone + mark dirty
303
+ * 4. If unchanged, swap compacted clone into _cachedState
304
+ *
270
305
  * @this {import('../WarpGraph.js').default}
271
306
  * @param {import('../services/JoinReducer.js').WarpStateV5} state
272
307
  * @private
@@ -287,8 +322,36 @@ export function _maybeRunGC(state) {
287
322
  }
288
323
 
289
324
  if (/** @type {import('../services/GCPolicy.js').GCPolicy} */ (this._gcPolicy).enabled) {
290
- const appliedVV = computeAppliedVV(state);
291
- const result = executeGC(state, appliedVV);
325
+ // Snapshot frontier before GC
326
+ const preGcFingerprint = this._lastFrontier
327
+ ? frontierFingerprint(this._lastFrontier)
328
+ : null;
329
+
330
+ // Clone state so executeGC doesn't mutate live state
331
+ const clonedState = cloneStateV5(state);
332
+ const appliedVV = computeAppliedVV(clonedState);
333
+ const result = executeGC(clonedState, appliedVV);
334
+
335
+ // Check if frontier changed during GC (concurrent write)
336
+ const postGcFingerprint = this._lastFrontier
337
+ ? frontierFingerprint(this._lastFrontier)
338
+ : null;
339
+
340
+ if (preGcFingerprint !== postGcFingerprint) {
341
+ // Frontier changed — discard compacted state, mark dirty
342
+ this._stateDirty = true;
343
+ this._cachedViewHash = null;
344
+ if (this._logger) {
345
+ this._logger.warn(
346
+ 'Auto-GC discarded: frontier changed during compaction (concurrent write)',
347
+ { reasons, preGcFingerprint, postGcFingerprint },
348
+ );
349
+ }
350
+ return;
351
+ }
352
+
353
+ // Frontier unchanged — swap in compacted state
354
+ this._cachedState = clonedState;
292
355
  this._lastGCTime = this._clock.now();
293
356
  this._patchesSinceGC = 0;
294
357
  if (this._logger) {
@@ -348,11 +411,17 @@ export function maybeRunGC() {
348
411
  * Explicitly runs GC on the cached state.
349
412
  * Compacts tombstoned dots that are covered by the appliedVV.
350
413
  *
414
+ * Uses clone-then-swap pattern for snapshot isolation (B63):
415
+ * clones state, runs executeGC on clone, verifies frontier unchanged,
416
+ * then swaps in compacted clone. If frontier changed during GC,
417
+ * throws E_GC_STALE so the caller can retry after re-materializing.
418
+ *
351
419
  * **Requires a cached state.**
352
420
  *
353
421
  * @this {import('../WarpGraph.js').default}
354
422
  * @returns {{nodesCompacted: number, edgesCompacted: number, tombstonesRemoved: number, durationMs: number}}
355
423
  * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
424
+ * @throws {QueryError} If frontier changed during GC (code: `E_GC_STALE`)
356
425
  *
357
426
  * @example
358
427
  * await graph.materialize();
@@ -368,13 +437,30 @@ export function runGC() {
368
437
  });
369
438
  }
370
439
 
371
- // Compute appliedVV from current state
372
- const appliedVV = computeAppliedVV(this._cachedState);
373
-
374
- // Execute GC (mutates cached state)
375
- const result = executeGC(this._cachedState, appliedVV);
440
+ // Snapshot frontier before GC
441
+ const preGcFingerprint = this._lastFrontier
442
+ ? frontierFingerprint(this._lastFrontier)
443
+ : null;
444
+
445
+ // Clone state so executeGC doesn't mutate live state until verified
446
+ const clonedState = cloneStateV5(this._cachedState);
447
+ const appliedVV = computeAppliedVV(clonedState);
448
+ const result = executeGC(clonedState, appliedVV);
449
+
450
+ // Verify frontier unchanged (concurrent write detection)
451
+ const postGcFingerprint = this._lastFrontier
452
+ ? frontierFingerprint(this._lastFrontier)
453
+ : null;
454
+
455
+ if (preGcFingerprint !== postGcFingerprint) {
456
+ throw new QueryError(
457
+ 'GC aborted: frontier changed during compaction (concurrent write detected)',
458
+ { code: 'E_GC_STALE' },
459
+ );
460
+ }
376
461
 
377
- // Update GC tracking
462
+ // Frontier unchanged — swap in compacted state
463
+ this._cachedState = clonedState;
378
464
  this._lastGCTime = this._clock.now();
379
465
  this._patchesSinceGC = 0;
380
466
 
@@ -51,6 +51,30 @@ function scanPatchesForMaxLamport(graph, patches) {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Creates a shallow-frozen public view of materialized state.
56
+ *
57
+ * @param {import('../services/JoinReducer.js').WarpStateV5} state
58
+ * @returns {import('../services/JoinReducer.js').WarpStateV5}
59
+ */
60
+ function freezePublicState(state) {
61
+ return Object.freeze({ ...state });
62
+ }
63
+
64
+ /**
65
+ * Creates a shallow-frozen public result for receipt-enabled materialization.
66
+ *
67
+ * @param {import('../services/JoinReducer.js').WarpStateV5} state
68
+ * @param {import('../types/TickReceipt.js').TickReceipt[]} receipts
69
+ * @returns {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}}
70
+ */
71
+ function freezePublicStateWithReceipts(state, receipts) {
72
+ return Object.freeze({
73
+ state: freezePublicState(state),
74
+ receipts,
75
+ });
76
+ }
77
+
54
78
 
55
79
  /**
56
80
  * Materializes the current graph state.
@@ -90,7 +114,12 @@ export async function materialize(options) {
90
114
  try {
91
115
  // When ceiling is active, delegate to ceiling-aware path (with its own cache)
92
116
  if (ceiling !== null) {
93
- return await this._materializeWithCeiling(ceiling, !!collectReceipts, t0);
117
+ const result = await this._materializeWithCeiling(ceiling, !!collectReceipts, t0);
118
+ if (collectReceipts) {
119
+ const withReceipts = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}} */ (result);
120
+ return freezePublicStateWithReceipts(withReceipts.state, withReceipts.receipts);
121
+ }
122
+ return freezePublicState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (result));
94
123
  }
95
124
 
96
125
  // Check for checkpoint
@@ -190,7 +219,7 @@ export async function materialize(options) {
190
219
  }
191
220
  }
192
221
 
193
- await this._setMaterializedState(state, diff);
222
+ await this._setMaterializedState(state, { diff });
194
223
  this._provenanceDegraded = false;
195
224
  this._cachedCeiling = null;
196
225
  this._cachedFrontier = null;
@@ -225,9 +254,12 @@ export async function materialize(options) {
225
254
  this._logTiming('materialize', t0, { metrics: `${patchCount} patches` });
226
255
 
227
256
  if (collectReceipts) {
228
- return { state, receipts: /** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts) };
257
+ return freezePublicStateWithReceipts(
258
+ /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state),
259
+ /** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts),
260
+ );
229
261
  }
230
- return state;
262
+ return freezePublicState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
231
263
  } catch (err) {
232
264
  this._logTiming('materialize', t0, { error: /** @type {Error} */ (err) });
233
265
  throw err;
@@ -242,7 +274,17 @@ export async function materialize(options) {
242
274
  * @private
243
275
  */
244
276
  export async function _materializeGraph() {
245
- const state = await this.materialize();
277
+ if (!this._stateDirty && this._materializedGraph) {
278
+ return this._materializedGraph;
279
+ }
280
+ const materialized = await this.materialize();
281
+ const state = this._stateDirty
282
+ ? /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (materialized)
283
+ : (this._cachedState
284
+ || /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (materialized));
285
+ if (!state) {
286
+ return /** @type {object} */ (this._materializedGraph);
287
+ }
246
288
  if (!this._materializedGraph || this._materializedGraph.state !== state) {
247
289
  await this._setMaterializedState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
248
290
  }