@git-stunts/git-warp 10.8.0 → 11.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +53 -32
  2. package/SECURITY.md +64 -0
  3. package/bin/cli/commands/check.js +168 -0
  4. package/bin/cli/commands/doctor/checks.js +422 -0
  5. package/bin/cli/commands/doctor/codes.js +46 -0
  6. package/bin/cli/commands/doctor/index.js +239 -0
  7. package/bin/cli/commands/doctor/types.js +89 -0
  8. package/bin/cli/commands/history.js +73 -0
  9. package/bin/cli/commands/info.js +139 -0
  10. package/bin/cli/commands/install-hooks.js +128 -0
  11. package/bin/cli/commands/materialize.js +99 -0
  12. package/bin/cli/commands/path.js +88 -0
  13. package/bin/cli/commands/query.js +194 -0
  14. package/bin/cli/commands/registry.js +28 -0
  15. package/bin/cli/commands/seek.js +592 -0
  16. package/bin/cli/commands/trust.js +154 -0
  17. package/bin/cli/commands/verify-audit.js +113 -0
  18. package/bin/cli/commands/view.js +45 -0
  19. package/bin/cli/infrastructure.js +336 -0
  20. package/bin/cli/schemas.js +177 -0
  21. package/bin/cli/shared.js +244 -0
  22. package/bin/cli/types.js +85 -0
  23. package/bin/presenters/index.js +6 -0
  24. package/bin/presenters/text.js +136 -0
  25. package/bin/warp-graph.js +5 -2346
  26. package/index.d.ts +32 -2
  27. package/index.js +2 -0
  28. package/package.json +8 -7
  29. package/src/domain/WarpGraph.js +106 -3252
  30. package/src/domain/errors/QueryError.js +2 -2
  31. package/src/domain/errors/TrustError.js +29 -0
  32. package/src/domain/errors/index.js +1 -0
  33. package/src/domain/services/AuditMessageCodec.js +137 -0
  34. package/src/domain/services/AuditReceiptService.js +471 -0
  35. package/src/domain/services/AuditVerifierService.js +693 -0
  36. package/src/domain/services/HttpSyncServer.js +36 -22
  37. package/src/domain/services/MessageCodecInternal.js +3 -0
  38. package/src/domain/services/MessageSchemaDetector.js +2 -2
  39. package/src/domain/services/SyncAuthService.js +69 -3
  40. package/src/domain/services/WarpMessageCodec.js +4 -1
  41. package/src/domain/trust/TrustCanonical.js +42 -0
  42. package/src/domain/trust/TrustCrypto.js +111 -0
  43. package/src/domain/trust/TrustEvaluator.js +180 -0
  44. package/src/domain/trust/TrustRecordService.js +274 -0
  45. package/src/domain/trust/TrustStateBuilder.js +209 -0
  46. package/src/domain/trust/canonical.js +68 -0
  47. package/src/domain/trust/reasonCodes.js +64 -0
  48. package/src/domain/trust/schemas.js +160 -0
  49. package/src/domain/trust/verdict.js +42 -0
  50. package/src/domain/types/git-cas.d.ts +20 -0
  51. package/src/domain/utils/RefLayout.js +59 -0
  52. package/src/domain/warp/PatchSession.js +18 -0
  53. package/src/domain/warp/Writer.js +18 -3
  54. package/src/domain/warp/_internal.js +26 -0
  55. package/src/domain/warp/_wire.js +58 -0
  56. package/src/domain/warp/_wiredMethods.d.ts +100 -0
  57. package/src/domain/warp/checkpoint.methods.js +397 -0
  58. package/src/domain/warp/fork.methods.js +323 -0
  59. package/src/domain/warp/materialize.methods.js +188 -0
  60. package/src/domain/warp/materializeAdvanced.methods.js +339 -0
  61. package/src/domain/warp/patch.methods.js +529 -0
  62. package/src/domain/warp/provenance.methods.js +284 -0
  63. package/src/domain/warp/query.methods.js +279 -0
  64. package/src/domain/warp/subscribe.methods.js +272 -0
  65. package/src/domain/warp/sync.methods.js +549 -0
  66. package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
  67. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
  68. package/src/ports/CommitPort.js +10 -0
  69. package/src/ports/RefPort.js +17 -0
  70. package/src/hooks/post-merge.sh +0 -60
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Fork and wormhole methods for WarpGraph, plus backfill-rejection helpers.
3
+ *
4
+ * Every function uses `this` bound to a WarpGraph instance at runtime
5
+ * via wireWarpMethods().
6
+ *
7
+ * @module domain/warp/fork.methods
8
+ */
9
+
10
+ import { ForkError, DEFAULT_ADJACENCY_CACHE_SIZE } from './_internal.js';
11
+ import { validateGraphName, validateWriterId, buildWriterRef, buildWritersPrefix } from '../utils/RefLayout.js';
12
+ import { generateWriterId } from '../utils/WriterId.js';
13
+ import { createWormhole as createWormholeImpl } from '../services/WormholeService.js';
14
+
15
+ // ============================================================================
16
+ // Fork API
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Creates a fork of this graph at a specific point in a writer's history.
21
+ *
22
+ * A fork creates a new WarpGraph instance that shares history up to the
23
+ * specified patch SHA. Due to Git's content-addressed storage, the shared
24
+ * history is automatically deduplicated. The fork gets a new writer ID and
25
+ * operates independently from the original graph.
26
+ *
27
+ * **Key Properties:**
28
+ * - Fork materializes the same state as the original at the fork point
29
+ * - Writes to the fork don't appear in the original
30
+ * - Writes to the original after fork don't appear in the fork
31
+ * - History up to the fork point is shared (content-addressed dedup)
32
+ *
33
+ * @this {import('../WarpGraph.js').default}
34
+ * @param {Object} options - Fork configuration
35
+ * @param {string} options.from - Writer ID whose chain to fork from
36
+ * @param {string} options.at - Patch SHA to fork at (must be in the writer's chain)
37
+ * @param {string} [options.forkName] - Name for the forked graph. Defaults to `<graphName>-fork-<timestamp>`
38
+ * @param {string} [options.forkWriterId] - Writer ID for the fork. Defaults to a new canonical ID.
39
+ * @returns {Promise<import('../WarpGraph.js').default>} A new WarpGraph instance for the fork
40
+ * @throws {ForkError} If `from` writer does not exist (code: `E_FORK_WRITER_NOT_FOUND`)
41
+ * @throws {ForkError} If `at` SHA does not exist (code: `E_FORK_PATCH_NOT_FOUND`)
42
+ * @throws {ForkError} If `at` SHA is not in the writer's chain (code: `E_FORK_PATCH_NOT_IN_CHAIN`)
43
+ * @throws {ForkError} If fork graph name is invalid (code: `E_FORK_NAME_INVALID`)
44
+ * @throws {ForkError} If a graph with the fork name already has refs (code: `E_FORK_ALREADY_EXISTS`)
45
+ * @throws {ForkError} If required parameters are missing or invalid (code: `E_FORK_INVALID_ARGS`)
46
+ * @throws {ForkError} If forkWriterId is invalid (code: `E_FORK_WRITER_ID_INVALID`)
47
+ */
48
+ export async function fork({ from, at, forkName, forkWriterId }) {
49
+ const t0 = this._clock.now();
50
+
51
+ try {
52
+ // Validate required parameters
53
+ if (!from || typeof from !== 'string') {
54
+ throw new ForkError("Required parameter 'from' is missing or not a string", {
55
+ code: 'E_FORK_INVALID_ARGS',
56
+ context: { from },
57
+ });
58
+ }
59
+
60
+ if (!at || typeof at !== 'string') {
61
+ throw new ForkError("Required parameter 'at' is missing or not a string", {
62
+ code: 'E_FORK_INVALID_ARGS',
63
+ context: { at },
64
+ });
65
+ }
66
+
67
+ // 1. Validate that the `from` writer exists
68
+ const writers = await this.discoverWriters();
69
+ if (!writers.includes(from)) {
70
+ throw new ForkError(`Writer '${from}' does not exist in graph '${this._graphName}'`, {
71
+ code: 'E_FORK_WRITER_NOT_FOUND',
72
+ context: { writerId: from, graphName: this._graphName, existingWriters: writers },
73
+ });
74
+ }
75
+
76
+ // 2. Validate that `at` SHA exists in the repository
77
+ const nodeExists = await this._persistence.nodeExists(at);
78
+ if (!nodeExists) {
79
+ throw new ForkError(`Patch SHA '${at}' does not exist`, {
80
+ code: 'E_FORK_PATCH_NOT_FOUND',
81
+ context: { patchSha: at, writerId: from },
82
+ });
83
+ }
84
+
85
+ // 3. Validate that `at` SHA is in the writer's chain
86
+ const writerRef = buildWriterRef(this._graphName, from);
87
+ const tipSha = await this._persistence.readRef(writerRef);
88
+
89
+ if (!tipSha) {
90
+ throw new ForkError(`Writer '${from}' has no commits`, {
91
+ code: 'E_FORK_WRITER_NOT_FOUND',
92
+ context: { writerId: from },
93
+ });
94
+ }
95
+
96
+ // Walk the chain to verify `at` is reachable from the tip
97
+ const isInChain = await this._isAncestor(at, tipSha);
98
+ if (!isInChain) {
99
+ throw new ForkError(`Patch SHA '${at}' is not in writer '${from}' chain`, {
100
+ code: 'E_FORK_PATCH_NOT_IN_CHAIN',
101
+ context: { patchSha: at, writerId: from, tipSha },
102
+ });
103
+ }
104
+
105
+ // 4. Generate or validate fork name (add random suffix to prevent collisions)
106
+ const resolvedForkName =
107
+ forkName ?? `${this._graphName}-fork-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
108
+ try {
109
+ validateGraphName(resolvedForkName);
110
+ } catch (err) {
111
+ throw new ForkError(`Invalid fork name: ${/** @type {Error} */ (err).message}`, {
112
+ code: 'E_FORK_NAME_INVALID',
113
+ context: { forkName: resolvedForkName, originalError: /** @type {Error} */ (err).message },
114
+ });
115
+ }
116
+
117
+ // 5. Check that the fork graph doesn't already exist (has any refs)
118
+ const forkWritersPrefix = buildWritersPrefix(resolvedForkName);
119
+ const existingForkRefs = await this._persistence.listRefs(forkWritersPrefix);
120
+ if (existingForkRefs.length > 0) {
121
+ throw new ForkError(`Graph '${resolvedForkName}' already exists`, {
122
+ code: 'E_FORK_ALREADY_EXISTS',
123
+ context: { forkName: resolvedForkName, existingRefs: existingForkRefs },
124
+ });
125
+ }
126
+
127
+ // 6. Generate or validate fork writer ID
128
+ const resolvedForkWriterId = forkWriterId || generateWriterId();
129
+ try {
130
+ validateWriterId(resolvedForkWriterId);
131
+ } catch (err) {
132
+ throw new ForkError(`Invalid fork writer ID: ${/** @type {Error} */ (err).message}`, {
133
+ code: 'E_FORK_WRITER_ID_INVALID',
134
+ context: { forkWriterId: resolvedForkWriterId, originalError: /** @type {Error} */ (err).message },
135
+ });
136
+ }
137
+
138
+ // 7. Create the fork's writer ref pointing to the `at` commit
139
+ const forkWriterRef = buildWriterRef(resolvedForkName, resolvedForkWriterId);
140
+ await this._persistence.updateRef(forkWriterRef, at);
141
+
142
+ // 8. Open and return a new WarpGraph instance for the fork
143
+ // Dynamic import to avoid circular dependency (WarpGraph -> fork.methods -> WarpGraph)
144
+ const { default: WarpGraph } = await import('../WarpGraph.js');
145
+
146
+ const forkGraph = await WarpGraph.open({
147
+ persistence: this._persistence,
148
+ graphName: resolvedForkName,
149
+ writerId: resolvedForkWriterId,
150
+ gcPolicy: this._gcPolicy,
151
+ adjacencyCacheSize: this._adjacencyCache?.maxSize ?? DEFAULT_ADJACENCY_CACHE_SIZE,
152
+ checkpointPolicy: this._checkpointPolicy || undefined,
153
+ autoMaterialize: this._autoMaterialize,
154
+ onDeleteWithData: this._onDeleteWithData,
155
+ logger: this._logger || undefined,
156
+ clock: this._clock,
157
+ crypto: this._crypto,
158
+ codec: this._codec,
159
+ });
160
+
161
+ this._logTiming('fork', t0, {
162
+ metrics: `from=${from} at=${at.slice(0, 7)} name=${resolvedForkName}`,
163
+ });
164
+
165
+ return forkGraph;
166
+ } catch (err) {
167
+ this._logTiming('fork', t0, { error: /** @type {Error} */ (err) });
168
+ throw err;
169
+ }
170
+ }
171
+
172
+ // ============================================================================
173
+ // Wormhole API (HOLOGRAM)
174
+ // ============================================================================
175
+
176
+ /**
177
+ * Creates a wormhole compressing a range of patches.
178
+ *
179
+ * A wormhole is a compressed representation of a contiguous range of patches
180
+ * from a single writer. It preserves provenance by storing the original
181
+ * patches as a ProvenancePayload that can be replayed during materialization.
182
+ *
183
+ * **Key Properties:**
184
+ * - **Provenance Preservation**: The wormhole contains the full sub-payload,
185
+ * allowing exact replay of the compressed segment.
186
+ * - **Monoid Composition**: Two consecutive wormholes can be composed by
187
+ * concatenating their sub-payloads (use `WormholeService.composeWormholes`).
188
+ * - **Materialization Equivalence**: A wormhole + remaining patches produces
189
+ * the same state as materializing all patches.
190
+ *
191
+ * @this {import('../WarpGraph.js').default}
192
+ * @param {string} fromSha - SHA of the first (oldest) patch commit in the range
193
+ * @param {string} toSha - SHA of the last (newest) patch commit in the range
194
+ * @returns {Promise<{fromSha: string, toSha: string, writerId: string, payload: import('../services/ProvenancePayload.js').default, patchCount: number}>} The created wormhole edge
195
+ * @throws {import('../errors/WormholeError.js').default} If fromSha or toSha doesn't exist (E_WORMHOLE_SHA_NOT_FOUND)
196
+ * @throws {import('../errors/WormholeError.js').default} If fromSha is not an ancestor of toSha (E_WORMHOLE_INVALID_RANGE)
197
+ * @throws {import('../errors/WormholeError.js').default} If commits span multiple writers (E_WORMHOLE_MULTI_WRITER)
198
+ * @throws {import('../errors/WormholeError.js').default} If a commit is not a patch commit (E_WORMHOLE_NOT_PATCH)
199
+ */
200
+ export async function createWormhole(fromSha, toSha) {
201
+ const t0 = this._clock.now();
202
+
203
+ try {
204
+ const wormhole = await createWormholeImpl(/** @type {any} */ ({ // TODO(ts-cleanup): needs options type
205
+ persistence: this._persistence,
206
+ graphName: this._graphName,
207
+ fromSha,
208
+ toSha,
209
+ codec: this._codec,
210
+ }));
211
+
212
+ this._logTiming('createWormhole', t0, {
213
+ metrics: `${wormhole.patchCount} patches from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`,
214
+ });
215
+
216
+ return wormhole;
217
+ } catch (err) {
218
+ this._logTiming('createWormhole', t0, { error: /** @type {Error} */ (err) });
219
+ throw err;
220
+ }
221
+ }
222
+
223
+ // ============================================================================
224
+ // Backfill Rejection and Divergence Detection
225
+ // ============================================================================
226
+
227
+ /**
228
+ * Checks if ancestorSha is an ancestor of descendantSha.
229
+ * Walks the commit graph (linear per-writer chain assumption).
230
+ *
231
+ * @this {import('../WarpGraph.js').default}
232
+ * @param {string} ancestorSha - The potential ancestor commit SHA
233
+ * @param {string} descendantSha - The potential descendant commit SHA
234
+ * @returns {Promise<boolean>} True if ancestorSha is an ancestor of descendantSha
235
+ * @private
236
+ */
237
+ export async function _isAncestor(ancestorSha, descendantSha) {
238
+ if (!ancestorSha || !descendantSha) {
239
+ return false;
240
+ }
241
+ if (ancestorSha === descendantSha) {
242
+ return true;
243
+ }
244
+
245
+ let cur = descendantSha;
246
+ const MAX_WALK = 100_000;
247
+ let steps = 0;
248
+ while (cur) {
249
+ if (++steps > MAX_WALK) {
250
+ throw new Error(`_isAncestor: exceeded ${MAX_WALK} steps — possible cycle`);
251
+ }
252
+ const nodeInfo = await this._persistence.getNodeInfo(cur);
253
+ const parent = nodeInfo.parents?.[0] ?? null;
254
+ if (parent === ancestorSha) {
255
+ return true;
256
+ }
257
+ cur = parent;
258
+ }
259
+ return false;
260
+ }
261
+
262
+ /**
263
+ * Determines relationship between incoming patch and checkpoint head.
264
+ *
265
+ * @this {import('../WarpGraph.js').default}
266
+ * @param {string} ckHead - The checkpoint head SHA for this writer
267
+ * @param {string} incomingSha - The incoming patch commit SHA
268
+ * @returns {Promise<'same' | 'ahead' | 'behind' | 'diverged'>} The relationship
269
+ * @private
270
+ */
271
+ export async function _relationToCheckpointHead(ckHead, incomingSha) {
272
+ if (incomingSha === ckHead) {
273
+ return 'same';
274
+ }
275
+ if (await this._isAncestor(ckHead, incomingSha)) {
276
+ return 'ahead';
277
+ }
278
+ if (await this._isAncestor(incomingSha, ckHead)) {
279
+ return 'behind';
280
+ }
281
+ return 'diverged';
282
+ }
283
+
284
+ /**
285
+ * Validates an incoming patch against checkpoint frontier.
286
+ * Uses graph reachability, NOT lamport timestamps.
287
+ *
288
+ * @this {import('../WarpGraph.js').default}
289
+ * @param {string} writerId - The writer ID for this patch
290
+ * @param {string} incomingSha - The incoming patch commit SHA
291
+ * @param {{state: import('../services/JoinReducer.js').WarpStateV5, frontier: Map<string, string>, stateHash: string, schema: number}} checkpoint - The checkpoint to validate against
292
+ * @returns {Promise<void>}
293
+ * @throws {Error} If patch is behind/same as checkpoint frontier (backfill rejected)
294
+ * @throws {Error} If patch does not extend checkpoint head (writer fork detected)
295
+ * @private
296
+ */
297
+ export async function _validatePatchAgainstCheckpoint(writerId, incomingSha, checkpoint) {
298
+ if (!checkpoint || (checkpoint.schema !== 2 && checkpoint.schema !== 3)) {
299
+ return;
300
+ }
301
+
302
+ const ckHead = checkpoint.frontier?.get(writerId);
303
+ if (!ckHead) {
304
+ return; // Checkpoint didn't include this writer
305
+ }
306
+
307
+ const relation = await this._relationToCheckpointHead(ckHead, incomingSha);
308
+
309
+ if (relation === 'same' || relation === 'behind') {
310
+ throw new Error(
311
+ `Backfill rejected for writer ${writerId}: ` +
312
+ `incoming patch is ${relation} checkpoint frontier`
313
+ );
314
+ }
315
+
316
+ if (relation === 'diverged') {
317
+ throw new Error(
318
+ `Writer fork detected for ${writerId}: ` +
319
+ `incoming patch does not extend checkpoint head`
320
+ );
321
+ }
322
+ // relation === 'ahead' => OK
323
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Extracted materialize methods for WarpGraph.
3
+ *
4
+ * Each function is designed to be bound to a WarpGraph instance at runtime.
5
+ *
6
+ * @module domain/warp/materialize.methods
7
+ */
8
+
9
+ import { reduceV5, createEmptyStateV5, cloneStateV5 } from '../services/JoinReducer.js';
10
+ import { ProvenanceIndex } from '../services/ProvenanceIndex.js';
11
+ import { diffStates, isEmptyDiff } from '../services/StateDiff.js';
12
+
13
+ /**
14
+ * Materializes the current graph state.
15
+ *
16
+ * Discovers all writers, collects all patches from each writer's ref chain,
17
+ * and reduces them to produce the current state.
18
+ *
19
+ * Checks if a checkpoint exists and uses incremental materialization if so.
20
+ *
21
+ * When `options.receipts` is true, returns `{ state, receipts }` where
22
+ * receipts is an array of TickReceipt objects (one per applied patch).
23
+ * When false or omitted (default), returns just the state for backward
24
+ * compatibility with zero receipt overhead.
25
+ *
26
+ * When a Lamport ceiling is active (via `options.ceiling` or the
27
+ * instance-level `_seekCeiling`), delegates to a ceiling-aware path
28
+ * that replays only patches with `lamport <= ceiling`, bypassing
29
+ * checkpoints, auto-checkpoint, and GC.
30
+ *
31
+ * Side effects: Updates internal cached state, version vector, last frontier,
32
+ * and patches-since-checkpoint counter. May trigger auto-checkpoint and GC
33
+ * based on configured policies. Notifies subscribers if state changed.
34
+ *
35
+ * @this {import('../WarpGraph.js').default}
36
+ * @param {{receipts?: boolean, ceiling?: number|null}} [options] - Optional configuration
37
+ * @returns {Promise<import('../services/JoinReducer.js').WarpStateV5|{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}>} The materialized graph state, or { state, receipts } when receipts enabled
38
+ * @throws {Error} If checkpoint loading fails or patch decoding fails
39
+ * @throws {Error} If writer ref access or patch blob reading fails
40
+ */
41
+ export async function materialize(options) {
42
+ const t0 = this._clock.now();
43
+ // ZERO-COST: only resolve receipts flag when options provided
44
+ const collectReceipts = options && options.receipts;
45
+ // Resolve ceiling: explicit option > instance-level seek ceiling > null (latest)
46
+ const ceiling = this._resolveCeiling(options);
47
+
48
+ try {
49
+ // When ceiling is active, delegate to ceiling-aware path (with its own cache)
50
+ if (ceiling !== null) {
51
+ return await this._materializeWithCeiling(ceiling, !!collectReceipts, t0);
52
+ }
53
+
54
+ // Check for checkpoint
55
+ const checkpoint = await this._loadLatestCheckpoint();
56
+
57
+ /** @type {import('../services/JoinReducer.js').WarpStateV5|undefined} */
58
+ let state;
59
+ /** @type {import('../types/TickReceipt.js').TickReceipt[]|undefined} */
60
+ let receipts;
61
+ let patchCount = 0;
62
+
63
+ // If checkpoint exists, use incremental materialization
64
+ if (checkpoint?.schema === 2 || checkpoint?.schema === 3) {
65
+ const patches = await this._loadPatchesSince(checkpoint);
66
+ if (collectReceipts) {
67
+ const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}} */ (reduceV5(/** @type {any} */ (patches), /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (checkpoint.state), { receipts: true })); // TODO(ts-cleanup): type patch array
68
+ state = result.state;
69
+ receipts = result.receipts;
70
+ } else {
71
+ state = /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {any} */ (patches), /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (checkpoint.state))); // TODO(ts-cleanup): type patch array
72
+ }
73
+ patchCount = patches.length;
74
+
75
+ // Build provenance index: start from checkpoint index if present, then add new patches
76
+ const ckPI = /** @type {any} */ (checkpoint).provenanceIndex; // TODO(ts-cleanup): type checkpoint cast
77
+ this._provenanceIndex = ckPI
78
+ ? ckPI.clone()
79
+ : new ProvenanceIndex();
80
+ for (const { patch, sha } of patches) {
81
+ /** @type {import('../services/ProvenanceIndex.js').ProvenanceIndex} */ (this._provenanceIndex).addPatch(sha, patch.reads, patch.writes);
82
+ }
83
+ } else {
84
+ // 1. Discover all writers
85
+ const writerIds = await this.discoverWriters();
86
+
87
+ // 2. If no writers, return empty state
88
+ if (writerIds.length === 0) {
89
+ state = createEmptyStateV5();
90
+ this._provenanceIndex = new ProvenanceIndex();
91
+ if (collectReceipts) {
92
+ receipts = [];
93
+ }
94
+ } else {
95
+ // 3. For each writer, collect all patches
96
+ const allPatches = [];
97
+ for (const writerId of writerIds) {
98
+ const writerPatches = await this._loadWriterPatches(writerId);
99
+ for (const p of writerPatches) {
100
+ allPatches.push(p);
101
+ }
102
+ }
103
+
104
+ // 4. If no patches, return empty state
105
+ if (allPatches.length === 0) {
106
+ state = createEmptyStateV5();
107
+ this._provenanceIndex = new ProvenanceIndex();
108
+ if (collectReceipts) {
109
+ receipts = [];
110
+ }
111
+ } else {
112
+ // 5. Reduce all patches to state
113
+ if (collectReceipts) {
114
+ const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}} */ (reduceV5(/** @type {any} */ (allPatches), undefined, { receipts: true })); // TODO(ts-cleanup): type patch array
115
+ state = result.state;
116
+ receipts = result.receipts;
117
+ } else {
118
+ state = /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {any} */ (allPatches))); // TODO(ts-cleanup): type patch array
119
+ }
120
+ patchCount = allPatches.length;
121
+
122
+ // Build provenance index from all patches
123
+ this._provenanceIndex = new ProvenanceIndex();
124
+ for (const { patch, sha } of allPatches) {
125
+ this._provenanceIndex.addPatch(sha, patch.reads, patch.writes);
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ await this._setMaterializedState(state);
132
+ this._provenanceDegraded = false;
133
+ this._cachedCeiling = null;
134
+ this._cachedFrontier = null;
135
+ this._lastFrontier = await this.getFrontier();
136
+ this._patchesSinceCheckpoint = patchCount;
137
+
138
+ // Auto-checkpoint if policy is set and threshold exceeded.
139
+ // Guard prevents recursion: createCheckpoint() calls materialize() internally.
140
+ if (this._checkpointPolicy && !this._checkpointing && patchCount >= this._checkpointPolicy.every) {
141
+ try {
142
+ await this.createCheckpoint();
143
+ this._patchesSinceCheckpoint = 0;
144
+ } catch {
145
+ // Checkpoint failure does not break materialize — continue silently
146
+ }
147
+ }
148
+
149
+ this._maybeRunGC(state);
150
+
151
+ // Notify subscribers if state changed since last notification
152
+ // Also handles deferred replay for subscribers added with replay: true before cached state
153
+ if (this._subscribers.length > 0) {
154
+ const hasPendingReplay = this._subscribers.some(s => s.pendingReplay);
155
+ const diff = diffStates(this._lastNotifiedState, state);
156
+ if (!isEmptyDiff(diff) || hasPendingReplay) {
157
+ this._notifySubscribers(diff, state);
158
+ }
159
+ }
160
+ // Clone state to prevent eager path mutations from affecting the baseline
161
+ this._lastNotifiedState = cloneStateV5(state);
162
+
163
+ this._logTiming('materialize', t0, { metrics: `${patchCount} patches` });
164
+
165
+ if (collectReceipts) {
166
+ return { state, receipts: /** @type {import('../types/TickReceipt.js').TickReceipt[]} */ (receipts) };
167
+ }
168
+ return state;
169
+ } catch (err) {
170
+ this._logTiming('materialize', t0, { error: /** @type {Error} */ (err) });
171
+ throw err;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Materializes the graph and returns the materialized graph details.
177
+ *
178
+ * @this {import('../WarpGraph.js').default}
179
+ * @returns {Promise<object>}
180
+ * @private
181
+ */
182
+ export async function _materializeGraph() {
183
+ const state = await this.materialize();
184
+ if (!this._materializedGraph || this._materializedGraph.state !== state) {
185
+ await this._setMaterializedState(/** @type {import('../services/JoinReducer.js').WarpStateV5} */ (state));
186
+ }
187
+ return /** @type {object} */ (this._materializedGraph);
188
+ }