@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.
Files changed (59) hide show
  1. package/README.md +9 -6
  2. package/bin/cli/commands/trust.js +37 -1
  3. package/bin/cli/infrastructure.js +14 -1
  4. package/bin/cli/schemas.js +4 -4
  5. package/bin/presenters/text.js +10 -3
  6. package/bin/warp-graph.js +4 -1
  7. package/index.d.ts +17 -1
  8. package/package.json +1 -1
  9. package/src/domain/WarpGraph.js +1 -1
  10. package/src/domain/crdt/Dot.js +5 -0
  11. package/src/domain/crdt/LWW.js +3 -1
  12. package/src/domain/crdt/ORSet.js +33 -23
  13. package/src/domain/crdt/VersionVector.js +12 -0
  14. package/src/domain/errors/PatchError.js +27 -0
  15. package/src/domain/errors/StorageError.js +8 -0
  16. package/src/domain/errors/WriterError.js +5 -0
  17. package/src/domain/errors/index.js +1 -0
  18. package/src/domain/services/AuditReceiptService.js +2 -1
  19. package/src/domain/services/AuditVerifierService.js +33 -2
  20. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  21. package/src/domain/services/BoundaryTransitionRecord.js +1 -0
  22. package/src/domain/services/CheckpointMessageCodec.js +5 -0
  23. package/src/domain/services/CheckpointService.js +29 -2
  24. package/src/domain/services/GCPolicy.js +25 -4
  25. package/src/domain/services/GraphTraversal.js +3 -1
  26. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  27. package/src/domain/services/JoinReducer.js +311 -75
  28. package/src/domain/services/KeyCodec.js +48 -0
  29. package/src/domain/services/MaterializedViewService.js +14 -3
  30. package/src/domain/services/MessageSchemaDetector.js +35 -5
  31. package/src/domain/services/OpNormalizer.js +79 -0
  32. package/src/domain/services/PatchBuilderV2.js +240 -160
  33. package/src/domain/services/QueryBuilder.js +4 -0
  34. package/src/domain/services/SyncAuthService.js +3 -0
  35. package/src/domain/services/SyncController.js +12 -31
  36. package/src/domain/services/SyncProtocol.js +76 -32
  37. package/src/domain/services/WarpMessageCodec.js +2 -0
  38. package/src/domain/trust/TrustCrypto.js +8 -5
  39. package/src/domain/trust/TrustRecordService.js +50 -36
  40. package/src/domain/types/TickReceipt.js +6 -4
  41. package/src/domain/types/WarpTypesV2.js +77 -5
  42. package/src/domain/utils/CachedValue.js +34 -5
  43. package/src/domain/utils/EventId.js +4 -1
  44. package/src/domain/utils/LRUCache.js +3 -1
  45. package/src/domain/utils/RefLayout.js +4 -0
  46. package/src/domain/utils/canonicalStringify.js +48 -18
  47. package/src/domain/utils/defaultClock.js +1 -0
  48. package/src/domain/utils/matchGlob.js +7 -0
  49. package/src/domain/warp/PatchSession.js +30 -24
  50. package/src/domain/warp/Writer.js +12 -1
  51. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  52. package/src/domain/warp/checkpoint.methods.js +36 -7
  53. package/src/domain/warp/fork.methods.js +1 -1
  54. package/src/domain/warp/materialize.methods.js +44 -5
  55. package/src/domain/warp/materializeAdvanced.methods.js +50 -10
  56. package/src/domain/warp/patch.methods.js +21 -11
  57. package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
  58. package/src/infrastructure/codecs/CborCodec.js +2 -0
  59. 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
- // Update cached state
337
- this._host._cachedState = result.state;
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
- // Invalidate derived caches (C1) sync changes underlying state
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
- // Note: We assume remote is ahead; the caller should verify ancestry
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: frontierObj,
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
- // Convert incoming frontier from object to Map
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: frontierObj,
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
- // Group patches by writer to ensure proper ordering
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 containing ops we don't understand.
522
- // Currently SCHEMA_V3 is the max, so this is a no-op for this
523
- // codebase. If a future schema adds new op types, this check
524
- // will prevent silent data loss until the reader is upgraded.
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: frontierObj,
664
+ frontier: frontierToObject(frontier),
621
665
  patches: [],
622
666
  };
623
667
  }
@@ -29,4 +29,6 @@ export {
29
29
  assertOpsCompatible,
30
30
  SCHEMA_V2,
31
31
  SCHEMA_V3,
32
+ PATCH_SCHEMA_V2,
33
+ PATCH_SCHEMA_V3,
32
34
  } from './MessageSchemaDetector.js';
@@ -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<Array<Record<string, unknown>>>}
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
- if (!tip) {
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
- return [];
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
- const records = [];
130
- let current = tip;
137
+ const records = [];
138
+ let current = tip;
131
139
 
132
- while (current) {
133
- const info = await this._persistence.getNodeInfo(current);
134
- const entries = await this._persistence.readTreeOids(
135
- await this._persistence.getCommitTree(current),
136
- );
137
- const blobOid = entries['record.cbor'];
138
- if (!blobOid) {
139
- break;
140
- }
141
- const record = /** @type {Record<string, unknown>} */ (this._codec.decode(
142
- await this._persistence.readBlob(blobOid),
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
- records.unshift(record);
153
+ records.unshift(record);
146
154
 
147
- if (info.parents.length === 0) {
148
- break;
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
- return records;
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 six patch operations defined
84
- * in PatchBuilderV2: NodeAdd, NodeTombstone, EdgeAdd, EdgeTombstone,
85
- * PropSet, and BlobValue.
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
- * Union of all v2 operation types
88
- * @typedef {OpV2NodeAdd | OpV2NodeRemove | OpV2EdgeAdd | OpV2EdgeRemove | OpV2PropSet} OpV2
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
- const value = await this._compute();
75
- this._value = value;
76
- this._cachedAt = this._clock.now();
77
- this._cachedAtIso = this._clock.timestamp();
80
+ if (this._inflight) {
81
+ return await this._inflight;
82
+ }
78
83
 
79
- return value;
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
- // Move to end (most recently used) by deleting and re-inserting
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
  *