@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
@@ -7,10 +7,24 @@
7
7
  * - Array elements that are undefined/function/symbol become "null"
8
8
  * - Object properties with undefined/function/symbol values are omitted
9
9
  *
10
+ * Throws TypeError on circular references rather than stack-overflowing.
11
+ *
10
12
  * @param {unknown} value - Any JSON-serializable value
11
13
  * @returns {string} Canonical JSON string with sorted keys
12
14
  */
13
15
  export function canonicalStringify(value) {
16
+ return _canonicalStringify(value, new WeakSet());
17
+ }
18
+
19
+ /**
20
+ * Internal recursive helper with cycle detection.
21
+ *
22
+ * @param {unknown} value - Any JSON-serializable value
23
+ * @param {WeakSet<object>} seen - Set of already-visited objects for cycle detection
24
+ * @returns {string} Canonical JSON string with sorted keys
25
+ * @private
26
+ */
27
+ function _canonicalStringify(value, seen) {
14
28
  if (value === undefined) {
15
29
  return 'null';
16
30
  }
@@ -18,26 +32,42 @@ export function canonicalStringify(value) {
18
32
  return 'null';
19
33
  }
20
34
  if (Array.isArray(value)) {
21
- // Map elements: undefined/function/symbol -> "null", others recurse
22
- const elements = value.map(el => {
23
- if (el === undefined || typeof el === 'function' || typeof el === 'symbol') {
24
- return 'null';
25
- }
26
- return canonicalStringify(el);
27
- });
28
- return `[${elements.join(',')}]`;
35
+ if (seen.has(value)) {
36
+ throw new TypeError('Circular reference detected in canonicalStringify');
37
+ }
38
+ seen.add(value);
39
+ try {
40
+ // Map elements: undefined/function/symbol -> "null", others recurse
41
+ const elements = value.map(el => {
42
+ if (el === undefined || typeof el === 'function' || typeof el === 'symbol') {
43
+ return 'null';
44
+ }
45
+ return _canonicalStringify(el, seen);
46
+ });
47
+ return `[${elements.join(',')}]`;
48
+ } finally {
49
+ seen.delete(value);
50
+ }
29
51
  }
30
52
  if (typeof value === 'object') {
31
- const obj = /** @type {Record<string, unknown>} */ (value);
32
- // Filter out keys with undefined/function/symbol values, then sort
33
- const keys = Object.keys(obj)
34
- .filter(k => {
35
- const v = obj[k];
36
- return v !== undefined && typeof v !== 'function' && typeof v !== 'symbol';
37
- })
38
- .sort();
39
- const pairs = keys.map(k => `${JSON.stringify(k)}:${canonicalStringify(obj[k])}`);
40
- return `{${pairs.join(',')}}`;
53
+ if (seen.has(value)) {
54
+ throw new TypeError('Circular reference detected in canonicalStringify');
55
+ }
56
+ seen.add(value);
57
+ try {
58
+ const obj = /** @type {Record<string, unknown>} */ (value);
59
+ // Filter out keys with undefined/function/symbol values, then sort
60
+ const keys = Object.keys(obj)
61
+ .filter(k => {
62
+ const v = obj[k];
63
+ return v !== undefined && typeof v !== 'function' && typeof v !== 'symbol';
64
+ })
65
+ .sort();
66
+ const pairs = keys.map(k => `${JSON.stringify(k)}:${_canonicalStringify(obj[k], seen)}`);
67
+ return `{${pairs.join(',')}}`;
68
+ } finally {
69
+ seen.delete(value);
70
+ }
41
71
  }
42
72
  return JSON.stringify(value);
43
73
  }
@@ -13,6 +13,7 @@ const defaultClock = {
13
13
  return performance.now();
14
14
  },
15
15
  timestamp() {
16
+ // eslint-disable-next-line no-restricted-syntax -- ClockPort implementation
16
17
  return new Date().toISOString();
17
18
  },
18
19
  };
@@ -46,6 +46,13 @@ export function matchGlob(pattern, str) {
46
46
  if (!regex) {
47
47
  regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
48
48
  globRegexCache.set(pattern, regex);
49
+ // Prevent unbounded cache growth. 1000 entries is generous for typical
50
+ // usage; a full clear is simpler and cheaper than LRU for a regex cache.
51
+ // Evict after insert so the just-compiled regex survives the clear.
52
+ if (globRegexCache.size >= 1000) {
53
+ globRegexCache.clear();
54
+ globRegexCache.set(pattern, regex);
55
+ }
49
56
  }
50
57
  return regex.test(str);
51
58
  }
