@git-stunts/git-warp 12.1.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.
@@ -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
  }
@@ -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;
@@ -530,6 +530,14 @@ export function join(otherState) {
530
530
  // Update cached state
531
531
  this._cachedState = mergedState;
532
532
 
533
+ // Invalidate derived caches (C1) — join changes underlying state
534
+ this._materializedGraph = null;
535
+ this._logicalIndex = null;
536
+ this._propertyReader = null;
537
+ this._cachedViewHash = null;
538
+ this._cachedIndexTree = null;
539
+ this._stateDirty = true;
540
+
533
541
  return { state: mergedState, receipt };
534
542
  }
535
543
 
@@ -319,9 +319,9 @@ export function query() {
319
319
  */
320
320
  export async function observer(name, config) {
321
321
  /** @param {unknown} m */
322
- const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
322
+ const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.length > 0 && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
323
323
  if (!config || !isValidMatch(config.match)) {
324
- throw new Error('observer config.match must be a string or array of strings');
324
+ throw new Error('observer config.match must be a non-empty string or non-empty array of strings');
325
325
  }
326
326
  await this._ensureFreshState();
327
327
  return new ObserverView({ name, config, graph: this });
@@ -332,11 +332,11 @@ export async function observer(name, config) {
332
332
  *
333
333
  * @this {import('../WarpGraph.js').default}
334
334
  * @param {Object} configA - Observer configuration for A
335
- * @param {string} configA.match - Glob pattern for visible nodes
335
+ * @param {string|string[]} configA.match - Glob pattern(s) for visible nodes
336
336
  * @param {string[]} [configA.expose] - Property keys to include
337
337
  * @param {string[]} [configA.redact] - Property keys to exclude
338
338
  * @param {Object} configB - Observer configuration for B
339
- * @param {string} configB.match - Glob pattern for visible nodes
339
+ * @param {string|string[]} configB.match - Glob pattern(s) for visible nodes
340
340
  * @param {string[]} [configB.expose] - Property keys to include
341
341
  * @param {string[]} [configB.redact] - Property keys to exclude
342
342
  * @returns {Promise<{cost: number, breakdown: {nodeLoss: number, edgeLoss: number, propLoss: number}}>}
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { diffStates, isEmptyDiff } from '../services/StateDiff.js';
9
+ import { matchGlob } from '../utils/matchGlob.js';
9
10
 
10
11
  /**
11
12
  * Subscribes to graph changes.
@@ -101,13 +102,13 @@ export function subscribe({ onChange, onError, replay = false }) {
101
102
  * be at least 1000ms.
102
103
  *
103
104
  * @this {import('../WarpGraph.js').default}
104
- * @param {string} pattern - Glob pattern (e.g., 'user:*', 'order:123', '*')
105
+ * @param {string|string[]} pattern - Glob pattern(s) (e.g., 'user:*', 'order:123', '*')
105
106
  * @param {Object} options - Watch options
106
107
  * @param {(diff: import('../services/StateDiff.js').StateDiffResult) => void} options.onChange - Called with filtered diff when matching changes occur
107
108
  * @param {(error: Error) => void} [options.onError] - Called if onChange throws an error
108
109
  * @param {number} [options.poll] - Poll interval in ms (min 1000); checks frontier and auto-materializes
109
110
  * @returns {{unsubscribe: () => void}} Subscription handle
110
- * @throws {Error} If pattern is not a string
111
+ * @throws {Error} If pattern is not a string or array of strings
111
112
  * @throws {Error} If onChange is not a function
112
113
  * @throws {Error} If poll is provided but less than 1000
113
114
  *
@@ -130,31 +131,22 @@ export function subscribe({ onChange, onError, replay = false }) {
130
131
  * unsubscribe();
131
132
  */
132
133
  export function watch(pattern, { onChange, onError, poll }) {
133
- if (typeof pattern !== 'string') {
134
- throw new Error('pattern must be a string');
134
+ const isValidPattern = (/** @type {string|string[]} */ p) => typeof p === 'string' || (Array.isArray(p) && p.length > 0 && p.every(i => typeof i === 'string'));
135
+ if (!isValidPattern(pattern)) {
136
+ throw new Error('pattern must be a non-empty string or non-empty array of strings');
135
137
  }
136
138
  if (typeof onChange !== 'function') {
137
139
  throw new Error('onChange must be a function');
138
140
  }
139
141
  if (poll !== undefined) {
140
- if (typeof poll !== 'number' || poll < 1000) {
141
- throw new Error('poll must be a number >= 1000');
142
+ if (typeof poll !== 'number' || !Number.isFinite(poll) || poll < 1000) {
143
+ throw new Error('poll must be a finite number >= 1000');
142
144
  }
143
145
  }
144
146
 
145
- // Pattern matching: same logic as QueryBuilder.match()
146
- // Pre-compile pattern matcher once for performance
147
+ // Pattern matching logic
147
148
  /** @type {(nodeId: string) => boolean} */
148
- let matchesPattern;
149
- if (pattern === '*') {
150
- matchesPattern = () => true;
151
- } else if (pattern.includes('*')) {
152
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
153
- const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
154
- matchesPattern = (/** @type {string} */ nodeId) => regex.test(nodeId);
155
- } else {
156
- matchesPattern = (/** @type {string} */ nodeId) => nodeId === pattern;
157
- }
149
+ const matchesPattern = (nodeId) => matchGlob(pattern, nodeId);
158
150
 
159
151
  // Filtered onChange that only passes matching changes
160
152
  const filteredOnChange = (/** @type {import('../services/StateDiff.js').StateDiffResult} */ diff) => {
@@ -194,7 +186,7 @@ export function watch(pattern, { onChange, onError, poll }) {
194
186
  /** @type {ReturnType<typeof setInterval>|null} */
195
187
  let pollIntervalId = null;
196
188
  let pollInFlight = false;
197
- if (poll) {
189
+ if (poll !== undefined) {
198
190
  pollIntervalId = setInterval(() => {
199
191
  if (pollInFlight) {
200
192
  return;
@@ -575,8 +575,8 @@ export default class GitGraphAdapter extends GraphPersistencePort {
575
575
  async compareAndSwapRef(ref, newOid, expectedOid) {
576
576
  this._validateRef(ref);
577
577
  this._validateOid(newOid);
578
- // null means "ref must not exist" → use zero OID
579
- const oldArg = expectedOid || '0'.repeat(newOid.length);
578
+ // null means "ref must not exist" → use zero OID (always 40 chars for SHA-1)
579
+ const oldArg = expectedOid || '0'.repeat(40);
580
580
  if (expectedOid) {
581
581
  this._validateOid(expectedOid);
582
582
  }