@git-stunts/git-warp 12.0.0 → 12.2.0

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 (31) hide show
  1. package/README.md +6 -9
  2. package/bin/warp-graph.js +6 -2
  3. package/index.d.ts +4 -4
  4. package/package.json +2 -1
  5. package/src/domain/WarpGraph.js +3 -0
  6. package/src/domain/crdt/ORSet.js +33 -4
  7. package/src/domain/errors/SyncError.js +1 -0
  8. package/src/domain/errors/TrustError.js +2 -0
  9. package/src/domain/services/CheckpointService.js +2 -7
  10. package/src/domain/services/Frontier.js +18 -0
  11. package/src/domain/services/GraphTraversal.js +8 -49
  12. package/src/domain/services/HttpSyncServer.js +18 -29
  13. package/src/domain/services/JoinReducer.js +23 -0
  14. package/src/domain/services/ObserverView.js +4 -32
  15. package/src/domain/services/PatchBuilderV2.js +29 -3
  16. package/src/domain/services/QueryBuilder.js +78 -74
  17. package/src/domain/services/SyncController.js +74 -11
  18. package/src/domain/services/SyncPayloadSchema.js +236 -0
  19. package/src/domain/services/SyncProtocol.js +27 -8
  20. package/src/domain/services/SyncTrustGate.js +146 -0
  21. package/src/domain/services/TranslationCost.js +8 -24
  22. package/src/domain/trust/TrustRecordService.js +119 -6
  23. package/src/domain/utils/matchGlob.js +51 -0
  24. package/src/domain/warp/Writer.js +7 -5
  25. package/src/domain/warp/checkpoint.methods.js +66 -9
  26. package/src/domain/warp/materialize.methods.js +3 -0
  27. package/src/domain/warp/materializeAdvanced.methods.js +2 -0
  28. package/src/domain/warp/patch.methods.js +8 -0
  29. package/src/domain/warp/query.methods.js +7 -5
  30. package/src/domain/warp/subscribe.methods.js +11 -19
  31. package/src/infrastructure/adapters/GitGraphAdapter.js +2 -2
@@ -37,6 +37,7 @@
37
37
  */
38
38
 
39
39
  import defaultCodec from '../utils/defaultCodec.js';
40
+ import nullLogger from '../utils/nullLogger.js';
40
41
  import { decodePatchMessage, assertOpsCompatible, SCHEMA_V3 } from './WarpMessageCodec.js';
41
42
  import { join, cloneStateV5 } from './JoinReducer.js';
42
43
  import { cloneFrontier, updateFrontier } from './Frontier.js';
@@ -267,11 +268,9 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
267
268
  newWritersForRemote.push(writerId);
268
269
  } else if (remoteSha !== localSha) {
269
270
  // Different heads - remote might need patches from its head to local head
270
- // Only add if not already in needFromRemote (avoid double-counting)
271
- // This handles the case where local is ahead of remote
272
- if (!needFromRemote.has(writerId)) {
273
- needFromLocal.set(writerId, { from: remoteSha, to: localSha });
274
- }
271
+ // Always add both directions ancestry is verified during loadPatchRange()
272
+ // which will throw E_SYNC_DIVERGENCE if neither side descends from the other (S3)
273
+ needFromLocal.set(writerId, { from: remoteSha, to: localSha });
275
274
  }
276
275
  }
277
276
 
@@ -315,6 +314,8 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
315
314
  * - `writerId`: The writer who created this patch
316
315
  * - `sha`: The commit SHA this patch came from (for frontier updates)
317
316
  * - `patch`: The decoded patch object with ops and context
317
+ * @property {Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>} [skippedWriters] - Writers that were skipped during sync
318
+ * (e.g. due to trust gate filtering, divergence, or missing refs)
318
319
  */
319
320
 
320
321
  /**
@@ -375,6 +376,7 @@ export function createSyncRequest(frontier) {
375
376
  * @param {string} graphName - Graph name for error messages and logging
376
377
  * @param {Object} [options]
377
378
  * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization
379
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for divergence warnings
378
380
  * @returns {Promise<SyncResponse>} Response containing local frontier and patches.
379
381
  * Patches are ordered chronologically within each writer.
380
382
  * @throws {Error} If patch loading fails for reasons other than divergence
@@ -388,7 +390,9 @@ export function createSyncRequest(frontier) {
388
390
  * res.json(response);
389
391
  * });
390
392
  */