@@ -9,8 +9,8 @@
9
9
  * @see WARP Writer Spec v1
10
10
  */
11
11
 
12
- import { buildWriterRef } from '../utils/RefLayout.js';
13
12
  import WriterError from '../errors/WriterError.js';
13
+ import { buildWriterRef } from '../utils/RefLayout.js';
14
14
 
15
15
  /**
16
16
  * Fluent patch session for building and committing graph mutations.
@@ -60,7 +60,7 @@ export class PatchSession {
60
60
  *
61
61
  * @param {string} nodeId - The node ID to add
62
62
  * @returns {this} This session for chaining
63
- * @throws {Error} If this session has already been committed
63
+ * @throws {WriterError} SESSION_COMMITTED if already committed
64
64
  */
65
65
  addNode(nodeId) {
66
66
  this._ensureNotCommitted();
@@ -75,7 +75,7 @@ export class PatchSession {
75
75
  *
76
76
  * @param {string} nodeId - The node ID to remove
77
77
  * @returns {this} This session for chaining
78
- * @throws {Error} If this session has already been committed
78
+ * @throws {WriterError} SESSION_COMMITTED if already committed
79
79
  */
80
80
  removeNode(nodeId) {
81
81
  this._ensureNotCommitted();
@@ -90,7 +90,7 @@ export class PatchSession {
90
90
  * @param {string} to - Target node ID
91
91
  * @param {string} label - Edge label/type
92
92
  * @returns {this} This session for chaining
93
- * @throws {Error} If this session has already been committed
93
+ * @throws {WriterError} SESSION_COMMITTED if already committed
94
94
  */
95
95
  addEdge(from, to, label) {
96
96
  this._ensureNotCommitted();
@@ -107,7 +107,7 @@ export class PatchSession {
107
107
  * @param {string} to - Target node ID
108
108
  * @param {string} label - Edge label/type
109
109
  * @returns {this} This session for chaining
110
- * @throws {Error} If this session has already been committed
110
+ * @throws {WriterError} SESSION_COMMITTED if already committed
111
111
  */
112
112
  removeEdge(from, to, label) {
113
113
  this._ensureNotCommitted();
@@ -122,7 +122,7 @@ export class PatchSession {
122
122
  * @param {string} key - Property key
123
123
  * @param {unknown} value - Property value (must be JSON-serializable)
124
124
  * @returns {this} This session for chaining
125
- * @throws {Error} If this session has already been committed
125
+ * @throws {WriterError} SESSION_COMMITTED if already committed
126
126
  */
127
127
  setProperty(nodeId, key, value) {
128
128
  this._ensureNotCommitted();
@@ -139,7 +139,7 @@ export class PatchSession {
139
139
  * @param {string} key - Property key
140
140
  * @param {unknown} value - Property value (must be JSON-serializable)
141
141
  * @returns {this} This session for chaining
142
- * @throws {Error} If this session has already been committed
142
+ * @throws {WriterError} SESSION_COMMITTED if already committed
143
143
  */
144
144
  // eslint-disable-next-line max-params -- direct delegate matching PatchBuilderV2 signature
145
145
  setEdgeProperty(from, to, label, key, value) {
@@ -154,7 +154,7 @@ export class PatchSession {
154
154
  * @param {string} nodeId - The node ID to attach content to
155
155
  * @param {Buffer|string} content - The content to attach
156
156
  * @returns {Promise<this>} This session for chaining
157
- * @throws {Error} If this session has already been committed
157
+ * @throws {WriterError} SESSION_COMMITTED if already committed
158
158
  */
159
159
  async attachContent(nodeId, content) {
160
160
  this._ensureNotCommitted();
@@ -170,7 +170,7 @@ export class PatchSession {
170
170
  * @param {string} label - Edge label/type
171
171
  * @param {Buffer|string} content - The content to attach
172
172
  * @returns {Promise<this>} This session for chaining
173
- * @throws {Error} If this session has already been committed
173
+ * @throws {WriterError} SESSION_COMMITTED if already committed
174
174
  */
175
175
  // eslint-disable-next-line max-params -- direct delegate matching PatchBuilderV2 signature
176
176
  async attachEdgeContent(from, to, label, content) {
@@ -199,6 +199,7 @@ export class PatchSession {
199
199
  * @example
200
200
  * const sha = await patch.commit();
201
201
  */
202
+ // eslint-disable-next-line complexity -- maps multiple commit-failure modes into stable WriterError codes
202
203
  async commit() {
203
204
  this._ensureNotCommitted();
204
205
 
@@ -207,19 +208,6 @@ export class PatchSession {
207
208
  throw new WriterError('EMPTY_PATCH', 'Cannot commit empty patch: no operations added');
208
209
  }
209
210
 
210
- const writerRef = buildWriterRef(this._graphName, this._writerId);
211
-
212
- // Pre-commit CAS check: verify ref hasn't moved
213
- const currentHead = await this._persistence.readRef(writerRef);
214
- if (currentHead !== this._expectedOldHead) {
215
- throw new WriterError(
216
- 'WRITER_REF_ADVANCED',
217
- `Writer ref ${writerRef} has advanced since beginPatch(). ` +
218
- `Expected ${this._expectedOldHead || '(none)'}, found ${currentHead || '(none)'}. ` +
219
- `Call beginPatch() again to retry.`
220
- );
221
- }
222
-
223
211
  try {
224
212
  // Delegate to PatchBuilderV2.commit() which handles the git operations
225
213
  const sha = await this._builder.commit();
@@ -228,6 +216,21 @@ export class PatchSession {
228
216
  } catch (err) {
229
217
  const errMsg = err instanceof Error ? err.message : String(err);
230
218
  const cause = err instanceof Error ? err : undefined;
219
+ const casError = /** @type {{code?: unknown, expectedSha?: unknown, actualSha?: unknown}|null} */ (
220
+ (err && typeof err === 'object') ? err : null
221
+ );
222
+ if (casError?.code === 'WRITER_CAS_CONFLICT') {
223
+ const writerRef = buildWriterRef(this._graphName, this._writerId);
224
+ const expectedSha = typeof casError.expectedSha === 'string' ? casError.expectedSha : this._expectedOldHead;
225
+ const actualSha = typeof casError.actualSha === 'string' ? casError.actualSha : null;
226
+ throw new WriterError(
227
+ 'WRITER_REF_ADVANCED',
228
+ `Writer ref ${writerRef} has advanced since beginPatch(). ` +
229
+ `Expected ${expectedSha || '(none)'}, found ${actualSha || '(none)'}. ` +
230
+ 'Call beginPatch() again to retry.',
231
+ cause
232
+ );
233
+ }
231
234
  if (errMsg.includes('Concurrent commit detected') ||
232
235
  errMsg.includes('has advanced')) {
233
236
  throw new WriterError('WRITER_REF_ADVANCED', errMsg, cause);
@@ -246,12 +249,15 @@ export class PatchSession {
246
249
 
247
250
  /**
248
251
  * Ensures the session hasn't been committed yet.
249
- * @throws {Error} If already committed
252
+ * @throws {WriterError} SESSION_COMMITTED if already committed
250
253
  * @private
251
254
  */
252
255
  _ensureNotCommitted() {
253
256
  if (this._committed) {
254
- throw new Error('PatchSession already committed. Call beginPatch() to create a new session.');
257
+ throw new WriterError(
258
+ 'SESSION_COMMITTED',
259
+ 'PatchSession already committed. Call beginPatch() to create a new session.',
260
+ );
255
261
  }
256
262
  }
257
263
  }
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import defaultCodec from '../utils/defaultCodec.js';
18
+ import nullLogger from '../utils/nullLogger.js';
18
19
  import { validateWriterId, buildWriterRef } from '../utils/RefLayout.js';
19
20
  import { PatchSession } from './PatchSession.js';
20
21
  import { PatchBuilderV2 } from '../services/PatchBuilderV2.js';
@@ -44,8 +45,9 @@ export class Writer {
44
45
  * @param {(result: {patch: Object, sha: string}) => void | Promise<void>} [options.onCommitSuccess] - Callback invoked after successful commit with { patch, sha }
45
46
  * @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
46
47
  * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for CBOR serialization (defaults to domain-local codec)
48
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger port
47
49
  */
48
- constructor({ persistence, graphName, writerId, versionVector, getCurrentState, onCommitSuccess, onDeleteWithData = 'warn', codec }) {
50
+ constructor({ persistence, graphName, writerId, versionVector, getCurrentState, onCommitSuccess, onDeleteWithData = 'warn', codec, logger }) {
49
51
  validateWriterId(writerId);
50
52
 
51
53
  /** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default} */
@@ -72,6 +74,9 @@ export class Writer {
72
74
  /** @type {import('../../ports/CodecPort.js').default|undefined} */
73
75
  this._codec = codec || defaultCodec;
74
76
 
77
+ /** @type {import('../../ports/LoggerPort.js').default} */
78
+ this._logger = logger || nullLogger;
79
+
75
80
  /** @type {boolean} */
76
81
  this._commitInProgress = false;
77
82
  }
@@ -151,6 +156,7 @@ export class Writer {
151
156
  onCommitSuccess: this._onCommitSuccess,
152
157
  onDeleteWithData: this._onDeleteWithData,
153
158
  codec: this._codec,
159
+ logger: this._logger,
154
160
  });
155
161
 
156
162
  // Return PatchSession wrapping the builder
@@ -186,6 +192,11 @@ export class Writer {
186
192
  'commitPatch() is not reentrant. Use beginPatch() for nested or concurrent patches.',
187
193
  );
188
194
  }
195
+ // The `_commitInProgress` flag prevents concurrent commits from the same
196
+ // Writer instance. The finally block unconditionally resets it to ensure
197
+ // the writer remains usable after a failed commit. Error classification
198
+ // (CAS failure vs corruption vs I/O) is handled by the caller via the
199
+ // thrown error type.
189
200
  this._commitInProgress = true;
190
201
  try {
191
202
  const patch = await this.beginPatch();
@@ -249,7 +249,7 @@ declare module '../WarpGraph.js' {
249
249
  _resolveCeiling(options?: { ceiling?: number | null }): number | null;
250
250
  _buildAdjacency(state: WarpStateV5): { outgoing: Map<string, Array<{ neighborId: string; label: string }>>; incoming: Map<string, Array<{ neighborId: string; label: string }>> };
251
251
  _buildView(state: WarpStateV5, stateHash: string, diff?: import('../types/PatchDiff.js').PatchDiff): void;
252
- _setMaterializedState(state: WarpStateV5, diff?: import('../types/PatchDiff.js').PatchDiff): Promise<{ state: WarpStateV5; stateHash: string; adjacency: unknown }>;
252
+ _setMaterializedState(state: WarpStateV5, optionsOrDiff?: import('../types/PatchDiff.js').PatchDiff | { diff?: import('../types/PatchDiff.js').PatchDiff | null }): Promise<{ state: WarpStateV5; stateHash: string; adjacency: unknown }>;
253
253
  _materializeWithCeiling(ceiling: number, collectReceipts: boolean, t0: number): Promise<WarpStateV5 | { state: WarpStateV5; receipts: TickReceipt[] }>;
254
254
  _persistSeekCacheEntry(cacheKey: string, buf: Buffer, state: WarpStateV5): Promise<void>;
255
255
  _restoreIndexFromCache(indexTreeOid: string): Promise<void>;
@@ -167,8 +167,28 @@ export async function _loadLatestCheckpoint() {
167
167
 
168
168
  try {
169
169
  return await loadCheckpoint(this._persistence, checkpointSha, { codec: this._codec });
170
- } catch {
171
- return null;
170
+ } catch (err) {
171
+ // "Not found" conditions (missing tree entries, missing blobs) are expected
172
+ // when a checkpoint ref exists but the objects have been pruned or are
173
+ // unreachable. In that case, fall back to full replay by returning null.
174
+ // Decode/corruption errors (e.g., CBOR parse failure, schema mismatch)
175
+ // should propagate so callers see the real problem.
176
+ // These string-contains checks match specific error messages from the
177
+ // persistence layer and codec:
178
+ // "missing" — git cat-file on pruned/unreachable objects
179
+ // "not found" — readTree entry lookup failures
180
+ // "ENOENT" — filesystem-level missing path (bare repo edge case)
181
+ // "non-empty string" — readRef/getNodeInfo called with empty/null SHA
182
+ const msg = err instanceof Error ? err.message : '';
183
+ if (
184
+ msg.includes('missing') ||
185
+ msg.includes('not found') ||
186
+ msg.includes('ENOENT') ||
187
+ msg.includes('non-empty string')
188
+ ) {
189
+ return null;
190
+ }
191
+ throw err;
172
192
  }
173
193
  }
174
194
 
@@ -188,9 +208,11 @@ export async function _loadPatchesSince(checkpoint) {
188
208
  const checkpointSha = checkpoint.frontier?.get(writerId) || null;
189
209
  const patches = await this._loadWriterPatches(writerId, checkpointSha);
190
210
 
191
- // Validate each patch against checkpoint frontier
192
- for (const { sha } of patches) {
193
- await this._validatePatchAgainstCheckpoint(writerId, sha, checkpoint);
211
+ // Validate ancestry once at the writer tip; chain-order patches are then
212
+ // transitively valid between checkpointSha and tipSha.
213
+ if (patches.length > 0) {
214
+ const tipSha = patches[patches.length - 1].sha;
215
+ await this._validatePatchAgainstCheckpoint(writerId, tipSha, checkpoint);
194
216
  }
195
217
 
196
218
  for (const p of patches) {
@@ -228,10 +250,16 @@ export async function _validateMigrationBoundary() {
228
250
  }
229
251
 
230
252
  /**
231
- * Checks if there are any schema:1 patches in the graph.
253
+ * Checks whether any writer tip contains a schema:1 patch.
254
+ *
255
+ * **Heuristic only** — inspects the most recent patch per writer (the tip),
256
+ * not the full history chain. Older schema:1 patches buried deeper in a
257
+ * writer's chain will NOT be detected. This is acceptable because migration
258
+ * typically writes a new tip, so a schema:2+ tip implies the writer has
259
+ * been migrated.
232
260
  *
233
261
  * @this {import('../WarpGraph.js').default}
234
- * @returns {Promise<boolean>} True if schema:1 patches exist
262
+ * @returns {Promise<boolean>} True if any writer tip is schema:1 (or omits `schema`, treated as legacy v1)
235
263
  * @private
236
264
  */
237
265
  export async function _hasSchema1Patches() {
@@ -312,6 +340,7 @@ export function _maybeRunGC(state) {
312
340
  if (preGcFingerprint !== postGcFingerprint) {
313
341
  // Frontier changed — discard compacted state, mark dirty
314
342
  this._stateDirty = true;
343
+ this._cachedViewHash = null;
315
344
  if (this._logger) {
316
345
  this._logger.warn(
317
346
  'Auto-GC discarded: frontier changed during compaction (concurrent write)',
@@ -104,7 +104,7 @@ export async function fork({ from, at, forkName, forkWriterId }) {
104
104
 
105
105
  // 4. Generate or validate fork name (add random suffix to prevent collisions)
106
106
  const resolvedForkName =
107
- forkName ?? `${this._graphName}-fork-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
107
+ forkName ?? `${this._graphName}-fork-${Math.random().toString(36).slice(2, 10).padEnd(8, '0')}`;
108
108
  try {
109
109
  validateGraphName(resolvedForkName);
110
110
  } catch (err) {
@@ -51,6 +51,30 @@ function scanPatchesForMaxLamport(graph, patches) {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Creates a shallow-frozen public view of materialized state.
56
+ *
57
+ * @param {import('../services/JoinReducer.js').WarpStateV5} state
58
+ * @returns {import('../services/JoinReducer.js').WarpStateV5}
59
+ */
60
+ function freezePublicState(state) {
61
+ return Object.freeze({ ...state });
62
+ }
63
+
64
+ /**
65
+ * Creates a shallow-frozen public result for receipt-enabled materialization.
66
+ *
67
+ * @param {import('../services/JoinReducer.js').WarpStateV5} state
68
+ * @param {import('../types/TickReceipt.js').TickReceipt[]} receipts
69
+ * @returns {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}}
70
+ */
71
+ function freezePublicStateWithReceipts(state, receipts) {
72
+ return Object.freeze({
73
+ state: freezePublicState(state),
74
+ receipts,
75
+ });
76
+ }
77
+
54
78
 
55
79
  /**
56
80
  * Materializes the current graph state.
@@ -90,7 +114,12 @@ export async function materialize(options) {
90
114
  try {
91
115
  // When ceiling is active, delegate to ceiling-aware path (with its own cache)
92
116
  if (ceiling !== null) {
93
- return await this._materializeWithCeiling(ceiling, !!collectReceipts, t0);
117
+ const result = await this._materializeWithCeiling(ceiling, !!collectReceipts, t0);
118
+ if (collectReceipts) {
119
+ const withReceipts = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}} */ (result);
120
+ return freezePublicStateWithReceipts(withReceipts.state, withReceipts.receipts);
121
+ }
122
+ return freezePublicState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (result));
94
123
  }
95
124
 
96
125
  // Check for checkpoint
@@ -190,7 +219,7 @@ export async function materialize(options) {
190
219
  }
191
220
  }
192
221
 
193
- await this._setMaterializedState(state, diff);
222
+ await this._setMaterializedState(state, { diff });
194
223
  this._provenanceDegraded = false;
195
224
  this._cachedCeiling = null;
196
225
  this._cachedFrontier = null;
@@ -225,9 +254,12 @@ export async function materialize(options) {
225
254
  this._logTiming('materialize', t0, { metrics: `${patchCount} patches` });
226
255
 
227
256
  if (collectReceipts) {
228
- return { state, receipts: /** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts) };
257
+ return freezePublicStateWithReceipts(
258
+ /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state),
259
+ /** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts),
260
+ );
229
261
  }
230
- return state;
262
+ return freezePublicState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
231
263
  } catch (err) {
232
264
  this._logTiming('materialize', t0, { error: /** @type {Error} */ (err) });
233
265
  throw err;
@@ -245,7 +277,14 @@ export async function _materializeGraph() {
245
277
  if (!this._stateDirty && this._materializedGraph) {
246
278
  return this._materializedGraph;
247
279
  }
248
- const state = await this.materialize();
280
+ const materialized = await this.materialize();
281
+ const state = this._stateDirty
282
+ ? /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (materialized)
283
+ : (this._cachedState
284
+ || /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (materialized));
285
+ if (!state) {
286
+ return /** @type {object} */ (this._materializedGraph);
287
+ }
249
288
  if (!this._materializedGraph || this._materializedGraph.state !== state) {
250
289
  await this._setMaterializedState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
251
290
  }
@@ -22,15 +22,40 @@ import BitmapNeighborProvider from '../services/BitmapNeighborProvider.js';
22
22
 
23
23
  /** @typedef {import('../types/WarpPersistence.js').CorePersistence} CorePersistence */
24
24
  /** @typedef {import('../services/JoinReducer.js').WarpStateV5} WarpStateV5 */
25
+ /** @typedef {import('../types/TickReceipt.js').TickReceipt} TickReceipt */
25
26
 
26
27
  /**
27
28
  * @typedef {{ outgoing: Map<string, Array<{neighborId: string, label: string}>>, incoming: Map<string, Array<{neighborId: string, label: string}>> }} AdjacencyMap
28
- * @typedef {{ state: WarpStateV5, stateHash: string, adjacency: AdjacencyMap }} MaterializedResult
29
+ * @typedef {{ state: WarpStateV5, stateHash: string|null, adjacency: AdjacencyMap }} MaterializedResult
29
30
  */
30
31
 
31
32
  import { buildWriterRef } from '../utils/RefLayout.js';
32
33
  import { decodePatchMessage, detectMessageKind } from '../services/WarpMessageCodec.js';
33
34
 
35
+ /**
36
+ * Creates a shallow-frozen public view of materialized state.
37
+ *
38
+ * @param {WarpStateV5} state
39
+ * @returns {WarpStateV5}
40
+ */
41
+ function freezePublicState(state) {
42
+ return Object.freeze({ ...state });
43
+ }
44
+
45
+ /**
46
+ * Creates a shallow-frozen public materialization result with receipts.
47
+ *
48
+ * @param {WarpStateV5} state
49
+ * @param {TickReceipt[]} receipts
50
+ * @returns {{state: WarpStateV5, receipts: TickReceipt[]}}
51
+ */
52
+ function freezePublicStateWithReceipts(state, receipts) {
53
+ return Object.freeze({
54
+ state: freezePublicState(state),
55
+ receipts,
56
+ });
57
+ }
58
+
34
59
  /**
35
60
  * Resolves the effective ceiling from options and instance state.
36
61
  *
@@ -107,11 +132,23 @@ export function _buildAdjacency(state) {
107
132
  *
108
133
  * @this {import('../WarpGraph.js').default}
109
134
  * @param {import('../services/JoinReducer.js').WarpStateV5} state
110
- * @param {import('../types/PatchDiff.js').PatchDiff} [diff] - Optional diff for incremental index
135
+ * @param {import('../types/PatchDiff.js').PatchDiff|{diff?: import('../types/PatchDiff.js').PatchDiff|null}} [optionsOrDiff]
136
+ * Either a PatchDiff (legacy positional form) or options object.
111
137
  * @returns {Promise<MaterializedResult>}
112
138
  * @private
113
139
  */
114
- export async function _setMaterializedState(state, diff) {
140
+ export async function _setMaterializedState(state, optionsOrDiff) {
141
+ /** @type {import('../types/PatchDiff.js').PatchDiff|undefined} */
142
+ let diff;
143
+ if (
144
+ optionsOrDiff &&
145
+ typeof optionsOrDiff === 'object' &&
146
+ Object.prototype.hasOwnProperty.call(optionsOrDiff, 'diff')
147
+ ) {
148
+ diff = /** @type {{diff?: import('../types/PatchDiff.js').PatchDiff|null}} */ (optionsOrDiff).diff ?? undefined;
149
+ } else {
150
+ diff = /** @type {import('../types/PatchDiff.js').PatchDiff|undefined} */ (optionsOrDiff ?? undefined);
151
+ }
115
152
  this._cachedState = state;
116
153
  this._stateDirty = false;
117
154
  this._versionVector = vvClone(state.observedFrontier);
@@ -222,7 +259,7 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
222
259
  cf.size === frontier.size &&
223
260
  [...frontier].every(([w, sha]) => cf.get(w) === sha)
224
261
  ) {
225
- return this._cachedState;
262
+ return freezePublicState(this._cachedState);
226
263
  }
227
264
 
228
265
  const writerIds = [...frontier.keys()];
@@ -236,9 +273,9 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
236
273
  this._cachedFrontier = frontier;
237
274
  this._logTiming('materialize', t0, { metrics: '0 patches (ceiling)' });
238
275
  if (collectReceipts) {
239
- return { state, receipts: [] };
276
+ return freezePublicStateWithReceipts(state, []);
240
277
  }
241
- return state;
278
+ return freezePublicState(state);
242
279
  }
243
280
 
244
281
  // Persistent cache check — skip when collectReceipts is requested
@@ -259,7 +296,7 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
259
296
  await this._restoreIndexFromCache(cached.indexTreeOid);
260
297
  }
261
298
  this._logTiming('materialize', t0, { metrics: `cache hit (ceiling=${ceiling})` });
262
- return state;
299
+ return freezePublicState(state);
263
300
  } catch {
264
301
  // Corrupted payload — self-heal by removing the bad entry
265
302
  try { await this._seekCache.delete(cacheKey); } catch { /* best-effort */ }
@@ -322,9 +359,12 @@ export async function _materializeWithCeiling(ceiling, collectReceipts, t0) {
322
359
  this._logTiming('materialize', t0, { metrics: `${allPatches.length} patches (ceiling=${ceiling})` });
323
360
 
324
361
  if (collectReceipts) {
325
- return { state, receipts: /** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts) };
362
+ return freezePublicStateWithReceipts(
363
+ state,
364
+ /** @type {TickReceipt[]} */ (receipts),
365
+ );
326
366
  }
327
- return state;
367
+ return freezePublicState(state);
328
368
  }
329
369
 
330
370
  /**
@@ -459,7 +499,7 @@ export async function materializeAt(checkpointSha) {
459
499
  codec: this._codec,
460
500
  });
461
501
  await this._setMaterializedState(state);
462
- return state;
502
+ return freezePublicState(state);
463
503
  }
464
504
 
465
505
  /**