@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.
- package/README.md +5 -3
- package/bin/warp-graph.js +6 -2
- package/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +3 -0
- package/src/domain/crdt/ORSet.js +33 -4
- package/src/domain/errors/SyncError.js +1 -0
- package/src/domain/errors/TrustError.js +2 -0
- package/src/domain/services/CheckpointService.js +2 -7
- package/src/domain/services/Frontier.js +18 -0
- package/src/domain/services/GraphTraversal.js +8 -49
- package/src/domain/services/HttpSyncServer.js +18 -29
- package/src/domain/services/JoinReducer.js +23 -0
- package/src/domain/services/PatchBuilderV2.js +29 -3
- package/src/domain/services/QueryBuilder.js +63 -30
- package/src/domain/services/SyncController.js +74 -11
- package/src/domain/services/SyncPayloadSchema.js +236 -0
- package/src/domain/services/SyncProtocol.js +27 -8
- package/src/domain/services/SyncTrustGate.js +146 -0
- package/src/domain/services/TranslationCost.js +2 -2
- package/src/domain/trust/TrustRecordService.js +119 -6
- package/src/domain/warp/Writer.js +7 -5
- package/src/domain/warp/checkpoint.methods.js +66 -9
- package/src/domain/warp/materialize.methods.js +3 -0
- package/src/domain/warp/materializeAdvanced.methods.js +2 -0
- package/src/domain/warp/patch.methods.js +8 -0
- package/src/domain/warp/query.methods.js +4 -4
- package/src/domain/warp/subscribe.methods.js +11 -19
- package/src/infrastructure/adapters/GitGraphAdapter.js +2 -2
|
@@ -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
|
-
|
|
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
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
291
|
-
const
|
|
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
|
-
//
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
146
|
-
// Pre-compile pattern matcher once for performance
|
|
147
|
+
// Pattern matching logic
|
|
147
148
|
/** @type {(nodeId: string) => boolean} */
|
|
148
|
-
|
|
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(
|
|
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
|
}
|