391
- export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = /** @type {{ codec?: import('../../ports/CodecPort.js').default }} */ ({})) {
393
+ export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec, logger } = /** @type {{ codec?: import('../../ports/CodecPort.js').default, logger?: import('../../ports/LoggerPort.js').default }} */ ({})) {
394
+ const log = logger || nullLogger;
395
+
392
396
  // Convert incoming frontier from object to Map
393
397
  const remoteFrontier = new Map(Object.entries(request.frontier));
394
398
 
@@ -397,6 +401,8 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
397
401
 
398
402
  // Load patches that the requester needs (from local to requester)
399
403
  const patches = [];
404
+ /** @type {Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>} */
405
+ const skippedWriters = [];
400
406
 
401
407
  for (const [writerId, range] of delta.needFromRemote) {
402
408
  try {
@@ -413,9 +419,21 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
413
419
  patches.push({ writerId, sha, patch });
414
420
  }
415
421
  } catch (err) {
416
- // If we detect divergence, skip this writer
417
- // The requester may need to handle this separately
422
+ // If we detect divergence, log and skip this writer (B65).
423
+ // The requester will not receive patches for this writer.
418
424
  if ((err instanceof Error && 'code' in err && /** @type {{ code: string }} */ (err).code === 'E_SYNC_DIVERGENCE') || (err instanceof Error && err.message?.includes('Divergence detected'))) {
425
+ const entry = {
426
+ writerId,
427
+ reason: 'E_SYNC_DIVERGENCE',
428
+ localSha: range.to,
429
+ remoteSha: range.from ?? '',
430
+ };
431
+ skippedWriters.push(entry);
432
+ log.warn('Sync divergence detected — skipping writer', {
433
+ code: 'E_SYNC_DIVERGENCE',
434
+ graphName,
435
+ ...entry,
436
+ });
419
437
  continue;
420
438
  }
421
439
  throw err;
@@ -433,6 +451,7 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
433
451
  type: /** @type {'sync-response'} */ ('sync-response'),
434
452
  frontier: frontierObj,
435
453
  patches,
454
+ skippedWriters,
436
455
  };
437
456
  }
438
457
 
