@git-stunts/git-warp 12.1.0 → 12.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/bin/cli/commands/trust.js +37 -1
- package/bin/cli/infrastructure.js +14 -1
- package/bin/cli/schemas.js +4 -4
- package/bin/warp-graph.js +9 -2
- package/index.d.ts +18 -2
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +4 -1
- package/src/domain/crdt/Dot.js +5 -0
- package/src/domain/crdt/LWW.js +3 -1
- package/src/domain/crdt/ORSet.js +63 -27
- package/src/domain/crdt/VersionVector.js +12 -0
- package/src/domain/errors/PatchError.js +27 -0
- package/src/domain/errors/StorageError.js +8 -0
- package/src/domain/errors/SyncError.js +1 -0
- package/src/domain/errors/TrustError.js +2 -0
- package/src/domain/errors/WriterError.js +5 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditVerifierService.js +32 -2
- package/src/domain/services/BitmapIndexBuilder.js +14 -9
- package/src/domain/services/CheckpointService.js +12 -8
- package/src/domain/services/Frontier.js +18 -0
- package/src/domain/services/GCPolicy.js +25 -4
- package/src/domain/services/GraphTraversal.js +11 -50
- package/src/domain/services/HttpSyncServer.js +18 -29
- package/src/domain/services/IncrementalIndexUpdater.js +179 -36
- package/src/domain/services/JoinReducer.js +164 -31
- package/src/domain/services/MaterializedViewService.js +13 -2
- package/src/domain/services/PatchBuilderV2.js +210 -145
- package/src/domain/services/QueryBuilder.js +67 -30
- package/src/domain/services/SyncController.js +62 -18
- package/src/domain/services/SyncPayloadSchema.js +236 -0
- package/src/domain/services/SyncProtocol.js +102 -40
- package/src/domain/services/SyncTrustGate.js +146 -0
- package/src/domain/services/TranslationCost.js +2 -2
- package/src/domain/trust/TrustRecordService.js +161 -34
- package/src/domain/utils/CachedValue.js +34 -5
- package/src/domain/utils/EventId.js +4 -1
- package/src/domain/utils/LRUCache.js +3 -1
- package/src/domain/utils/RefLayout.js +4 -0
- package/src/domain/utils/canonicalStringify.js +48 -18
- package/src/domain/utils/matchGlob.js +7 -0
- package/src/domain/warp/PatchSession.js +30 -24
- package/src/domain/warp/Writer.js +12 -5
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/checkpoint.methods.js +102 -16
- package/src/domain/warp/materialize.methods.js +47 -5
- package/src/domain/warp/materializeAdvanced.methods.js +52 -10
- package/src/domain/warp/patch.methods.js +24 -8
- package/src/domain/warp/query.methods.js +4 -4
- package/src/domain/warp/subscribe.methods.js +11 -19
- package/src/infrastructure/adapters/GitGraphAdapter.js +57 -54
- package/src/infrastructure/codecs/CborCodec.js +2 -0
- package/src/domain/utils/fnv1a.js +0 -20
|
@@ -37,8 +37,10 @@
|
|
|
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
|
-
import { join, cloneStateV5 } from './JoinReducer.js';
|
|
42
|
+
import { join, cloneStateV5, isKnownOp } from './JoinReducer.js';
|
|
43
|
+
import SchemaUnsupportedError from '../errors/SchemaUnsupportedError.js';
|
|
42
44
|
import { cloneFrontier, updateFrontier } from './Frontier.js';
|
|
43
45
|
import { vvDeserialize } from '../crdt/VersionVector.js';
|
|
44
46
|
|
|
@@ -80,6 +82,33 @@ function normalizePatch(patch) {
|
|
|
80
82
|
return patch;
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Converts a frontier Map to a plain object for JSON serialization.
|
|
87
|
+
*
|
|
88
|
+
* @param {Map<string, string>} map - Frontier as Map<writerId, sha>
|
|
89
|
+
* @returns {{ [x: string]: string }} Plain object representation
|
|
90
|
+
* @private
|
|
91
|
+
*/
|
|
92
|
+
function frontierToObject(map) {
|
|
93
|
+
/** @type {{ [x: string]: string }} */
|
|
94
|
+
const obj = {};
|
|
95
|
+
for (const [writerId, sha] of map) {
|
|
96
|
+
obj[writerId] = sha;
|
|
97
|
+
}
|
|
98
|
+
return obj;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Converts a frontier plain object back to a Map.
|
|
103
|
+
*
|
|
104
|
+
* @param {{ [x: string]: string }} obj - Frontier as plain object
|
|
105
|
+
* @returns {Map<string, string>} Frontier as Map<writerId, sha>
|
|
106
|
+
* @private
|
|
107
|
+
*/
|
|
108
|
+
function objectToFrontier(obj) {
|
|
109
|
+
return new Map(Object.entries(obj));
|
|
110
|
+
}
|
|
111
|
+
|
|
83
112
|
/**
|
|
84
113
|
* Loads a patch from a commit.
|
|
85
114
|
*
|
|
@@ -251,7 +280,8 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
|
|
|
251
280
|
newWritersForLocal.push(writerId);
|
|
252
281
|
} else if (localSha !== remoteSha) {
|
|
253
282
|
// Different heads - local needs patches from its head to remote head
|
|
254
|
-
//
|
|
283
|
+
// Direction is intentionally deferred: ancestry is verified by
|
|
284
|
+
// isAncestor() pre-check or loadPatchRange() in processSyncRequest()
|
|
255
285
|
needFromRemote.set(writerId, { from: localSha, to: remoteSha });
|
|
256
286
|
}
|
|
257
287
|
// If localSha === remoteSha, already in sync for this writer
|
|
@@ -267,11 +297,9 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
|
|
|
267
297
|
newWritersForRemote.push(writerId);
|
|
268
298
|
} else if (remoteSha !== localSha) {
|
|
269
299
|
// Different heads - remote might need patches from its head to local head
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
needFromLocal.set(writerId, { from: remoteSha, to: localSha });
|
|
274
|
-
}
|
|
300
|
+
// Always add both directions — ancestry is verified during loadPatchRange()
|
|
301
|
+
// which will throw E_SYNC_DIVERGENCE if neither side descends from the other (S3)
|
|
302
|
+
needFromLocal.set(writerId, { from: remoteSha, to: localSha });
|
|
275
303
|
}
|
|
276
304
|
}
|
|
277
305
|
|
|
@@ -315,6 +343,8 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
|
|
|
315
343
|
* - `writerId`: The writer who created this patch
|
|
316
344
|
* - `sha`: The commit SHA this patch came from (for frontier updates)
|
|
317
345
|
* - `patch`: The decoded patch object with ops and context
|
|
346
|
+
* @property {Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>} [skippedWriters] - Writers that were skipped during sync
|
|
347
|
+
* (e.g. due to trust gate filtering, divergence, or missing refs)
|
|
318
348
|
*/
|
|
319
349
|
|
|
320
350
|
/**
|
|
@@ -338,16 +368,9 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
|
|
|
338
368
|
* // Send over HTTP: await fetch(url, { body: JSON.stringify(request) })
|
|
339
369
|
*/
|
|
340
370
|
export function createSyncRequest(frontier) {
|
|
341
|
-
// Convert Map to plain object for serialization
|
|
342
|
-
/** @type {{ [x: string]: string }} */
|
|
343
|
-
const frontierObj = {};
|
|
344
|
-
for (const [writerId, sha] of frontier) {
|
|
345
|
-
frontierObj[writerId] = sha;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
371
|
return {
|
|
349
372
|
type: /** @type {'sync-request'} */ ('sync-request'),
|
|
350
|
-
frontier:
|
|
373
|
+
frontier: frontierToObject(frontier),
|
|
351
374
|
};
|
|
352
375
|
}
|
|
353
376
|
|
|
@@ -375,6 +398,7 @@ export function createSyncRequest(frontier) {
|
|
|
375
398
|
* @param {string} graphName - Graph name for error messages and logging
|
|
376
399
|
* @param {Object} [options]
|
|
377
400
|
* @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization
|
|
401
|
+
* @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for divergence warnings
|
|
378
402
|
* @returns {Promise<SyncResponse>} Response containing local frontier and patches.
|
|
379
403
|
* Patches are ordered chronologically within each writer.
|
|
380
404
|
* @throws {Error} If patch loading fails for reasons other than divergence
|
|
@@ -388,18 +412,44 @@ export function createSyncRequest(frontier) {
|
|
|
388
412
|
* res.json(response);
|
|
389
413
|
* });
|
|
390
414
|
*/
|
|
391
|
-
export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = /** @type {{ codec?: import('../../ports/CodecPort.js').default }} */ ({})) {
|
|
392
|
-
|
|
393
|
-
|
|
415
|
+
export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec, logger } = /** @type {{ codec?: import('../../ports/CodecPort.js').default, logger?: import('../../ports/LoggerPort.js').default }} */ ({})) {
|
|
416
|
+
const log = logger || nullLogger;
|
|
417
|
+
|
|
418
|
+
const remoteFrontier = objectToFrontier(request.frontier);
|
|
394
419
|
|
|
395
420
|
// Compute what the requester needs
|
|
396
421
|
const delta = computeSyncDelta(remoteFrontier, localFrontier);
|
|
397
422
|
|
|
398
423
|
// Load patches that the requester needs (from local to requester)
|
|
399
424
|
const patches = [];
|
|
425
|
+
/** @type {Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>} */
|
|
426
|
+
const skippedWriters = [];
|
|
400
427
|
|
|
401
428
|
for (const [writerId, range] of delta.needFromRemote) {
|
|
402
429
|
try {
|
|
430
|
+
// Pre-check ancestry to avoid expensive chain walk (B107 / S3 fix).
|
|
431
|
+
// If the persistence layer provides isAncestor, use it to detect
|
|
432
|
+
// divergence early without walking the full commit chain.
|
|
433
|
+
const hasIsAncestor = typeof /** @type {{isAncestor?: (...args: unknown[]) => unknown}} */ (persistence).isAncestor === 'function';
|
|
434
|
+
if (range.from && hasIsAncestor) {
|
|
435
|
+
const isAnc = await /** @type {{isAncestor: (a: string, b: string) => Promise<boolean>}} */ (/** @type {unknown} */ (persistence)).isAncestor(range.from, range.to);
|
|
436
|
+
if (!isAnc) {
|
|
437
|
+
const entry = {
|
|
438
|
+
writerId,
|
|
439
|
+
reason: 'E_SYNC_DIVERGENCE',
|
|
440
|
+
localSha: range.to,
|
|
441
|
+
remoteSha: range.from,
|
|
442
|
+
};
|
|
443
|
+
skippedWriters.push(entry);
|
|
444
|
+
log.warn('Sync divergence detected — skipping writer', {
|
|
445
|
+
code: 'E_SYNC_DIVERGENCE',
|
|
446
|
+
graphName,
|
|
447
|
+
...entry,
|
|
448
|
+
});
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
403
453
|
const writerPatches = await loadPatchRange(
|
|
404
454
|
persistence,
|
|
405
455
|
graphName,
|
|
@@ -413,26 +463,32 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
|
|
|
413
463
|
patches.push({ writerId, sha, patch });
|
|
414
464
|
}
|
|
415
465
|
} catch (err) {
|
|
416
|
-
// If we detect divergence, skip this writer
|
|
417
|
-
// The requester
|
|
466
|
+
// If we detect divergence, log and skip this writer (B65).
|
|
467
|
+
// The requester will not receive patches for this writer.
|
|
418
468
|
if ((err instanceof Error && 'code' in err && /** @type {{ code: string }} */ (err).code === 'E_SYNC_DIVERGENCE') || (err instanceof Error && err.message?.includes('Divergence detected'))) {
|
|
469
|
+
const entry = {
|
|
470
|
+
writerId,
|
|
471
|
+
reason: 'E_SYNC_DIVERGENCE',
|
|
472
|
+
localSha: range.to,
|
|
473
|
+
remoteSha: range.from ?? '',
|
|
474
|
+
};
|
|
475
|
+
skippedWriters.push(entry);
|
|
476
|
+
log.warn('Sync divergence detected — skipping writer', {
|
|
477
|
+
code: 'E_SYNC_DIVERGENCE',
|
|
478
|
+
graphName,
|
|
479
|
+
...entry,
|
|
480
|
+
});
|
|
419
481
|
continue;
|
|
420
482
|
}
|
|
421
483
|
throw err;
|
|
422
484
|
}
|
|
423
485
|
}
|
|
424
486
|
|
|
425
|
-
// Convert local frontier to plain object
|
|
426
|
-
/** @type {{ [x: string]: string }} */
|
|
427
|
-
const frontierObj = {};
|
|
428
|
-
for (const [writerId, sha] of localFrontier) {
|
|
429
|
-
frontierObj[writerId] = sha;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
487
|
return {
|
|
433
488
|
type: /** @type {'sync-response'} */ ('sync-response'),
|
|
434
|
-
frontier:
|
|
489
|
+
frontier: frontierToObject(localFrontier),
|
|
435
490
|
patches,
|
|
491
|
+
skippedWriters,
|
|
436
492
|
};
|
|
437
493
|
}
|
|
438
494
|
|
|
@@ -484,7 +540,10 @@ export function applySyncResponse(response, state, frontier) {
|
|
|
484
540
|
const newFrontier = cloneFrontier(frontier);
|
|
485
541
|
let applied = 0;
|
|
486
542
|
|
|
487
|
-
//
|
|
543
|
+
// Patches arrive pre-grouped by writer from the sync response. This
|
|
544
|
+
// re-grouping is defensive — it handles edge cases where patches from
|
|
545
|
+
// multiple writers arrive interleaved (e.g., from a relay that merges
|
|
546
|
+
// streams).
|
|
488
547
|
const patchesByWriter = new Map();
|
|
489
548
|
for (const { writerId, sha, patch } of response.patches) {
|
|
490
549
|
if (!patchesByWriter.has(writerId)) {
|
|
@@ -499,10 +558,19 @@ export function applySyncResponse(response, state, frontier) {
|
|
|
499
558
|
for (const { sha, patch } of writerPatches) {
|
|
500
559
|
// Normalize patch context (in case it came from network serialization)
|
|
501
560
|
const normalizedPatch = normalizePatch(patch);
|
|
502
|
-
// Guard: reject patches
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
561
|
+
// Guard: reject patches with genuinely unknown op types (B106 / C2 fix).
|
|
562
|
+
// This prevents silent data loss when a newer writer sends ops we
|
|
563
|
+
// don't recognise — fail closed rather than silently ignoring.
|
|
564
|
+
for (const op of normalizedPatch.ops) {
|
|
565
|
+
if (!isKnownOp(op)) {
|
|
566
|
+
throw new SchemaUnsupportedError(
|
|
567
|
+
`Patch ${sha} contains unknown op type: ${op.type}`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Guard: reject patches exceeding our maximum supported schema version.
|
|
572
|
+
// isKnownOp() above checks op-type recognition; this checks the schema
|
|
573
|
+
// version ceiling. Currently SCHEMA_V3 is the max.
|
|
506
574
|
assertOpsCompatible(normalizedPatch.ops, SCHEMA_V3);
|
|
507
575
|
// Apply patch to state
|
|
508
576
|
join(newState, /** @type {Parameters<typeof join>[1]} */ (normalizedPatch), sha);
|
|
@@ -590,15 +658,9 @@ export function syncNeeded(localFrontier, remoteFrontier) {
|
|
|
590
658
|
* }
|
|
591
659
|
*/
|
|
592
660
|
export function createEmptySyncResponse(frontier) {
|
|
593
|
-
/** @type {{ [x: string]: string }} */
|
|
594
|
-
const frontierObj = {};
|
|
595
|
-
for (const [writerId, sha] of frontier) {
|
|
596
|
-
frontierObj[writerId] = sha;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
661
|
return {
|
|
600
662
|
type: /** @type {'sync-response'} */ ('sync-response'),
|
|
601
|
-
frontier:
|
|
663
|
+
frontier: frontierToObject(frontier),
|
|
602
664
|
patches: [],
|
|
603
665
|
};
|
|
604
666
|
}
|
|
@@ -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
|
+
}
|
|
@@ -182,10 +182,10 @@ function computePropLoss(state, { nodesA, nodesBSet, configA, configB }) {
|
|
|
182
182
|
*/
|
|
183
183
|
export function computeTranslationCost(configA, configB, state) {
|
|
184
184
|
/** @param {unknown} m */
|
|
185
|
-
const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
|
|
185
|
+
const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.length > 0 && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
|
|
186
186
|
if (!configA || !isValidMatch(configA.match) ||
|
|
187
187
|
!configB || !isValidMatch(configB.match)) {
|
|
188
|
-
throw new Error('configA.match and configB.match must be strings or arrays of strings');
|
|
188
|
+
throw new Error('configA.match and configB.match must be non-empty strings or non-empty arrays of strings');
|
|
189
189
|
}
|
|
190
190
|
const allNodes = [...orsetElements(state.nodeAlive)];
|
|
191
191
|
const nodesA = allNodes.filter((id) => matchGlob(configA.match, id));
|
|
@@ -14,11 +14,22 @@ 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)
|
|
20
27
|
*/
|
|
21
28
|
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{ok: true, records: Array<Record<string, unknown>>} | {ok: false, error: Error}} ReadRecordsResult
|
|
31
|
+
*/
|
|
32
|
+
|
|
22
33
|
export class TrustRecordService {
|
|
23
34
|
/**
|
|
24
35
|
* @param {Object} options
|
|
@@ -95,48 +106,65 @@ export class TrustRecordService {
|
|
|
95
106
|
* @param {string} graphName
|
|
96
107
|
* @param {Object} [options]
|
|
97
108
|
* @param {string} [options.tip] - Override tip commit (for pinned reads)
|
|
98
|
-
* @returns {Promise<
|
|
109
|
+
* @returns {Promise<ReadRecordsResult>}
|
|
99
110
|
*/
|
|
100
111
|
async readRecords(graphName, options = {}) {
|
|
101
112
|
const ref = buildTrustRecordRef(graphName);
|
|
102
113
|
let tip = options.tip ?? null;
|
|
103
114
|
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
tip = await this._persistence.readRef(ref);
|
|
107
|
-
} catch {
|
|
108
|
-
return [];
|
|
109
|
-
}
|
|
115
|
+
try {
|
|
110
116
|
if (!tip) {
|
|
111
|
-
|
|
117
|
+
try {
|
|
118
|
+
tip = await this._persistence.readRef(ref);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Distinguish "ref not found" from operational error (J15)
|
|
121
|
+
if (err instanceof Error && (err.message?.includes('not found') || err.message?.includes('does not exist'))) {
|
|
122
|
+
return { ok: true, records: [] };
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: new TrustError(
|
|
127
|
+
`Failed to read trust chain ref: ${err instanceof Error ? err.message : String(err)}`,
|
|
128
|
+
{ code: 'E_TRUST_READ_FAILED' },
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (!tip) {
|
|
133
|
+
return { ok: true, records: [] };
|
|
134
|
+
}
|
|
112
135
|
}
|
|
113
|
-
}
|
|
114
136
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
137
|
+
const records = [];
|
|
138
|
+
let current = tip;
|
|
139
|
+
|
|
140
|
+
while (current) {
|
|
141
|
+
const info = await this._persistence.getNodeInfo(current);
|
|
142
|
+
const entries = await this._persistence.readTreeOids(
|
|
143
|
+
await this._persistence.getCommitTree(current),
|
|
144
|
+
);
|
|
145
|
+
const blobOid = entries['record.cbor'];
|
|
146
|
+
if (!blobOid) {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
const record = /** @type {Record<string, unknown>} */ (this._codec.decode(
|
|
150
|
+
await this._persistence.readBlob(blobOid),
|
|
151
|
+
));
|
|
130
152
|
|
|
131
|
-
|
|
153
|
+
records.unshift(record);
|
|
132
154
|
|
|
133
|
-
|
|
134
|
-
|
|
155
|
+
if (info.parents.length === 0) {
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
current = info.parents[0];
|
|
135
159
|
}
|
|
136
|
-
current = info.parents[0];
|
|
137
|
-
}
|
|
138
160
|
|
|
139
|
-
|
|
161
|
+
return { ok: true, records };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
140
168
|
}
|
|
141
169
|
|
|
142
170
|
/**
|
|
@@ -196,6 +224,62 @@ export class TrustRecordService {
|
|
|
196
224
|
return { valid: errors.length === 0, errors };
|
|
197
225
|
}
|
|
198
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Appends a trust record with automatic retry on CAS conflict.
|
|
229
|
+
*
|
|
230
|
+
* On E_TRUST_CAS_CONFLICT, re-reads the chain tip, rebuilds the record
|
|
231
|
+
* with the new prev pointer, re-signs if a signer is provided, and
|
|
232
|
+
* retries. This is the higher-level API callers should use when they
|
|
233
|
+
* want automatic convergence under concurrent appenders.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} graphName
|
|
236
|
+
* @param {Record<string, unknown>} record - Complete signed trust record
|
|
237
|
+
* @param {Object} [options]
|
|
238
|
+
* @param {number} [options.maxRetries=3] - Maximum rebuild-and-retry attempts
|
|
239
|
+
* @param {((record: Record<string, unknown>) => Promise<Record<string, unknown>>)|null} [options.resign] - Function to re-sign a rebuilt record (null for unsigned)
|
|
240
|
+
* @param {boolean} [options.skipSignatureVerify=false] - Skip signature verification
|
|
241
|
+
* @returns {Promise<{commitSha: string, ref: string, attempts: number}>}
|
|
242
|
+
* @throws {TrustError} E_TRUST_CAS_EXHAUSTED if all retries fail
|
|
243
|
+
*/
|
|
244
|
+
async appendRecordWithRetry(graphName, record, options = {}) {
|
|
245
|
+
const { maxRetries = 3, resign = null, skipSignatureVerify = false } = options;
|
|
246
|
+
let currentRecord = record;
|
|
247
|
+
let attempts = 0;
|
|
248
|
+
|
|
249
|
+
for (let i = 0; i <= maxRetries; i++) {
|
|
250
|
+
attempts++;
|
|
251
|
+
try {
|
|
252
|
+
const result = await this.appendRecord(graphName, currentRecord, { skipSignatureVerify });
|
|
253
|
+
return { ...result, attempts };
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (!(err instanceof TrustError) || err.code !== 'E_TRUST_CAS_CONFLICT') {
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (i === maxRetries) {
|
|
260
|
+
throw new TrustError(
|
|
261
|
+
`Trust CAS exhausted after ${attempts} attempts (with retry)`,
|
|
262
|
+
{ code: 'E_TRUST_CAS_EXHAUSTED' },
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Rebuild: re-read chain tip, update prev pointer
|
|
267
|
+
const freshTipRecordId = err.context?.actualTipRecordId ?? null;
|
|
268
|
+
|
|
269
|
+
// Update prev to the new chain tip's recordId
|
|
270
|
+
currentRecord = { ...currentRecord, prev: freshTipRecordId };
|
|
271
|
+
|
|
272
|
+
// Re-sign if signer is provided
|
|
273
|
+
if (resign) {
|
|
274
|
+
currentRecord = await resign(currentRecord);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Unreachable
|
|
280
|
+
throw new TrustError('Trust CAS failed', { code: 'E_TRUST_CAS_EXHAUSTED' });
|
|
281
|
+
}
|
|
282
|
+
|
|
199
283
|
/**
|
|
200
284
|
* Validates that a record's signature envelope is structurally complete.
|
|
201
285
|
*
|
|
@@ -246,7 +330,15 @@ export class TrustRecordService {
|
|
|
246
330
|
}
|
|
247
331
|
|
|
248
332
|
/**
|
|
249
|
-
* Persists a trust record as a Git commit.
|
|
333
|
+
* Persists a trust record as a Git commit with CAS retry.
|
|
334
|
+
*
|
|
335
|
+
* On transient CAS failures (ref unchanged, e.g. lock contention), retries
|
|
336
|
+
* up to MAX_CAS_ATTEMPTS total. On real concurrent appends (ref advanced),
|
|
337
|
+
* throws E_TRUST_CAS_CONFLICT so the caller can rebuild + re-sign the record.
|
|
338
|
+
*
|
|
339
|
+
* The record's prev, recordId, and signature form a cryptographic chain.
|
|
340
|
+
* Only the original signer can rebuild, so we never silently rebase.
|
|
341
|
+
*
|
|
250
342
|
* @param {string} ref
|
|
251
343
|
* @param {Record<string, unknown>} record
|
|
252
344
|
* @param {string|null} parentSha - Resolved tip SHA (null for genesis)
|
|
@@ -273,9 +365,44 @@ export class TrustRecordService {
|
|
|
273
365
|
message,
|
|
274
366
|
});
|
|
275
367
|
|
|
276
|
-
// CAS update ref
|
|
277
|
-
|
|
368
|
+
// CAS update ref with retry for transient failures
|
|
369
|
+
for (let attempt = 1; attempt <= MAX_CAS_ATTEMPTS; attempt++) {
|
|
370
|
+
try {
|
|
371
|
+
await this._persistence.compareAndSwapRef(ref, commitSha, parentSha);
|
|
372
|
+
return commitSha;
|
|
373
|
+
} catch {
|
|
374
|
+
// Read fresh tip to distinguish transient vs real conflict
|
|
375
|
+
const { tipSha: freshTipSha, recordId: freshRecordId } = await this._readTip(ref);
|
|
376
|
+
|
|
377
|
+
if (freshTipSha === parentSha) {
|
|
378
|
+
// Ref unchanged — transient failure (lock contention, I/O race).
|
|
379
|
+
// Retry the same CAS with same commit.
|
|
380
|
+
if (attempt === MAX_CAS_ATTEMPTS) {
|
|
381
|
+
throw new TrustError(
|
|
382
|
+
`Trust CAS exhausted after ${MAX_CAS_ATTEMPTS} attempts`,
|
|
383
|
+
{ code: 'E_TRUST_CAS_EXHAUSTED' },
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Ref changed — real concurrent append. Our record's prev no longer
|
|
390
|
+
// matches the chain tip. The caller must rebuild, re-sign, and retry.
|
|
391
|
+
throw new TrustError(
|
|
392
|
+
`Trust CAS conflict: chain advanced from ${parentSha} to ${freshTipSha}`,
|
|
393
|
+
{
|
|
394
|
+
code: 'E_TRUST_CAS_CONFLICT',
|
|
395
|
+
context: {
|
|
396
|
+
expectedTipSha: parentSha,
|
|
397
|
+
actualTipSha: freshTipSha,
|
|
398
|
+
actualTipRecordId: freshRecordId,
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
278
404
|
|
|
279
|
-
|
|
405
|
+
// Unreachable, but satisfies type checker
|
|
406
|
+
throw new TrustError('Trust CAS failed', { code: 'E_TRUST_CAS_EXHAUSTED' });
|
|
280
407
|
}
|
|
281
408
|
}
|