@git-stunts/git-warp 12.2.0 → 12.3.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 +9 -6
- package/bin/cli/commands/trust.js +37 -1
- package/bin/cli/infrastructure.js +14 -1
- package/bin/cli/schemas.js +4 -4
- package/bin/presenters/text.js +10 -3
- package/bin/warp-graph.js +4 -1
- package/index.d.ts +17 -1
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +1 -1
- package/src/domain/crdt/Dot.js +5 -0
- package/src/domain/crdt/LWW.js +3 -1
- package/src/domain/crdt/ORSet.js +33 -23
- 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/WriterError.js +5 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditReceiptService.js +2 -1
- package/src/domain/services/AuditVerifierService.js +33 -2
- package/src/domain/services/BitmapIndexBuilder.js +14 -9
- package/src/domain/services/BoundaryTransitionRecord.js +1 -0
- package/src/domain/services/CheckpointMessageCodec.js +5 -0
- package/src/domain/services/CheckpointService.js +29 -2
- package/src/domain/services/GCPolicy.js +25 -4
- package/src/domain/services/GraphTraversal.js +3 -1
- package/src/domain/services/IncrementalIndexUpdater.js +179 -36
- package/src/domain/services/JoinReducer.js +311 -75
- package/src/domain/services/KeyCodec.js +48 -0
- package/src/domain/services/MaterializedViewService.js +14 -3
- package/src/domain/services/MessageSchemaDetector.js +35 -5
- package/src/domain/services/OpNormalizer.js +79 -0
- package/src/domain/services/PatchBuilderV2.js +240 -160
- package/src/domain/services/QueryBuilder.js +4 -0
- package/src/domain/services/SyncAuthService.js +3 -0
- package/src/domain/services/SyncController.js +12 -31
- package/src/domain/services/SyncProtocol.js +76 -32
- package/src/domain/services/WarpMessageCodec.js +2 -0
- package/src/domain/trust/TrustCrypto.js +8 -5
- package/src/domain/trust/TrustRecordService.js +50 -36
- package/src/domain/types/TickReceipt.js +6 -4
- package/src/domain/types/WarpTypesV2.js +77 -5
- 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/defaultClock.js +1 -0
- package/src/domain/utils/matchGlob.js +7 -0
- package/src/domain/warp/PatchSession.js +30 -24
- package/src/domain/warp/Writer.js +12 -1
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/checkpoint.methods.js +36 -7
- package/src/domain/warp/fork.methods.js +1 -1
- package/src/domain/warp/materialize.methods.js +44 -5
- package/src/domain/warp/materializeAdvanced.methods.js +50 -10
- package/src/domain/warp/patch.methods.js +21 -11
- package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
- package/src/infrastructure/codecs/CborCodec.js +2 -0
- package/src/domain/utils/fnv1a.js +0 -20
|
@@ -51,6 +51,7 @@ import SyncTrustGate from './SyncTrustGate.js';
|
|
|
51
51
|
* @property {number} _patchesSinceCheckpoint
|
|
52
52
|
* @property {(op: string, t0: number, opts?: {metrics?: string, error?: Error}) => void} _logTiming
|
|
53
53
|
* @property {(options?: Record<string, unknown>) => Promise<unknown>} materialize
|
|
54
|
+
* @property {(state: import('../services/JoinReducer.js').WarpStateV5) => Promise<unknown>} _setMaterializedState
|
|
54
55
|
* @property {() => Promise<string[]>} discoverWriters
|
|
55
56
|
*/
|
|
56
57
|
|
|
@@ -299,7 +300,7 @@ export default class SyncController {
|
|
|
299
300
|
* **Requires a cached state.**
|
|
300
301
|
*
|
|
301
302
|
* @param {import('./SyncProtocol.js').SyncResponse} response - The sync response
|
|
302
|
-
* @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number, trustVerdict?: string, writersApplied?: string[]}>} Result with updated state and frontier
|
|
303
|
+
* @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number, trustVerdict?: string, writersApplied?: string[], skippedWriters: Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>}>} Result with updated state and frontier
|
|
303
304
|
* @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
|
|
304
305
|
* @throws {SyncError} If trust gate rejects untrusted writers (code: `E_SYNC_UNTRUSTED_WRITER`)
|
|
305
306
|
*/
|
|
@@ -333,8 +334,13 @@ export default class SyncController {
|
|
|
333
334
|
const currentFrontier = this._host._lastFrontier || createFrontier();
|
|
334
335
|
const result = /** @type {{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} */ (applySyncResponseImpl(response, this._host._cachedState, currentFrontier));
|
|
335
336
|
|
|
336
|
-
//
|
|
337
|
-
|
|
337
|
+
// Route through canonical state-install path (B105 / C1 fix).
|
|
338
|
+
// _setMaterializedState sets _cachedState, clears _stateDirty, computes
|
|
339
|
+
// state hash, builds adjacency, and rebuilds indexes via _buildView().
|
|
340
|
+
// Bookkeeping is deferred until after install succeeds so that a failed
|
|
341
|
+
// _setMaterializedState does not leave _lastFrontier/_patchesSinceGC
|
|
342
|
+
// advanced while _cachedState remains stale.
|
|
343
|
+
await this._host._setMaterializedState(result.state);
|
|
338
344
|
|
|
339
345
|
// Keep _lastFrontier in sync so hasFrontierChanged() won't misreport stale.
|
|
340
346
|
this._host._lastFrontier = result.frontier;
|
|
@@ -342,32 +348,7 @@ export default class SyncController {
|
|
|
342
348
|
// Track patches for GC
|
|
343
349
|
this._host._patchesSinceGC += result.applied;
|
|
344
350
|
|
|
345
|
-
|
|
346
|
-
this._invalidateDerivedCaches();
|
|
347
|
-
|
|
348
|
-
// State is now in sync with the frontier -- clear dirty flag
|
|
349
|
-
this._host._stateDirty = false;
|
|
350
|
-
|
|
351
|
-
return { ...result, writersApplied };
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Invalidates all derived caches on the host graph.
|
|
356
|
-
*
|
|
357
|
-
* Called after sync apply or join to ensure stale index/provider/view
|
|
358
|
-
* data is not returned to callers. The next query or traversal will
|
|
359
|
-
* trigger a rebuild.
|
|
360
|
-
*
|
|
361
|
-
* @private
|
|
362
|
-
*/
|
|
363
|
-
_invalidateDerivedCaches() {
|
|
364
|
-
const h = /** @type {import('../WarpGraph.js').default} */ (this._host);
|
|
365
|
-
h._materializedGraph = null;
|
|
366
|
-
h._logicalIndex = null;
|
|
367
|
-
h._propertyReader = null;
|
|
368
|
-
h._cachedViewHash = null;
|
|
369
|
-
h._cachedIndexTree = null;
|
|
370
|
-
h._stateDirty = true;
|
|
351
|
+
return { ...result, writersApplied, skippedWriters: response.skippedWriters || [] };
|
|
371
352
|
}
|
|
372
353
|
|
|
373
354
|
/**
|
|
@@ -396,7 +377,7 @@ export default class SyncController {
|
|
|
396
377
|
* @param {(event: {type: string, attempt: number, durationMs?: number, status?: number, error?: Error}) => void} [options.onStatus]
|
|
397
378
|
* @param {boolean} [options.materialize=false] - Auto-materialize after sync
|
|
398
379
|
* @param {{ secret: string, keyId?: string }} [options.auth] - Client auth credentials
|
|
399
|
-
* @returns {Promise<{applied: number, attempts: number, state?: import('./JoinReducer.js').WarpStateV5}>}
|
|
380
|
+
* @returns {Promise<{applied: number, attempts: number, skippedWriters: Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>, state?: import('./JoinReducer.js').WarpStateV5}>}
|
|
400
381
|
*/
|
|
401
382
|
async syncWith(remote, options = {}) {
|
|
402
383
|
const t0 = this._host._clock.now();
|
|
@@ -550,7 +531,7 @@ export default class SyncController {
|
|
|
550
531
|
|
|
551
532
|
const durationMs = this._host._clock.now() - attemptStart;
|
|
552
533
|
emit('complete', { durationMs, applied: result.applied });
|
|
553
|
-
return { applied: result.applied, attempts: attempt };
|
|
534
|
+
return { applied: result.applied, attempts: attempt, skippedWriters: result.skippedWriters || [] };
|
|
554
535
|
};
|
|
555
536
|
|
|
556
537
|
try {
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
import defaultCodec from '../utils/defaultCodec.js';
|
|
40
40
|
import nullLogger from '../utils/nullLogger.js';
|
|
41
41
|
import { decodePatchMessage, assertOpsCompatible, SCHEMA_V3 } from './WarpMessageCodec.js';
|
|
42
|
-
import { join, cloneStateV5 } from './JoinReducer.js';
|
|
42
|
+
import { join, cloneStateV5, isKnownRawOp } from './JoinReducer.js';
|
|
43
|
+
import SchemaUnsupportedError from '../errors/SchemaUnsupportedError.js';
|
|
43
44
|
import { cloneFrontier, updateFrontier } from './Frontier.js';
|
|
44
45
|
import { vvDeserialize } from '../crdt/VersionVector.js';
|
|
45
46
|
|
|
@@ -81,6 +82,33 @@ function normalizePatch(patch) {
|
|
|
81
82
|
return patch;
|
|
82
83
|
}
|
|
83
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
|
+
|
|
84
112
|
/**
|
|
85
113
|
* Loads a patch from a commit.
|
|
86
114
|
*
|
|
@@ -252,7 +280,8 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
|
|
|
252
280
|
newWritersForLocal.push(writerId);
|
|
253
281
|
} else if (localSha !== remoteSha) {
|
|
254
282
|
// Different heads - local needs patches from its head to remote head
|
|
255
|
-
//
|
|
283
|
+
// Direction is intentionally deferred: ancestry is verified by
|
|
284
|
+
// isAncestor() pre-check or loadPatchRange() in processSyncRequest()
|
|
256
285
|
needFromRemote.set(writerId, { from: localSha, to: remoteSha });
|
|
257
286
|
}
|
|
258
287
|
// If localSha === remoteSha, already in sync for this writer
|
|
@@ -339,16 +368,9 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
|
|
|
339
368
|
* // Send over HTTP: await fetch(url, { body: JSON.stringify(request) })
|
|
340
369
|
*/
|
|
341
370
|
export function createSyncRequest(frontier) {
|
|
342
|
-
// Convert Map to plain object for serialization
|
|
343
|
-
/** @type {{ [x: string]: string }} */
|
|
344
|
-
const frontierObj = {};
|
|
345
|
-
for (const [writerId, sha] of frontier) {
|
|
346
|
-
frontierObj[writerId] = sha;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
371
|
return {
|
|
350
372
|
type: /** @type {'sync-request'} */ ('sync-request'),
|
|
351
|
-
frontier:
|
|
373
|
+
frontier: frontierToObject(frontier),
|
|
352
374
|
};
|
|
353
375
|
}
|
|
354
376
|
|
|
@@ -393,8 +415,7 @@ export function createSyncRequest(frontier) {
|
|
|
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 }} */ ({})) {
|
|
394
416
|
const log = logger || nullLogger;
|
|
395
417
|
|
|
396
|
-
|
|
397
|
-
const remoteFrontier = new Map(Object.entries(request.frontier));
|
|
418
|
+
const remoteFrontier = objectToFrontier(request.frontier);
|
|
398
419
|
|
|
399
420
|
// Compute what the requester needs
|
|
400
421
|
const delta = computeSyncDelta(remoteFrontier, localFrontier);
|
|
@@ -406,6 +427,29 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
|
|
|
406
427
|
|
|
407
428
|
for (const [writerId, range] of delta.needFromRemote) {
|
|
408
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
|
+
|
|
409
453
|
const writerPatches = await loadPatchRange(
|
|
410
454
|
persistence,
|
|
411
455
|
graphName,
|
|
@@ -440,16 +484,9 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
|
|
|
440
484
|
}
|
|
441
485
|
}
|
|
442
486
|
|
|
443
|
-
// Convert local frontier to plain object
|
|
444
|
-
/** @type {{ [x: string]: string }} */
|
|
445
|
-
const frontierObj = {};
|
|
446
|
-
for (const [writerId, sha] of localFrontier) {
|
|
447
|
-
frontierObj[writerId] = sha;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
487
|
return {
|
|
451
488
|
type: /** @type {'sync-response'} */ ('sync-response'),
|
|
452
|
-
frontier:
|
|
489
|
+
frontier: frontierToObject(localFrontier),
|
|
453
490
|
patches,
|
|
454
491
|
skippedWriters,
|
|
455
492
|
};
|
|
@@ -503,7 +540,10 @@ export function applySyncResponse(response, state, frontier) {
|
|
|
503
540
|
const newFrontier = cloneFrontier(frontier);
|
|
504
541
|
let applied = 0;
|
|
505
542
|
|
|
506
|
-
//
|
|
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).
|
|
507
547
|
const patchesByWriter = new Map();
|
|
508
548
|
for (const { writerId, sha, patch } of response.patches) {
|
|
509
549
|
if (!patchesByWriter.has(writerId)) {
|
|
@@ -518,10 +558,20 @@ export function applySyncResponse(response, state, frontier) {
|
|
|
518
558
|
for (const { sha, patch } of writerPatches) {
|
|
519
559
|
// Normalize patch context (in case it came from network serialization)
|
|
520
560
|
const normalizedPatch = normalizePatch(patch);
|
|
521
|
-
// Guard: reject patches
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
561
|
+
// Guard: reject patches with genuinely unknown op types (B106 / C2 fix).
|
|
562
|
+
// Uses isKnownRawOp to accept only the 6 wire-format types. Canonical-only
|
|
563
|
+
// types (NodePropSet, EdgePropSet) must never appear on the wire before
|
|
564
|
+
// ADR 2 capability cutover — reject them here to fail closed.
|
|
565
|
+
for (const op of normalizedPatch.ops) {
|
|
566
|
+
if (!isKnownRawOp(op)) {
|
|
567
|
+
throw new SchemaUnsupportedError(
|
|
568
|
+
`Patch ${sha} contains unknown op type: ${op.type}`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Guard: reject patches exceeding our maximum supported schema version.
|
|
573
|
+
// isKnownRawOp() above checks op-type recognition; this checks the schema
|
|
574
|
+
// version ceiling. Currently SCHEMA_V3 is the max.
|
|
525
575
|
assertOpsCompatible(normalizedPatch.ops, SCHEMA_V3);
|
|
526
576
|
// Apply patch to state
|
|
527
577
|
join(newState, /** @type {Parameters<typeof join>[1]} */ (normalizedPatch), sha);
|
|
@@ -609,15 +659,9 @@ export function syncNeeded(localFrontier, remoteFrontier) {
|
|
|
609
659
|
* }
|
|
610
660
|
*/
|
|
611
661
|
export function createEmptySyncResponse(frontier) {
|
|
612
|
-
/** @type {{ [x: string]: string }} */
|
|
613
|
-
const frontierObj = {};
|
|
614
|
-
for (const [writerId, sha] of frontier) {
|
|
615
|
-
frontierObj[writerId] = sha;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
662
|
return {
|
|
619
663
|
type: /** @type {'sync-response'} */ ('sync-response'),
|
|
620
|
-
frontier:
|
|
664
|
+
frontier: frontierToObject(frontier),
|
|
621
665
|
patches: [],
|
|
622
666
|
};
|
|
623
667
|
}
|
|
@@ -16,6 +16,13 @@ export const SUPPORTED_ALGORITHMS = new Set(['ed25519']);
|
|
|
16
16
|
|
|
17
17
|
const ED25519_PUBLIC_KEY_LENGTH = 32;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* DER-encoded SPKI prefix for Ed25519 public keys (RFC 8410, Section 4).
|
|
21
|
+
* Prepend to a 32-byte raw key to form a valid SPKI structure for `createPublicKey()`.
|
|
22
|
+
* @see https://www.rfc-editor.org/rfc/rfc8410#section-4
|
|
23
|
+
*/
|
|
24
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
25
|
+
|
|
19
26
|
/**
|
|
20
27
|
* Decodes a base64-encoded Ed25519 public key and validates its length.
|
|
21
28
|
*
|
|
@@ -81,11 +88,7 @@ export function verifySignature({
|
|
|
81
88
|
const raw = decodePublicKey(publicKeyBase64);
|
|
82
89
|
|
|
83
90
|
const keyObject = createPublicKey({
|
|
84
|
-
key: Buffer.concat([
|
|
85
|
-
// DER prefix for Ed25519 public key (RFC 8410)
|
|
86
|
-
Buffer.from('302a300506032b6570032100', 'hex'),
|
|
87
|
-
raw,
|
|
88
|
-
]),
|
|
91
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
|
|
89
92
|
format: 'der',
|
|
90
93
|
type: 'spki',
|
|
91
94
|
});
|
|
@@ -26,6 +26,10 @@ const MAX_CAS_ATTEMPTS = 3;
|
|
|
26
26
|
* @property {boolean} [skipSignatureVerify=false] - Skip signature verification (for testing)
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{ok: true, records: Array<Record<string, unknown>>} | {ok: false, error: Error}} ReadRecordsResult
|
|
31
|
+
*/
|
|
32
|
+
|
|
29
33
|
export class TrustRecordService {
|
|
30
34
|
/**
|
|
31
35
|
* @param {Object} options
|
|
@@ -102,55 +106,65 @@ export class TrustRecordService {
|
|
|
102
106
|
* @param {string} graphName
|
|
103
107
|
* @param {Object} [options]
|
|
104
108
|
* @param {string} [options.tip] - Override tip commit (for pinned reads)
|
|
105
|
-
* @returns {Promise<
|
|
109
|
+
* @returns {Promise<ReadRecordsResult>}
|
|
106
110
|
*/
|
|
107
111
|
async readRecords(graphName, options = {}) {
|
|
108
112
|
const ref = buildTrustRecordRef(graphName);
|
|
109
113
|
let tip = options.tip ?? null;
|
|
110
114
|
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
tip = await this._persistence.readRef(ref);
|
|
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
|
-
);
|
|
123
|
-
}
|
|
115
|
+
try {
|
|
124
116
|
if (!tip) {
|
|
125
|
-
|
|
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
|
+
}
|
|
126
135
|
}
|
|
127
|
-
}
|
|
128
136
|
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
const records = [];
|
|
138
|
+
let current = tip;
|
|
131
139
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
));
|
|
144
152
|
|
|
145
|
-
|
|
153
|
+
records.unshift(record);
|
|
146
154
|
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
if (info.parents.length === 0) {
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
current = info.parents[0];
|
|
149
159
|
}
|
|
150
|
-
current = info.parents[0];
|
|
151
|
-
}
|
|
152
160
|
|
|
153
|
-
|
|
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
|
+
}
|
|
154
168
|
}
|
|
155
169
|
|
|
156
170
|
/**
|
|
@@ -25,6 +25,8 @@ export const OP_TYPES = Object.freeze([
|
|
|
25
25
|
'EdgeAdd',
|
|
26
26
|
'EdgeTombstone',
|
|
27
27
|
'PropSet',
|
|
28
|
+
'NodePropSet',
|
|
29
|
+
'EdgePropSet',
|
|
28
30
|
'BlobValue',
|
|
29
31
|
]);
|
|
30
32
|
|
|
@@ -80,9 +82,9 @@ function validateOp(op, index) {
|
|
|
80
82
|
/**
|
|
81
83
|
* Validates that an operation type is one of the allowed OP_TYPES.
|
|
82
84
|
*
|
|
83
|
-
* Valid operation types correspond to the
|
|
84
|
-
*
|
|
85
|
-
*
|
|
85
|
+
* Valid operation types correspond to the eight receipt operation types:
|
|
86
|
+
* NodeAdd, NodeTombstone, EdgeAdd, EdgeTombstone, PropSet, NodePropSet,
|
|
87
|
+
* EdgePropSet, and BlobValue.
|
|
86
88
|
*
|
|
87
89
|
* @param {unknown} value - The operation type to validate
|
|
88
90
|
* @param {number} i - Index of the operation in the ops array (for error messages)
|
|
@@ -156,7 +158,7 @@ function validateOpResult(value, i) {
|
|
|
156
158
|
|
|
157
159
|
/**
|
|
158
160
|
* @typedef {Object} OpOutcome
|
|
159
|
-
* @property {string} op - Operation type ('NodeAdd' | 'NodeTombstone' | 'EdgeAdd' | 'EdgeTombstone' | 'PropSet' | 'BlobValue')
|
|
161
|
+
* @property {string} op - Operation type ('NodeAdd' | 'NodeTombstone' | 'EdgeAdd' | 'EdgeTombstone' | 'PropSet' | 'NodePropSet' | 'EdgePropSet' | 'BlobValue')
|
|
160
162
|
* @property {string} target - Node ID or edge key
|
|
161
163
|
* @property {'applied' | 'superseded' | 'redundant'} result - Outcome of the operation
|
|
162
164
|
* @property {string} [reason] - Human-readable explanation (e.g., "LWW: writer bob at lamport 43 wins")
|
|
@@ -74,18 +74,64 @@
|
|
|
74
74
|
*/
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* Property set operation - sets a property value on a node
|
|
78
|
-
* Uses EventId for identification (derived from patch context)
|
|
77
|
+
* Property set operation - sets a property value on a node (raw/persisted form).
|
|
78
|
+
* Uses EventId for identification (derived from patch context).
|
|
79
|
+
*
|
|
80
|
+
* In raw patches, edge properties are also encoded as PropSet with the node
|
|
81
|
+
* field carrying a \x01-prefixed edge identity. See {@link OpV2NodePropSet}
|
|
82
|
+
* and {@link OpV2EdgePropSet} for the canonical (internal) representations.
|
|
83
|
+
*
|
|
79
84
|
* @typedef {Object} OpV2PropSet
|
|
80
85
|
* @property {'PropSet'} type - Operation type discriminator
|
|
86
|
+
* @property {NodeId} node - Node ID to set property on (may contain \x01 prefix for edge props)
|
|
87
|
+
* @property {string} key - Property key
|
|
88
|
+
* @property {unknown} value - Property value (any JSON-serializable type)
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Canonical node property set operation (internal only — never persisted).
|
|
93
|
+
* @typedef {Object} OpV2NodePropSet
|
|
94
|
+
* @property {'NodePropSet'} type - Operation type discriminator
|
|
81
95
|
* @property {NodeId} node - Node ID to set property on
|
|
82
96
|
* @property {string} key - Property key
|
|
83
97
|
* @property {unknown} value - Property value (any JSON-serializable type)
|
|
84
98
|
*/
|
|
85
99
|
|
|
86
100
|
/**
|
|
87
|
-
*
|
|
88
|
-
* @typedef {
|
|
101
|
+
* Canonical edge property set operation (internal only — never persisted).
|
|
102
|
+
* @typedef {Object} OpV2EdgePropSet
|
|
103
|
+
* @property {'EdgePropSet'} type - Operation type discriminator
|
|
104
|
+
* @property {NodeId} from - Source node ID
|
|
105
|
+
* @property {NodeId} to - Target node ID
|
|
106
|
+
* @property {string} label - Edge label
|
|
107
|
+
* @property {string} key - Property key
|
|
108
|
+
* @property {unknown} value - Property value (any JSON-serializable type)
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Blob value reference operation.
|
|
113
|
+
* @typedef {Object} OpV2BlobValue
|
|
114
|
+
* @property {'BlobValue'} type - Operation type discriminator
|
|
115
|
+
* @property {string} node - Node ID the blob is attached to
|
|
116
|
+
* @property {string} oid - Blob object ID in the Git object store
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Union of all raw (persisted) v2 operation types.
|
|
121
|
+
* @typedef {OpV2NodeAdd | OpV2NodeRemove | OpV2EdgeAdd | OpV2EdgeRemove | OpV2PropSet | OpV2BlobValue} RawOpV2
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Union of all canonical (internal) v2 operation types.
|
|
126
|
+
* Reducers, provenance, receipts, and queries operate on canonical ops only.
|
|
127
|
+
* @typedef {OpV2NodeAdd | OpV2NodeRemove | OpV2EdgeAdd | OpV2EdgeRemove | OpV2NodePropSet | OpV2EdgePropSet | OpV2BlobValue} CanonicalOpV2
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Union of all v2 operation types (raw + canonical).
|
|
132
|
+
* Used in patch containers that may hold either raw ops (from disk)
|
|
133
|
+
* or canonical ops (after normalization).
|
|
134
|
+
* @typedef {RawOpV2 | CanonicalOpV2} OpV2
|
|
89
135
|
*/
|
|
90
136
|
|
|
91
137
|
// ============================================================================
|
|
@@ -153,7 +199,9 @@ export function createEdgeRemoveV2(from, to, label, observedDots) {
|
|
|
153
199
|
}
|
|
154
200
|
|
|
155
201
|
/**
|
|
156
|
-
* Creates a PropSet operation (no dot - uses EventId)
|
|
202
|
+
* Creates a raw PropSet operation (no dot - uses EventId).
|
|
203
|
+
* This is the persisted form. For internal use, prefer
|
|
204
|
+
* {@link createNodePropSetV2} or {@link createEdgePropSetV2}.
|
|
157
205
|
* @param {NodeId} node - Node ID to set property on
|
|
158
206
|
* @param {string} key - Property key
|
|
159
207
|
* @param {unknown} value - Property value (any JSON-serializable type)
|
|
@@ -163,6 +211,30 @@ export function createPropSetV2(node, key, value) {
|
|
|
163
211
|
return { type: 'PropSet', node, key, value };
|
|
164
212
|
}
|
|
165
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Creates a canonical NodePropSet operation (internal only).
|
|
216
|
+
* @param {NodeId} node - Node ID to set property on
|
|
217
|
+
* @param {string} key - Property key
|
|
218
|
+
* @param {unknown} value - Property value (any JSON-serializable type)
|
|
219
|
+
* @returns {OpV2NodePropSet} NodePropSet operation
|
|
220
|
+
*/
|
|
221
|
+
export function createNodePropSetV2(node, key, value) {
|
|
222
|
+
return { type: 'NodePropSet', node, key, value };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Creates a canonical EdgePropSet operation (internal only).
|
|
227
|
+
* @param {NodeId} from - Source node ID
|
|
228
|
+
* @param {NodeId} to - Target node ID
|
|
229
|
+
* @param {string} label - Edge label
|
|
230
|
+
* @param {string} key - Property key
|
|
231
|
+
* @param {unknown} value - Property value (any JSON-serializable type)
|
|
232
|
+
* @returns {OpV2EdgePropSet} EdgePropSet operation
|
|
233
|
+
*/
|
|
234
|
+
export function createEdgePropSetV2(from, to, label, key, value) {
|
|
235
|
+
return { type: 'EdgePropSet', from, to, label, key, value };
|
|
236
|
+
}
|
|
237
|
+
|
|
166
238
|
// ============================================================================
|
|
167
239
|
// Factory Functions - Patch
|
|
168
240
|
// ============================================================================
|
|
@@ -54,6 +54,12 @@ class CachedValue {
|
|
|
54
54
|
/** @type {T|null} */
|
|
55
55
|
this._value = null;
|
|
56
56
|
|
|
57
|
+
/** @type {Promise<T>|null} */
|
|
58
|
+
this._inflight = null;
|
|
59
|
+
|
|
60
|
+
/** @type {number} */
|
|
61
|
+
this._generation = 0;
|
|
62
|
+
|
|
57
63
|
/** @type {number} */
|
|
58
64
|
this._cachedAt = 0;
|
|
59
65
|
|
|
@@ -71,12 +77,33 @@ class CachedValue {
|
|
|
71
77
|
return /** @type {T} */ (this._value);
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this._cachedAtIso = this._clock.timestamp();
|
|
80
|
+
if (this._inflight) {
|
|
81
|
+
return await this._inflight;
|
|
82
|
+
}
|
|
78
83
|
|
|
79
|
-
|
|
84
|
+
const generation = this._generation;
|
|
85
|
+
|
|
86
|
+
this._inflight = Promise.resolve(this._compute()).then(
|
|
87
|
+
(value) => {
|
|
88
|
+
// Ignore stale in-flight completion if cache was invalidated mid-flight.
|
|
89
|
+
if (generation !== this._generation) {
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
this._value = value;
|
|
93
|
+
this._cachedAt = this._clock.now();
|
|
94
|
+
this._cachedAtIso = this._clock.timestamp();
|
|
95
|
+
this._inflight = null;
|
|
96
|
+
return value;
|
|
97
|
+
},
|
|
98
|
+
(err) => {
|
|
99
|
+
if (generation === this._generation) {
|
|
100
|
+
this._inflight = null;
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return await this._inflight;
|
|
80
107
|
}
|
|
81
108
|
|
|
82
109
|
/**
|
|
@@ -99,7 +126,9 @@ class CachedValue {
|
|
|
99
126
|
* Invalidates the cached value, forcing recomputation on next get().
|
|
100
127
|
*/
|
|
101
128
|
invalidate() {
|
|
129
|
+
this._generation += 1;
|
|
102
130
|
this._value = null;
|
|
131
|
+
this._inflight = null;
|
|
103
132
|
this._cachedAt = 0;
|
|
104
133
|
this._cachedAtIso = null;
|
|
105
134
|
}
|
|
@@ -49,6 +49,9 @@ export function createEventId(lamport, writerId, patchSha, opIndex) {
|
|
|
49
49
|
* Compares two EventIds lexicographically.
|
|
50
50
|
* Order: lamport -> writerId -> patchSha -> opIndex
|
|
51
51
|
*
|
|
52
|
+
* SHA tiebreaker uses lexicographic string comparison. This is arbitrary but
|
|
53
|
+
* deterministic — the specific order doesn't matter as long as all writers agree.
|
|
54
|
+
*
|
|
52
55
|
* @param {EventId} a
|
|
53
56
|
* @param {EventId} b
|
|
54
57
|
* @returns {number} -1 if a < b, 0 if equal, 1 if a > b
|
|
@@ -64,7 +67,7 @@ export function compareEventIds(a, b) {
|
|
|
64
67
|
return a.writerId < b.writerId ? -1 : 1;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
// 3. Compare patchSha as string
|
|
70
|
+
// 3. Compare patchSha as string (lexicographic — arbitrary but deterministic)
|
|
68
71
|
if (a.patchSha !== b.patchSha) {
|
|
69
72
|
return a.patchSha < b.patchSha ? -1 : 1;
|
|
70
73
|
}
|
|
@@ -34,7 +34,9 @@ class LRUCache {
|
|
|
34
34
|
if (!this._cache.has(key)) {
|
|
35
35
|
return undefined;
|
|
36
36
|
}
|
|
37
|
-
//
|
|
37
|
+
// Delete-reinsert maintains insertion order in the underlying Map, which
|
|
38
|
+
// serves as the LRU eviction order. This is O(1) amortized in V8's Map
|
|
39
|
+
// implementation despite appearing wasteful (2x Map ops per get).
|
|
38
40
|
const value = /** @type {V} */ (this._cache.get(key));
|
|
39
41
|
this._cache.delete(key);
|
|
40
42
|
this._cache.set(key, value);
|
|
@@ -376,6 +376,10 @@ export function buildTrustRecordRef(graphName) {
|
|
|
376
376
|
/**
|
|
377
377
|
* Parses and extracts the writer ID from a writer ref path.
|
|
378
378
|
*
|
|
379
|
+
* Returns null for any non-writer ref, including malformed refs. Callers that
|
|
380
|
+
* need to distinguish "not a writer ref" from "malformed ref" should validate
|
|
381
|
+
* the ref format separately before calling this method.
|
|
382
|
+
*
|
|
379
383
|
* @param {string} refPath - The full ref path
|
|
380
384
|
* @returns {string|null} The writer ID, or null if the path is not a valid writer ref
|
|
381
385
|
*
|