@@ -0,0 +1,146 @@
1
+ /**
2
+ * SyncTrustGate -- Encapsulates trust evaluation for sync operations.
3
+ *
4
+ * Evaluates whether inbound patch authors are trusted according to the
5
+ * trust record chain. Used by SyncController to validate HTTP sync
6
+ * responses before applying patches.
7
+ *
8
+ * Trust-gates on `writersApplied` (patch authors being ingested), not
9
+ * frontier keys (which are claims, not effects).
10
+ *
11
+ * @module domain/services/SyncTrustGate
12
+ * @see B1 -- Signed sync ingress
13
+ */
14
+
15
+ import nullLogger from '../utils/nullLogger.js';
16
+
17
+ /**
18
+ * @typedef {'enforce'|'log-only'|'off'} TrustMode
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} TrustGateResult
23
+ * @property {boolean} allowed - Whether the writers are trusted
24
+ * @property {string[]} untrustedWriters - Writers that failed trust evaluation
25
+ * @property {string} verdict - Human-readable verdict
26
+ */
27
+
28
+ /** @type {() => TrustGateResult} */
29
+ const PASS = () => ({ allowed: true, untrustedWriters: [], verdict: 'pass' });
30
+
31
+ export default class SyncTrustGate {
32
+ /**
33
+ * @param {Object} options
34
+ * @param {{evaluateWriters: (writerIds: string[]) => Promise<{trusted: Set<string>}>}} [options.trustEvaluator] - Trust evaluator instance
35
+ * @param {TrustMode} [options.trustMode='off'] - Trust enforcement mode
36
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger
37
+ */
38
+ constructor({ trustEvaluator, trustMode = 'off', logger } = {}) {
39
+ this._evaluator = trustEvaluator || null;
40
+ this._mode = trustMode;
41
+ this._logger = logger || nullLogger;
42
+ }
43
+
44
+ /**
45
+ * Evaluates whether the given patch writers are trusted.
46
+ *
47
+ * @param {string[]} writerIds - Writer IDs from patches being applied
48
+ * @param {Object} [context] - Additional context for logging
49
+ * @param {string} [context.graphName] - Graph name
50
+ * @param {string} [context.peerId] - Remote peer identity (if authenticated)
51
+ * @returns {Promise<TrustGateResult>}
52
+ */
53
+ async evaluate(writerIds, context = {}) {
54
+ if (this._mode === 'off' || !this._evaluator) {
55
+ return { allowed: true, untrustedWriters: [], verdict: 'trust_disabled' };
56
+ }
57
+ if (writerIds.length === 0) {
58
+ return { allowed: true, untrustedWriters: [], verdict: 'no_writers' };
59
+ }
60
+
61
+ try {
62
+ const result = await this._evaluator.evaluateWriters(writerIds);
63
+ const untrusted = writerIds.filter((id) => !result.trusted.has(id));
64
+ return this._decide(untrusted, writerIds, context);
65
+ } catch (err) {
66
+ return this._handleError(err, writerIds, context);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Decides the gate result based on untrusted writers and mode.
72
+ * @param {string[]} untrusted
73
+ * @param {string[]} writerIds
74
+ * @param {Object} context
75
+ * @returns {TrustGateResult}
76
+ * @private
77
+ */
78
+ _decide(untrusted, writerIds, context) {
79
+ this._logger.info('Trust gate decision', {
80
+ code: 'SYNC_TRUST_GATE',
81
+ mode: this._mode,
82
+ writersApplied: writerIds,
83
+ untrustedWriters: untrusted,
84
+ verdict: untrusted.length === 0 ? 'pass' : 'fail',
85
+ ...context,
86
+ });
87
+
88
+ if (untrusted.length === 0) {
89
+ return PASS();
90
+ }
91
+
92
+ if (this._mode === 'enforce') {
93
+ this._logger.warn('Trust gate rejected untrusted writers', {
94
+ code: 'SYNC_TRUST_REJECTED',
95
+ untrustedWriters: untrusted,
96
+ ...context,
97
+ });
98
+ return { allowed: false, untrustedWriters: untrusted, verdict: 'rejected' };
99
+ }
100
+
101
+ this._logger.warn('Trust gate: untrusted writers allowed (log-only mode)', {
102
+ code: 'SYNC_TRUST_WARN',
103
+ untrustedWriters: untrusted,
104
+ ...context,
105
+ });
106
+ return { allowed: true, untrustedWriters: untrusted, verdict: 'warn_allowed' };
107
+ }
108
+
109
+ /**
110
+ * Handles trust evaluation errors with fail-open/fail-closed semantics.
111
+ * @param {unknown} err
112
+ * @param {string[]} writerIds
113
+ * @param {Object} context
114
+ * @returns {TrustGateResult}
115
+ * @private
116
+ */
117
+ _handleError(err, writerIds, context) {
118
+ this._logger.error('Trust gate evaluation failed', {
119
+ code: 'SYNC_TRUST_ERROR',
120
+ error: err instanceof Error ? err.message : String(err),
121
+ ...context,
122
+ });
123
+
124
+ if (this._mode === 'enforce') {
125
+ return { allowed: false, untrustedWriters: writerIds, verdict: 'error_rejected' };
126
+ }
127
+ return { allowed: true, untrustedWriters: [], verdict: 'error_allowed' };
128
+ }
129
+
130
+ /**
131
+ * Extracts writer IDs from patches in a sync response.
132
+ * These are the actual data authors being ingested — the trust target.
133
+ *
134
+ * @param {Array<{writerId: string}>} patches - Patches from sync response
135
+ * @returns {string[]} Deduplicated writer IDs
136
+ */
137
+ static extractWritersFromPatches(patches) {
138
+ const writers = new Set();
139
+ for (const { writerId } of patches) {
140
+ if (writerId) {
141
+ writers.add(writerId);
142
+ }
143
+ }
144
+ return [...writers];
145
+ }
146
+ }
@@ -15,28 +15,10 @@
15
15
 
16
16
  import { orsetElements, orsetContains } from '../crdt/ORSet.js';
17
17
  import { decodeEdgeKey, decodePropKey, isEdgePropKey } from './KeyCodec.js';
18
+ import { matchGlob } from '../utils/matchGlob.js';
18
19
 
19
20
  /** @typedef {import('./JoinReducer.js').WarpStateV5} WarpStateV5 */
20
21
 
21
- /**
22
- * Tests whether a string matches a glob-style pattern.
23
- *
24
- * @param {string} pattern - Glob pattern (e.g. 'user:*', '*:admin', '*')
25
- * @param {string} str - The string to test
26
- * @returns {boolean} True if the string matches the pattern
27
- */
28
- function matchGlob(pattern, str) {
29
- if (pattern === '*') {
30
- return true;
31
- }
32
- if (!pattern.includes('*')) {
33
- return pattern === str;
34
- }
35
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
36
- const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
37
- return regex.test(str);
38
- }
39
-
40
22
  /**
41
23
  * Computes the set of property keys visible under an observer config.
42
24
  *
@@ -188,20 +170,22 @@ function computePropLoss(state, { nodesA, nodesBSet, configA, configB }) {
188
170
  * A's view to B's view. It is asymmetric: cost(A->B) != cost(B->A) in general.
189
171
  *
190
172
  * @param {Object} configA - Observer configuration for A
191
- * @param {string} configA.match - Glob pattern for visible nodes
173
+ * @param {string|string[]} configA.match - Glob pattern(s) for visible nodes
192
174
  * @param {string[]} [configA.expose] - Property keys to include
193
175
  * @param {string[]} [configA.redact] - Property keys to exclude
194
176
  * @param {Object} configB - Observer configuration for B
195
- * @param {string} configB.match - Glob pattern for visible nodes
177
+ * @param {string|string[]} configB.match - Glob pattern(s) for visible nodes
196
178
  * @param {string[]} [configB.expose] - Property keys to include
197
179
  * @param {string[]} [configB.redact] - Property keys to exclude
198
180
  * @param {WarpStateV5} state - WarpStateV5 materialized state
199
181
  * @returns {{ cost: number, breakdown: { nodeLoss: number, edgeLoss: number, propLoss: number } }}
200
182
  */
201
183
  export function computeTranslationCost(configA, configB, state) {
202
- if (!configA || typeof configA.match !== 'string' ||
203
- !configB || typeof configB.match !== 'string') {
204
- throw new Error('configA.match and configB.match must be strings');
184
+ /** @param {unknown} m */
185
+ const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.length > 0 && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
186
+ if (!configA || !isValidMatch(configA.match) ||
187
+ !configB || !isValidMatch(configB.match)) {
188
+ throw new Error('configA.match and configB.match must be non-empty strings or non-empty arrays of strings');
205
189
  }
206
190
  const allNodes = [...orsetElements(state.nodeAlive)];
207
191
  const nodesA = allNodes.filter((id) => matchGlob(configA.match, id));
@@ -14,6 +14,13 @@ import { TrustRecordSchema } from './schemas.js';
14
14
  import { verifyRecordId } from './TrustCanonical.js';
15
15
  import TrustError from '../errors/TrustError.js';
16
16
 
17
+ /**
18
+ * Maximum CAS attempts for _persistRecord before giving up.
19
+ * Handles transient failures (lock contention, I/O race).
20
+ * @type {number}
21
+ */
22
+ const MAX_CAS_ATTEMPTS = 3;
23
+
17
24
  /**
18
25
  * @typedef {Object} AppendOptions
19
26
  * @property {boolean} [skipSignatureVerify=false] - Skip signature verification (for testing)
@@ -104,8 +111,15 @@ export class TrustRecordService {
104
111
  if (!tip) {
105
112
  try {
106
113
  tip = await this._persistence.readRef(ref);
107
- } catch {
108
- return [];
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
+ );
109
123
  }
110
124
  if (!tip) {
111
125
  return [];
@@ -196,6 +210,62 @@ export class TrustRecordService {
196
210
  return { valid: errors.length === 0, errors };
197
211
  }
198
212
 
213
+ /**
214
+ * Appends a trust record with automatic retry on CAS conflict.
215
+ *
216
+ * On E_TRUST_CAS_CONFLICT, re-reads the chain tip, rebuilds the record
217
+ * with the new prev pointer, re-signs if a signer is provided, and
218
+ * retries. This is the higher-level API callers should use when they
219
+ * want automatic convergence under concurrent appenders.
220
+ *
221
+ * @param {string} graphName
222
+ * @param {Record<string, unknown>} record - Complete signed trust record
223
+ * @param {Object} [options]
224
+ * @param {number} [options.maxRetries=3] - Maximum rebuild-and-retry attempts
225
+ * @param {((record: Record<string, unknown>) => Promise<Record<string, unknown>>)|null} [options.resign] - Function to re-sign a rebuilt record (null for unsigned)
226
+ * @param {boolean} [options.skipSignatureVerify=false] - Skip signature verification
227
+ * @returns {Promise<{commitSha: string, ref: string, attempts: number}>}
228
+ * @throws {TrustError} E_TRUST_CAS_EXHAUSTED if all retries fail
229
+ */
230
+ async appendRecordWithRetry(graphName, record, options = {}) {
231
+ const { maxRetries = 3, resign = null, skipSignatureVerify = false } = options;
232
+ let currentRecord = record;
233
+ let attempts = 0;
234
+
235
+ for (let i = 0; i <= maxRetries; i++) {
236
+ attempts++;
237
+ try {
238
+ const result = await this.appendRecord(graphName, currentRecord, { skipSignatureVerify });
239
+ return { ...result, attempts };
240
+ } catch (err) {
241
+ if (!(err instanceof TrustError) || err.code !== 'E_TRUST_CAS_CONFLICT') {
242
+ throw err;
243
+ }
244
+
245
+ if (i === maxRetries) {
246
+ throw new TrustError(
247
+ `Trust CAS exhausted after ${attempts} attempts (with retry)`,
248
+ { code: 'E_TRUST_CAS_EXHAUSTED' },
249
+ );
250
+ }
251
+
252
+ // Rebuild: re-read chain tip, update prev pointer
253
+ const freshTipRecordId = err.context?.actualTipRecordId ?? null;
254
+
255
+ // Update prev to the new chain tip's recordId
256
+ currentRecord = { ...currentRecord, prev: freshTipRecordId };
257
+
258
+ // Re-sign if signer is provided
259
+ if (resign) {
260
+ currentRecord = await resign(currentRecord);
261
+ }
262
+ }
263
+ }
264
+
265
+ // Unreachable
266
+ throw new TrustError('Trust CAS failed', { code: 'E_TRUST_CAS_EXHAUSTED' });
267
+ }
268
+
199
269
  /**
200
270
  * Validates that a record's signature envelope is structurally complete.
201
271
  *
@@ -246,7 +316,15 @@ export class TrustRecordService {
246
316
  }
247
317
 
248
318
  /**
249
- * Persists a trust record as a Git commit.
319
+ * Persists a trust record as a Git commit with CAS retry.
320
+ *
321
+ * On transient CAS failures (ref unchanged, e.g. lock contention), retries
322
+ * up to MAX_CAS_ATTEMPTS total. On real concurrent appends (ref advanced),
323
+ * throws E_TRUST_CAS_CONFLICT so the caller can rebuild + re-sign the record.
324
+ *
325
+ * The record's prev, recordId, and signature form a cryptographic chain.
326
+ * Only the original signer can rebuild, so we never silently rebase.
327
+ *
250
328
  * @param {string} ref
251
329
  * @param {Record<string, unknown>} record
252
330
  * @param {string|null} parentSha - Resolved tip SHA (null for genesis)
@@ -273,9 +351,44 @@ export class TrustRecordService {
273
351
  message,
274
352
  });
275
353
 
276
- // CAS update ref fails atomically if a concurrent append changed the tip
277
- await this._persistence.compareAndSwapRef(ref, commitSha, parentSha);
354
+ // CAS update ref with retry for transient failures
355
+ for (let attempt = 1; attempt <= MAX_CAS_ATTEMPTS; attempt++) {
356
+ try {
357
+ await this._persistence.compareAndSwapRef(ref, commitSha, parentSha);
358
+ return commitSha;
359
+ } catch {
360
+ // Read fresh tip to distinguish transient vs real conflict
361
+ const { tipSha: freshTipSha, recordId: freshRecordId } = await this._readTip(ref);
362
+
363
+ if (freshTipSha === parentSha) {
364
+ // Ref unchanged — transient failure (lock contention, I/O race).
365
+ // Retry the same CAS with same commit.
366
+ if (attempt === MAX_CAS_ATTEMPTS) {
367
+ throw new TrustError(
368
+ `Trust CAS exhausted after ${MAX_CAS_ATTEMPTS} attempts`,
369
+ { code: 'E_TRUST_CAS_EXHAUSTED' },
370
+ );
371
+ }
372
+ continue;
373
+ }
374
+
375
+ // Ref changed — real concurrent append. Our record's prev no longer
376
+ // matches the chain tip. The caller must rebuild, re-sign, and retry.
377
+ throw new TrustError(
378
+ `Trust CAS conflict: chain advanced from ${parentSha} to ${freshTipSha}`,
379
+ {
380
+ code: 'E_TRUST_CAS_CONFLICT',
381
+ context: {
382
+ expectedTipSha: parentSha,
383
+ actualTipSha: freshTipSha,
384
+ actualTipRecordId: freshRecordId,
385
+ },
386
+ },
387
+ );
388
+ }
389
+ }
278
390
 
279
- return commitSha;
391
+ // Unreachable, but satisfies type checker
392
+ throw new TrustError('Trust CAS failed', { code: 'E_TRUST_CAS_EXHAUSTED' });
280
393
  }
281
394
  }
@@ -0,0 +1,51 @@
1
+ /** @type {Map<string, RegExp>} Module-level cache for compiled glob regexes. */
2
+ const globRegexCache = new Map();
3
+
4
+ /**
5
+ * Escapes special regex characters in a string so it can be used as a literal match.
6
+ *
7
+ * @param {string} value - The string to escape
8
+ * @returns {string} The escaped string safe for use in a RegExp
9
+ * @private
10
+ */
11
+ function escapeRegex(value) {
12
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ }
14
+
15
+ /**
16
+ * Tests whether a string matches a glob-style pattern or an array of patterns.
17
+ *
18
+ * Supports:
19
+ * - `*` as the default pattern, matching all strings
20
+ * - Wildcard `*` anywhere in the pattern, matching zero or more characters
21
+ * - Literal match when pattern contains no wildcards
22
+ * - Array of patterns: returns true if ANY pattern matches (OR semantics)
23
+ *
24
+ * @param {string|string[]} pattern - The glob pattern(s) to match against
25
+ * @param {string} str - The string to test
26
+ * @returns {boolean} True if the string matches any of the patterns
27
+ */
28
+ export function matchGlob(pattern, str) {
29
+ if (Array.isArray(pattern)) {
30
+ return pattern.some((p) => matchGlob(p, str));
31
+ }
32
+
33
+ if (pattern === '*') {
34
+ return true;
35
+ }
36
+
37
+ if (typeof pattern !== 'string') {
38
+ return false;
39
+ }
40
+
41
+ if (!pattern.includes('*')) {
42
+ return pattern === str;
43
+ }
44
+
45
+ let regex = globRegexCache.get(pattern);
46
+ if (!regex) {
47
+ regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
48
+ globRegexCache.set(pattern, regex);
49
+ }
50
+ return regex.test(str);
51
+ }
@@ -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
 
@@ -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
 
@@ -267,6 +268,12 @@ export async function _hasSchema1Patches() {
267
268
  * Post-materialize GC check. Warn by default; execute only when enabled.
268
269
  * GC failure never breaks materialize.
269
270
  *
271
+ * Uses clone-then-swap pattern for snapshot isolation (B63):
272
+ * 1. Snapshot frontier fingerprint before GC
273
+ * 2. Clone state, run executeGC on clone
274
+ * 3. Compare frontier after GC — if changed, discard clone + mark dirty
275
+ * 4. If unchanged, swap compacted clone into _cachedState
276
+ *
270
277
  * @this {import('../WarpGraph.js').default}
271
278
  * @param {import('../services/JoinReducer.js').WarpStateV5} state
272
279
  * @private
@@ -287,8 +294,35 @@ export function _maybeRunGC(state) {
287
294
  }
288
295
 
289
296
  if (/** @type {import('../services/GCPolicy.js').GCPolicy} */ (this._gcPolicy).enabled) {
290
- const appliedVV = computeAppliedVV(state);
291
- const result = executeGC(state, appliedVV);
297
+ // Snapshot frontier before GC
298
+ const preGcFingerprint = this._lastFrontier
299
+ ? frontierFingerprint(this._lastFrontier)
300
+ : null;
301
+
302
+ // Clone state so executeGC doesn't mutate live state
303
+ const clonedState = cloneStateV5(state);
304
+ const appliedVV = computeAppliedVV(clonedState);
305
+ const result = executeGC(clonedState, appliedVV);
306
+
307
+ // Check if frontier changed during GC (concurrent write)
308
+ const postGcFingerprint = this._lastFrontier
309
+ ? frontierFingerprint(this._lastFrontier)
310
+ : null;
311
+
312
+ if (preGcFingerprint !== postGcFingerprint) {
313
+ // Frontier changed — discard compacted state, mark dirty
314
+ this._stateDirty = true;
315
+ if (this._logger) {
316
+ this._logger.warn(
317
+ 'Auto-GC discarded: frontier changed during compaction (concurrent write)',
318
+ { reasons, preGcFingerprint, postGcFingerprint },
319
+ );
320
+ }
321
+ return;
322
+ }
323
+
324
+ // Frontier unchanged — swap in compacted state
325
+ this._cachedState = clonedState;
292
326
  this._lastGCTime = this._clock.now();
293
327
  this._patchesSinceGC = 0;
294
328
  if (this._logger) {
@@ -348,11 +382,17 @@ export function maybeRunGC() {
348
382
  * Explicitly runs GC on the cached state.
349
383
  * Compacts tombstoned dots that are covered by the appliedVV.
350
384
  *
385
+ * Uses clone-then-swap pattern for snapshot isolation (B63):
386
+ * clones state, runs executeGC on clone, verifies frontier unchanged,
387
+ * then swaps in compacted clone. If frontier changed during GC,
388
+ * throws E_GC_STALE so the caller can retry after re-materializing.
389
+ *
351
390
  * **Requires a cached state.**
352
391
  *
353
392
  * @this {import('../WarpGraph.js').default}
354
393
  * @returns {{nodesCompacted: number, edgesCompacted: number, tombstonesRemoved: number, durationMs: number}}
355
394
  * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
395
+ * @throws {QueryError} If frontier changed during GC (code: `E_GC_STALE`)
356
396
  *
357
397
  * @example
358
398
  * await graph.materialize();
@@ -368,13 +408,30 @@ export function runGC() {
368
408
  });
369
409
  }
370
410
 
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);
411
+ // Snapshot frontier before GC
412
+ const preGcFingerprint = this._lastFrontier
413
+ ? frontierFingerprint(this._lastFrontier)
414
+ : null;
415
+
416
+ // Clone state so executeGC doesn't mutate live state until verified
417
+ const clonedState = cloneStateV5(this._cachedState);
418
+ const appliedVV = computeAppliedVV(clonedState);
419
+ const result = executeGC(clonedState, appliedVV);
420
+
421
+ // Verify frontier unchanged (concurrent write detection)
422
+ const postGcFingerprint = this._lastFrontier
423
+ ? frontierFingerprint(this._lastFrontier)
424
+ : null;
425
+
426
+ if (preGcFingerprint !== postGcFingerprint) {
427
+ throw new QueryError(
428
+ 'GC aborted: frontier changed during compaction (concurrent write detected)',
429
+ { code: 'E_GC_STALE' },
430
+ );
431
+ }
376
432
 
377
- // Update GC tracking
433
+ // Frontier unchanged — swap in compacted state
434
+ this._cachedState = clonedState;
378
435
  this._lastGCTime = this._clock.now();
379
436
  this._patchesSinceGC = 0;
380
437
 
@@ -242,6 +242,9 @@ export async function materialize(options) {
242
242
  * @private
243
243
  */
244
244
  export async function _materializeGraph() {
245
+ if (!this._stateDirty && this._materializedGraph) {
246
+ return this._materializedGraph;
247
+ }
245
248
  const state = await this.materialize();
246
249
  if (!this._materializedGraph || this._materializedGraph.state !== state) {
247
250
  await this._setMaterializedState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
@@ -167,6 +167,7 @@ export function _buildView(state, stateHash, diff) {
167
167
  this._propertyReader = result.propertyReader;
168
168
  this._cachedViewHash = stateHash;
169
169
  this._cachedIndexTree = result.tree;
170
+ this._indexDegraded = false;
170
171
 
171
172
  const provider = new BitmapNeighborProvider({ logicalIndex: result.logicalIndex });
172
173
  if (this._materializedGraph) {
@@ -176,6 +177,7 @@ export function _buildView(state, stateHash, diff) {
176
177
  this._logger?.warn('[warp] index build failed, falling back to linear scan', {
177
178
  error: /** @type {Error} */ (err).message,
178
179
  });
180
+ this._indexDegraded = true;
179
181
  this._logicalIndex = null;
180
182
  this._propertyReader = null;
181
183
  this._cachedIndexTree = null;