@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.
- package/README.md +6 -9
- package/bin/warp-graph.js +6 -2
- package/index.d.ts +4 -4
- package/package.json +2 -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/ObserverView.js +4 -32
- package/src/domain/services/PatchBuilderV2.js +29 -3
- package/src/domain/services/QueryBuilder.js +78 -74
- 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 +8 -24
- package/src/domain/trust/TrustRecordService.js +119 -6
- package/src/domain/utils/matchGlob.js +51 -0
- 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 +7 -5
- package/src/domain/warp/subscribe.methods.js +11 -19
- 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
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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;
|