@git-stunts/git-warp 10.1.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 (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +16 -0
  3. package/README.md +480 -0
  4. package/SECURITY.md +30 -0
  5. package/bin/git-warp +24 -0
  6. package/bin/warp-graph.js +1574 -0
  7. package/index.d.ts +2366 -0
  8. package/index.js +180 -0
  9. package/package.json +129 -0
  10. package/scripts/install-git-warp.sh +258 -0
  11. package/scripts/uninstall-git-warp.sh +139 -0
  12. package/src/domain/WarpGraph.js +3157 -0
  13. package/src/domain/crdt/Dot.js +160 -0
  14. package/src/domain/crdt/LWW.js +154 -0
  15. package/src/domain/crdt/ORSet.js +371 -0
  16. package/src/domain/crdt/VersionVector.js +222 -0
  17. package/src/domain/entities/GraphNode.js +60 -0
  18. package/src/domain/errors/EmptyMessageError.js +47 -0
  19. package/src/domain/errors/ForkError.js +30 -0
  20. package/src/domain/errors/IndexError.js +23 -0
  21. package/src/domain/errors/OperationAbortedError.js +22 -0
  22. package/src/domain/errors/QueryError.js +39 -0
  23. package/src/domain/errors/SchemaUnsupportedError.js +17 -0
  24. package/src/domain/errors/ShardCorruptionError.js +56 -0
  25. package/src/domain/errors/ShardLoadError.js +57 -0
  26. package/src/domain/errors/ShardValidationError.js +61 -0
  27. package/src/domain/errors/StorageError.js +57 -0
  28. package/src/domain/errors/SyncError.js +30 -0
  29. package/src/domain/errors/TraversalError.js +23 -0
  30. package/src/domain/errors/WarpError.js +31 -0
  31. package/src/domain/errors/WormholeError.js +28 -0
  32. package/src/domain/errors/WriterError.js +39 -0
  33. package/src/domain/errors/index.js +21 -0
  34. package/src/domain/services/AnchorMessageCodec.js +99 -0
  35. package/src/domain/services/BitmapIndexBuilder.js +225 -0
  36. package/src/domain/services/BitmapIndexReader.js +435 -0
  37. package/src/domain/services/BoundaryTransitionRecord.js +463 -0
  38. package/src/domain/services/CheckpointMessageCodec.js +147 -0
  39. package/src/domain/services/CheckpointSerializerV5.js +281 -0
  40. package/src/domain/services/CheckpointService.js +384 -0
  41. package/src/domain/services/CommitDagTraversalService.js +156 -0
  42. package/src/domain/services/DagPathFinding.js +712 -0
  43. package/src/domain/services/DagTopology.js +239 -0
  44. package/src/domain/services/DagTraversal.js +245 -0
  45. package/src/domain/services/Frontier.js +108 -0
  46. package/src/domain/services/GCMetrics.js +101 -0
  47. package/src/domain/services/GCPolicy.js +122 -0
  48. package/src/domain/services/GitLogParser.js +205 -0
  49. package/src/domain/services/HealthCheckService.js +246 -0
  50. package/src/domain/services/HookInstaller.js +326 -0
  51. package/src/domain/services/HttpSyncServer.js +262 -0
  52. package/src/domain/services/IndexRebuildService.js +426 -0
  53. package/src/domain/services/IndexStalenessChecker.js +103 -0
  54. package/src/domain/services/JoinReducer.js +582 -0
  55. package/src/domain/services/KeyCodec.js +113 -0
  56. package/src/domain/services/LegacyAnchorDetector.js +67 -0
  57. package/src/domain/services/LogicalTraversal.js +351 -0
  58. package/src/domain/services/MessageCodecInternal.js +132 -0
  59. package/src/domain/services/MessageSchemaDetector.js +145 -0
  60. package/src/domain/services/MigrationService.js +55 -0
  61. package/src/domain/services/ObserverView.js +265 -0
  62. package/src/domain/services/PatchBuilderV2.js +669 -0
  63. package/src/domain/services/PatchMessageCodec.js +140 -0
  64. package/src/domain/services/ProvenanceIndex.js +337 -0
  65. package/src/domain/services/ProvenancePayload.js +242 -0
  66. package/src/domain/services/QueryBuilder.js +835 -0
  67. package/src/domain/services/StateDiff.js +300 -0
  68. package/src/domain/services/StateSerializerV5.js +156 -0
  69. package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
  70. package/src/domain/services/SyncProtocol.js +593 -0
  71. package/src/domain/services/TemporalQuery.js +201 -0
  72. package/src/domain/services/TranslationCost.js +221 -0
  73. package/src/domain/services/TraversalService.js +8 -0
  74. package/src/domain/services/WarpMessageCodec.js +29 -0
  75. package/src/domain/services/WarpStateIndexBuilder.js +127 -0
  76. package/src/domain/services/WormholeService.js +353 -0
  77. package/src/domain/types/TickReceipt.js +285 -0
  78. package/src/domain/types/WarpTypes.js +209 -0
  79. package/src/domain/types/WarpTypesV2.js +200 -0
  80. package/src/domain/utils/CachedValue.js +140 -0
  81. package/src/domain/utils/EventId.js +89 -0
  82. package/src/domain/utils/LRUCache.js +112 -0
  83. package/src/domain/utils/MinHeap.js +114 -0
  84. package/src/domain/utils/RefLayout.js +280 -0
  85. package/src/domain/utils/WriterId.js +205 -0
  86. package/src/domain/utils/cancellation.js +33 -0
  87. package/src/domain/utils/canonicalStringify.js +42 -0
  88. package/src/domain/utils/defaultClock.js +20 -0
  89. package/src/domain/utils/defaultCodec.js +51 -0
  90. package/src/domain/utils/nullLogger.js +21 -0
  91. package/src/domain/utils/roaring.js +181 -0
  92. package/src/domain/utils/shardVersion.js +9 -0
  93. package/src/domain/warp/PatchSession.js +217 -0
  94. package/src/domain/warp/Writer.js +181 -0
  95. package/src/hooks/post-merge.sh +60 -0
  96. package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
  97. package/src/infrastructure/adapters/ClockAdapter.js +57 -0
  98. package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
  99. package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
  100. package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
  101. package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
  102. package/src/infrastructure/adapters/NoOpLogger.js +62 -0
  103. package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
  104. package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
  105. package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
  106. package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
  107. package/src/infrastructure/codecs/CborCodec.js +384 -0
  108. package/src/ports/BlobPort.js +30 -0
  109. package/src/ports/ClockPort.js +25 -0
  110. package/src/ports/CodecPort.js +25 -0
  111. package/src/ports/CommitPort.js +114 -0
  112. package/src/ports/ConfigPort.js +31 -0
  113. package/src/ports/CryptoPort.js +38 -0
  114. package/src/ports/GraphPersistencePort.js +57 -0
  115. package/src/ports/HttpServerPort.js +25 -0
  116. package/src/ports/IndexStoragePort.js +39 -0
  117. package/src/ports/LoggerPort.js +68 -0
  118. package/src/ports/RefPort.js +51 -0
  119. package/src/ports/TreePort.js +51 -0
  120. package/src/visualization/index.js +26 -0
  121. package/src/visualization/layouts/converters.js +75 -0
  122. package/src/visualization/layouts/elkAdapter.js +86 -0
  123. package/src/visualization/layouts/elkLayout.js +95 -0
  124. package/src/visualization/layouts/index.js +29 -0
  125. package/src/visualization/renderers/ascii/box.js +16 -0
  126. package/src/visualization/renderers/ascii/check.js +271 -0
  127. package/src/visualization/renderers/ascii/colors.js +13 -0
  128. package/src/visualization/renderers/ascii/formatters.js +73 -0
  129. package/src/visualization/renderers/ascii/graph.js +344 -0
  130. package/src/visualization/renderers/ascii/history.js +335 -0
  131. package/src/visualization/renderers/ascii/index.js +14 -0
  132. package/src/visualization/renderers/ascii/info.js +245 -0
  133. package/src/visualization/renderers/ascii/materialize.js +255 -0
  134. package/src/visualization/renderers/ascii/path.js +240 -0
  135. package/src/visualization/renderers/ascii/progress.js +32 -0
  136. package/src/visualization/renderers/ascii/symbols.js +33 -0
  137. package/src/visualization/renderers/ascii/table.js +19 -0
  138. package/src/visualization/renderers/browser/index.js +1 -0
  139. package/src/visualization/renderers/svg/index.js +159 -0
  140. package/src/visualization/utils/ansi.js +14 -0
  141. package/src/visualization/utils/time.js +40 -0
  142. package/src/visualization/utils/truncate.js +40 -0
  143. package/src/visualization/utils/unicode.js +52 -0
@@ -0,0 +1,3157 @@
1
+ /**
2
+ * WarpGraph - Main API class for WARP multi-writer graph database.
3
+ *
4
+ * Provides a factory for opening multi-writer graphs and methods for
5
+ * creating patches, materializing state, and managing checkpoints.
6
+ *
7
+ * @module domain/WarpGraph
8
+ * @see WARP Spec Section 11
9
+ */
10
+
11
+ import { validateGraphName, validateWriterId, buildWriterRef, buildCoverageRef, buildCheckpointRef, buildWritersPrefix, parseWriterIdFromRef } from './utils/RefLayout.js';
12
+ import { PatchBuilderV2 } from './services/PatchBuilderV2.js';
13
+ import { reduceV5, createEmptyStateV5, joinStates, join as joinPatch, cloneStateV5 } from './services/JoinReducer.js';
14
+ import { decodeEdgeKey, decodePropKey, isEdgePropKey, decodeEdgePropKey, encodeEdgeKey } from './services/KeyCodec.js';
15
+ import { ProvenanceIndex } from './services/ProvenanceIndex.js';
16
+ import { ProvenancePayload } from './services/ProvenancePayload.js';
17
+ import { diffStates, isEmptyDiff } from './services/StateDiff.js';
18
+ import { orsetContains, orsetElements } from './crdt/ORSet.js';
19
+ import defaultCodec from './utils/defaultCodec.js';
20
+ import { decodePatchMessage, detectMessageKind, encodeAnchorMessage } from './services/WarpMessageCodec.js';
21
+ import { loadCheckpoint, materializeIncremental, create as createCheckpointCommit } from './services/CheckpointService.js';
22
+ import { createFrontier, updateFrontier } from './services/Frontier.js';
23
+ import { createVersionVector, vvClone, vvIncrement } from './crdt/VersionVector.js';
24
+ import { DEFAULT_GC_POLICY, shouldRunGC, executeGC } from './services/GCPolicy.js';
25
+ import { collectGCMetrics } from './services/GCMetrics.js';
26
+ import { computeAppliedVV } from './services/CheckpointSerializerV5.js';
27
+ import { computeStateHashV5 } from './services/StateSerializerV5.js';
28
+ import {
29
+ createSyncRequest,
30
+ processSyncRequest,
31
+ applySyncResponse,
32
+ syncNeeded,
33
+ } from './services/SyncProtocol.js';
34
+ import { retry, timeout, RetryExhaustedError, TimeoutError } from '@git-stunts/alfred';
35
+ import { Writer } from './warp/Writer.js';
36
+ import { generateWriterId, resolveWriterId } from './utils/WriterId.js';
37
+ import QueryBuilder from './services/QueryBuilder.js';
38
+ import LogicalTraversal from './services/LogicalTraversal.js';
39
+ import ObserverView from './services/ObserverView.js';
40
+ import { computeTranslationCost } from './services/TranslationCost.js';
41
+ import LRUCache from './utils/LRUCache.js';
42
+ import SyncError from './errors/SyncError.js';
43
+ import QueryError from './errors/QueryError.js';
44
+ import ForkError from './errors/ForkError.js';
45
+ import { createWormhole as createWormholeImpl } from './services/WormholeService.js';
46
+ import { checkAborted } from './utils/cancellation.js';
47
+ import OperationAbortedError from './errors/OperationAbortedError.js';
48
+ import { compareEventIds } from './utils/EventId.js';
49
+ import { TemporalQuery } from './services/TemporalQuery.js';
50
+ import HttpSyncServer from './services/HttpSyncServer.js';
51
+ import defaultClock from './utils/defaultClock.js';
52
+
53
+ const DEFAULT_SYNC_SERVER_MAX_BYTES = 4 * 1024 * 1024;
54
+ const DEFAULT_SYNC_WITH_RETRIES = 3;
55
+ const DEFAULT_SYNC_WITH_BASE_DELAY_MS = 250;
56
+ const DEFAULT_SYNC_WITH_MAX_DELAY_MS = 2000;
57
+ const DEFAULT_SYNC_WITH_TIMEOUT_MS = 10_000;
58
+
59
+ /**
60
+ * Normalizes a sync endpoint path to ensure it starts with '/'.
61
+ * Returns '/sync' if no path is provided.
62
+ *
63
+ * @param {string|undefined|null} path - The sync path to normalize
64
+ * @returns {string} Normalized path starting with '/'
65
+ * @private
66
+ */
67
+ function normalizeSyncPath(path) {
68
+ if (!path) {
69
+ return '/sync';
70
+ }
71
+ return path.startsWith('/') ? path : `/${path}`;
72
+ }
73
+
74
+ const DEFAULT_ADJACENCY_CACHE_SIZE = 3;
75
+
76
+ /**
77
+ * @typedef {Object} MaterializedGraph
78
+ * @property {import('./services/JoinReducer.js').WarpStateV5} state
79
+ * @property {string} stateHash
80
+ * @property {{outgoing: Map<string, Array<{neighborId: string, label: string}>>, incoming: Map<string, Array<{neighborId: string, label: string}>>}} adjacency
81
+ */
82
+
83
+ /**
84
+ * WarpGraph class for interacting with a WARP multi-writer graph.
85
+ */
86
+ export default class WarpGraph {
87
+ /**
88
+ * @private
89
+ * @param {Object} options
90
+ * @param {import('../ports/GraphPersistencePort.js').default} options.persistence - Git adapter
91
+ * @param {string} options.graphName - Graph namespace
92
+ * @param {string} options.writerId - This writer's ID
93
+ * @param {Object} [options.gcPolicy] - GC policy configuration (overrides defaults)
94
+ * @param {number} [options.adjacencyCacheSize] - Max materialized adjacency cache entries
95
+ * @param {{every: number}} [options.checkpointPolicy] - Auto-checkpoint policy; creates a checkpoint every N patches
96
+ * @param {boolean} [options.autoMaterialize=false] - If true, query methods auto-materialize instead of throwing
97
+ * @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node that still has edges or properties
98
+ * @param {import('../ports/LoggerPort.js').default} [options.logger] - Logger for structured logging
99
+ * @param {import('../ports/ClockPort.js').default} [options.clock] - Clock for timing instrumentation (defaults to performance-based clock)
100
+ * @param {import('../ports/CryptoPort.js').default} [options.crypto] - Crypto adapter for hashing
101
+ * @param {import('../ports/CodecPort.js').default} [options.codec] - Codec for CBOR serialization (defaults to domain-local codec)
102
+ */
103
+ constructor({ persistence, graphName, writerId, gcPolicy = {}, adjacencyCacheSize = DEFAULT_ADJACENCY_CACHE_SIZE, checkpointPolicy, autoMaterialize = false, onDeleteWithData = 'warn', logger, clock, crypto, codec }) {
104
+ /** @type {import('../ports/GraphPersistencePort.js').default} */
105
+ this._persistence = persistence;
106
+
107
+ /** @type {string} */
108
+ this._graphName = graphName;
109
+
110
+ /** @type {string} */
111
+ this._writerId = writerId;
112
+
113
+ /** @type {import('./crdt/VersionVector.js').VersionVector} */
114
+ this._versionVector = createVersionVector();
115
+
116
+ /** @type {import('./services/JoinReducer.js').WarpStateV5|null} */
117
+ this._cachedState = null;
118
+
119
+ /** @type {boolean} */
120
+ this._stateDirty = false;
121
+
122
+ /** @type {Object} */
123
+ this._gcPolicy = { ...DEFAULT_GC_POLICY, ...gcPolicy };
124
+
125
+ /** @type {number} */
126
+ this._lastGCTime = 0;
127
+
128
+ /** @type {number} */
129
+ this._patchesSinceGC = 0;
130
+
131
+ /** @type {number} */
132
+ this._patchesSinceCheckpoint = 0;
133
+
134
+ /** @type {{every: number}|null} */
135
+ this._checkpointPolicy = checkpointPolicy || null;
136
+
137
+ /** @type {boolean} */
138
+ this._checkpointing = false;
139
+
140
+ /** @type {boolean} */
141
+ this._autoMaterialize = autoMaterialize;
142
+
143
+ /** @type {LogicalTraversal} */
144
+ this.traverse = new LogicalTraversal(this);
145
+
146
+ /** @type {MaterializedGraph|null} */
147
+ this._materializedGraph = null;
148
+
149
+ /** @type {import('./utils/LRUCache.js').default|null} */
150
+ this._adjacencyCache = adjacencyCacheSize > 0 ? new LRUCache(adjacencyCacheSize) : null;
151
+
152
+ /** @type {Map<string, string>|null} */
153
+ this._lastFrontier = null;
154
+
155
+ /** @type {import('../ports/LoggerPort.js').default|null} */
156
+ this._logger = logger || null;
157
+
158
+ /** @type {import('../ports/ClockPort.js').default} */
159
+ this._clock = clock || defaultClock;
160
+
161
+ /** @type {import('../ports/CryptoPort.js').default|undefined} */
162
+ this._crypto = crypto;
163
+
164
+ /** @type {import('../ports/CodecPort.js').default} */
165
+ this._codec = codec || defaultCodec;
166
+
167
+ /** @type {'reject'|'cascade'|'warn'} */
168
+ this._onDeleteWithData = onDeleteWithData;
169
+
170
+ /** @type {Array<{onChange: Function, onError?: Function}>} */
171
+ this._subscribers = [];
172
+
173
+ /** @type {import('./services/JoinReducer.js').WarpStateV5|null} */
174
+ this._lastNotifiedState = null;
175
+
176
+ /** @type {import('./services/ProvenanceIndex.js').ProvenanceIndex|null} */
177
+ this._provenanceIndex = null;
178
+
179
+ /** @type {import('./services/TemporalQuery.js').TemporalQuery|null} */
180
+ this._temporalQuery = null;
181
+ }
182
+
183
+ /**
184
+ * Logs a timing message for a completed or failed operation.
185
+ * @param {string} op - Operation name (e.g. 'materialize')
186
+ * @param {number} t0 - Start timestamp from this._clock.now()
187
+ * @param {Object} [opts] - Options
188
+ * @param {string} [opts.metrics] - Extra metrics string to append in parentheses
189
+ * @param {Error} [opts.error] - If set, logs a failure message instead
190
+ * @private
191
+ */
192
+ _logTiming(op, t0, { metrics, error } = {}) {
193
+ if (!this._logger) {
194
+ return;
195
+ }
196
+ const elapsed = Math.round(this._clock.now() - t0);
197
+ if (error) {
198
+ this._logger.info(`[warp] ${op} failed in ${elapsed}ms`, { error: error.message });
199
+ } else {
200
+ const suffix = metrics ? ` (${metrics})` : '';
201
+ this._logger.info(`[warp] ${op} completed in ${elapsed}ms${suffix}`);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Opens a multi-writer graph.
207
+ *
208
+ * @param {Object} options
209
+ * @param {import('../ports/GraphPersistencePort.js').default} options.persistence - Git adapter
210
+ * @param {string} options.graphName - Graph namespace
211
+ * @param {string} options.writerId - This writer's ID
212
+ * @param {Object} [options.gcPolicy] - GC policy configuration (overrides defaults)
213
+ * @param {number} [options.adjacencyCacheSize] - Max materialized adjacency cache entries
214
+ * @param {{every: number}} [options.checkpointPolicy] - Auto-checkpoint policy; creates a checkpoint every N patches
215
+ * @param {boolean} [options.autoMaterialize] - If true, query methods auto-materialize instead of throwing
216
+ * @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData] - Policy when deleting a node that still has edges or properties (default: 'warn')
217
+ * @param {import('../ports/LoggerPort.js').default} [options.logger] - Logger for structured logging
218
+ * @param {import('../ports/ClockPort.js').default} [options.clock] - Clock for timing instrumentation (defaults to performance-based clock)
219
+ * @param {import('../ports/CryptoPort.js').default} [options.crypto] - Crypto adapter for hashing
220
+ * @param {import('../ports/CodecPort.js').default} [options.codec] - Codec for CBOR serialization (defaults to domain-local codec)
221
+ * @returns {Promise<WarpGraph>} The opened graph instance
222
+ * @throws {Error} If graphName, writerId, checkpointPolicy, or onDeleteWithData is invalid
223
+ *
224
+ * @example
225
+ * const graph = await WarpGraph.open({
226
+ * persistence: gitAdapter,
227
+ * graphName: 'events',
228
+ * writerId: 'node-1'
229
+ * });
230
+ */
231
+ static async open({ persistence, graphName, writerId, gcPolicy = {}, adjacencyCacheSize, checkpointPolicy, autoMaterialize, onDeleteWithData, logger, clock, crypto, codec }) {
232
+ // Validate inputs
233
+ validateGraphName(graphName);
234
+ validateWriterId(writerId);
235
+
236
+ if (!persistence) {
237
+ throw new Error('persistence is required');
238
+ }
239
+
240
+ // Validate checkpointPolicy
241
+ if (checkpointPolicy !== undefined && checkpointPolicy !== null) {
242
+ if (typeof checkpointPolicy !== 'object' || checkpointPolicy === null) {
243
+ throw new Error('checkpointPolicy must be an object with { every: number }');
244
+ }
245
+ if (!Number.isInteger(checkpointPolicy.every) || checkpointPolicy.every <= 0) {
246
+ throw new Error('checkpointPolicy.every must be a positive integer');
247
+ }
248
+ }
249
+
250
+ // Validate autoMaterialize
251
+ if (autoMaterialize !== undefined && typeof autoMaterialize !== 'boolean') {
252
+ throw new Error('autoMaterialize must be a boolean');
253
+ }
254
+
255
+ // Validate onDeleteWithData
256
+ if (onDeleteWithData !== undefined) {
257
+ const valid = ['reject', 'cascade', 'warn'];
258
+ if (!valid.includes(onDeleteWithData)) {
259
+ throw new Error(`onDeleteWithData must be one of: ${valid.join(', ')}`);
260
+ }
261
+ }
262
+
263
+ const graph = new WarpGraph({ persistence, graphName, writerId, gcPolicy, adjacencyCacheSize, checkpointPolicy, autoMaterialize, onDeleteWithData, logger, clock, crypto, codec });
264
+
265
+ // Validate migration boundary
266
+ await graph._validateMigrationBoundary();
267
+
268
+ return graph;
269
+ }
270
+
271
+ /**
272
+ * Gets the graph name.
273
+ * @returns {string} The graph name
274
+ */
275
+ get graphName() {
276
+ return this._graphName;
277
+ }
278
+
279
+ /**
280
+ * Gets the writer ID.
281
+ * @returns {string} The writer ID
282
+ */
283
+ get writerId() {
284
+ return this._writerId;
285
+ }
286
+
287
+ /**
288
+ * Gets the persistence adapter.
289
+ * @returns {import('../ports/GraphPersistencePort.js').default} The persistence adapter
290
+ */
291
+ get persistence() {
292
+ return this._persistence;
293
+ }
294
+
295
+ /**
296
+ * Gets the onDeleteWithData policy.
297
+ * @returns {'reject'|'cascade'|'warn'} The delete-with-data policy
298
+ */
299
+ get onDeleteWithData() {
300
+ return this._onDeleteWithData;
301
+ }
302
+
303
+ /**
304
+ * Creates a new PatchBuilder for building and committing patches.
305
+ *
306
+ * On successful commit, the internal `onCommitSuccess` callback receives
307
+ * `{ patch, sha }` where `patch` is the committed patch object and `sha`
308
+ * is the Git commit SHA. This updates the version vector and applies the
309
+ * patch to cached state for eager re-materialization.
310
+ *
311
+ * @returns {Promise<PatchBuilderV2>} A fluent patch builder
312
+ *
313
+ * @example
314
+ * const commitSha = await (await graph.createPatch())
315
+ * .addNode('user:alice')
316
+ * .setProperty('user:alice', 'name', 'Alice')
317
+ * .addEdge('user:alice', 'user:bob', 'follows')
318
+ * .commit();
319
+ */
320
+ async createPatch() {
321
+ const { lamport, parentSha } = await this._nextLamport();
322
+ return new PatchBuilderV2({
323
+ persistence: this._persistence,
324
+ graphName: this._graphName,
325
+ writerId: this._writerId,
326
+ lamport,
327
+ versionVector: this._versionVector,
328
+ getCurrentState: () => this._cachedState,
329
+ expectedParentSha: parentSha,
330
+ onDeleteWithData: this._onDeleteWithData,
331
+ onCommitSuccess: (opts) => this._onPatchCommitted(this._writerId, opts),
332
+ codec: this._codec,
333
+ logger: this._logger,
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Returns patches from a writer's ref chain.
339
+ *
340
+ * @param {string} writerId - The writer ID to load patches for
341
+ * @param {string|null} [stopAtSha=null] - Stop walking when reaching this SHA (exclusive)
342
+ * @returns {Promise<Array<{patch: import('./types/WarpTypes.js').PatchV1, sha: string}>>} Array of patches
343
+ */
344
+ async getWriterPatches(writerId, stopAtSha = null) {
345
+ return await this._loadWriterPatches(writerId, stopAtSha);
346
+ }
347
+
348
+ /**
349
+ * Gets the next lamport timestamp and current parent SHA for this writer.
350
+ * Reads from the current ref chain to determine values.
351
+ *
352
+ * @returns {Promise<{lamport: number, parentSha: string|null}>} The next lamport and current parent
353
+ * @private
354
+ */
355
+ async _nextLamport() {
356
+ const writerRef = buildWriterRef(this._graphName, this._writerId);
357
+ const currentRefSha = await this._persistence.readRef(writerRef);
358
+
359
+ if (!currentRefSha) {
360
+ // First commit for this writer
361
+ return { lamport: 1, parentSha: null };
362
+ }
363
+
364
+ // Read the current patch commit to get its lamport timestamp
365
+ const commitMessage = await this._persistence.showNode(currentRefSha);
366
+ const kind = detectMessageKind(commitMessage);
367
+
368
+ if (kind !== 'patch') {
369
+ // Writer ref doesn't point to a patch commit - treat as first commit
370
+ return { lamport: 1, parentSha: currentRefSha };
371
+ }
372
+
373
+ try {
374
+ const patchInfo = decodePatchMessage(commitMessage);
375
+ return { lamport: patchInfo.lamport + 1, parentSha: currentRefSha };
376
+ } catch {
377
+ // Malformed message - error with actionable message
378
+ throw new Error(
379
+ `Failed to parse lamport from writer ref ${writerRef}: ` +
380
+ `commit ${currentRefSha} has invalid patch message format`
381
+ );
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Loads all patches from a writer's ref chain.
387
+ *
388
+ * Walks commits from the tip SHA back to the first patch commit,
389
+ * collecting all patches along the way.
390
+ *
391
+ * @param {string} writerId - The writer ID to load patches for
392
+ * @param {string|null} [stopAtSha=null] - Stop walking when reaching this SHA (exclusive)
393
+ * @returns {Promise<Array<{patch: import('./types/WarpTypes.js').PatchV1, sha: string}>>} Array of patches
394
+ * @private
395
+ */
396
+ async _loadWriterPatches(writerId, stopAtSha = null) {
397
+ const writerRef = buildWriterRef(this._graphName, writerId);
398
+ const tipSha = await this._persistence.readRef(writerRef);
399
+
400
+ if (!tipSha) {
401
+ return [];
402
+ }
403
+
404
+ const patches = [];
405
+ let currentSha = tipSha;
406
+
407
+ while (currentSha && currentSha !== stopAtSha) {
408
+ // Get commit info and message
409
+ const nodeInfo = await this._persistence.getNodeInfo(currentSha);
410
+ const {message} = nodeInfo;
411
+
412
+ // Check if this is a patch commit
413
+ const kind = detectMessageKind(message);
414
+ if (kind !== 'patch') {
415
+ // Not a patch commit, stop walking
416
+ break;
417
+ }
418
+
419
+ // Decode the patch message to get patchOid
420
+ const patchMeta = decodePatchMessage(message);
421
+
422
+ // Read the patch blob
423
+ const patchBuffer = await this._persistence.readBlob(patchMeta.patchOid);
424
+ const patch = this._codec.decode(patchBuffer);
425
+
426
+ patches.push({ patch, sha: currentSha });
427
+
428
+ // Move to parent commit
429
+ if (nodeInfo.parents && nodeInfo.parents.length > 0) {
430
+ currentSha = nodeInfo.parents[0];
431
+ } else {
432
+ break;
433
+ }
434
+ }
435
+
436
+ // Patches are collected in reverse order (newest first), reverse them
437
+ return patches.reverse();
438
+ }
439
+
440
+ /**
441
+ * Builds a deterministic adjacency map for the logical graph.
442
+ * @param {import('./services/JoinReducer.js').WarpStateV5} state
443
+ * @returns {{outgoing: Map<string, Array<{neighborId: string, label: string}>>, incoming: Map<string, Array<{neighborId: string, label: string}>>}}
444
+ * @private
445
+ */
446
+ _buildAdjacency(state) {
447
+ const outgoing = new Map();
448
+ const incoming = new Map();
449
+
450
+ for (const edgeKey of orsetElements(state.edgeAlive)) {
451
+ const { from, to, label } = decodeEdgeKey(edgeKey);
452
+
453
+ if (!orsetContains(state.nodeAlive, from) || !orsetContains(state.nodeAlive, to)) {
454
+ continue;
455
+ }
456
+
457
+ if (!outgoing.has(from)) {
458
+ outgoing.set(from, []);
459
+ }
460
+ if (!incoming.has(to)) {
461
+ incoming.set(to, []);
462
+ }
463
+
464
+ outgoing.get(from).push({ neighborId: to, label });
465
+ incoming.get(to).push({ neighborId: from, label });
466
+ }
467
+
468
+ const sortNeighbors = (list) => {
469
+ list.sort((a, b) => {
470
+ if (a.neighborId !== b.neighborId) {
471
+ return a.neighborId < b.neighborId ? -1 : 1;
472
+ }
473
+ return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
474
+ });
475
+ };
476
+
477
+ for (const list of outgoing.values()) {
478
+ sortNeighbors(list);
479
+ }
480
+
481
+ for (const list of incoming.values()) {
482
+ sortNeighbors(list);
483
+ }
484
+
485
+ return { outgoing, incoming };
486
+ }
487
+
488
+ /**
489
+ * Sets the cached state and materialized graph details.
490
+ * @param {import('./services/JoinReducer.js').WarpStateV5} state
491
+ * @returns {Promise<MaterializedGraph>}
492
+ * @private
493
+ */
494
+ async _setMaterializedState(state) {
495
+ this._cachedState = state;
496
+ this._stateDirty = false;
497
+ this._versionVector = vvClone(state.observedFrontier);
498
+
499
+ const stateHash = await computeStateHashV5(state, { crypto: this._crypto, codec: this._codec });
500
+ let adjacency;
501
+
502
+ if (this._adjacencyCache) {
503
+ adjacency = this._adjacencyCache.get(stateHash);
504
+ if (!adjacency) {
505
+ adjacency = this._buildAdjacency(state);
506
+ this._adjacencyCache.set(stateHash, adjacency);
507
+ }
508
+ } else {
509
+ adjacency = this._buildAdjacency(state);
510
+ }
511
+
512
+ this._materializedGraph = { state, stateHash, adjacency };
513
+ return this._materializedGraph;
514
+ }
515
+
516
+ /**
517
+ * Callback invoked after a patch is successfully committed.
518
+ *
519
+ * Updates version vector, patch count, cached state (if clean),
520
+ * provenance index, and frontier tracking.
521
+ *
522
+ * @param {string} writerId - The writer ID that committed the patch
523
+ * @param {{patch?: Object, sha?: string}} [opts] - Commit details
524
+ * @private
525
+ */
526
+ async _onPatchCommitted(writerId, { patch, sha } = {}) {
527
+ vvIncrement(this._versionVector, writerId);
528
+ this._patchesSinceCheckpoint++;
529
+ // Eager re-materialize: apply the just-committed patch to cached state
530
+ // Only when the cache is clean — applying a patch to stale state would be incorrect
531
+ if (this._cachedState && !this._stateDirty && patch && sha) {
532
+ joinPatch(this._cachedState, patch, sha);
533
+ await this._setMaterializedState(this._cachedState);
534
+ // Update provenance index with new patch
535
+ if (this._provenanceIndex) {
536
+ this._provenanceIndex.addPatch(sha, patch.reads, patch.writes);
537
+ }
538
+ // Keep _lastFrontier in sync so hasFrontierChanged() won't misreport stale
539
+ if (this._lastFrontier) {
540
+ this._lastFrontier.set(writerId, sha);
541
+ }
542
+ } else {
543
+ this._stateDirty = true;
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Materializes the graph and returns the materialized graph details.
549
+ * @returns {Promise<MaterializedGraph>}
550
+ * @private
551
+ */
552
+ async _materializeGraph() {
553
+ const state = await this.materialize();
554
+ if (!this._materializedGraph || this._materializedGraph.state !== state) {
555
+ await this._setMaterializedState(state);
556
+ }
557
+ return this._materializedGraph;
558
+ }
559
+
560
+ /**
561
+ * Materializes the current graph state.
562
+ *
563
+ * Discovers all writers, collects all patches from each writer's ref chain,
564
+ * and reduces them to produce the current state.
565
+ *
566
+ * Checks if a checkpoint exists and uses incremental materialization if so.
567
+ *
568
+ * When `options.receipts` is true, returns `{ state, receipts }` where
569
+ * receipts is an array of TickReceipt objects (one per applied patch).
570
+ * When false or omitted (default), returns just the state for backward
571
+ * compatibility with zero receipt overhead.
572
+ *
573
+ * Side effects: Updates internal cached state, version vector, last frontier,
574
+ * and patches-since-checkpoint counter. May trigger auto-checkpoint and GC
575
+ * based on configured policies. Notifies subscribers if state changed.
576
+ *
577
+ * @param {{receipts?: boolean}} [options] - Optional configuration
578
+ * @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
579
+ * @throws {Error} If checkpoint loading fails or patch decoding fails
580
+ * @throws {Error} If writer ref access or patch blob reading fails
581
+ */
582
+ async materialize(options) {
583
+ const t0 = this._clock.now();
584
+ // ZERO-COST: only resolve receipts flag when options provided
585
+ const collectReceipts = options && options.receipts;
586
+
587
+ try {
588
+ // Check for checkpoint
589
+ const checkpoint = await this._loadLatestCheckpoint();
590
+
591
+ let state;
592
+ let receipts;
593
+ let patchCount = 0;
594
+
595
+ // If checkpoint exists, use incremental materialization
596
+ if (checkpoint?.schema === 2 || checkpoint?.schema === 3) {
597
+ const patches = await this._loadPatchesSince(checkpoint);
598
+ if (collectReceipts) {
599
+ const result = reduceV5(patches, checkpoint.state, { receipts: true });
600
+ state = result.state;
601
+ receipts = result.receipts;
602
+ } else {
603
+ state = reduceV5(patches, checkpoint.state);
604
+ }
605
+ patchCount = patches.length;
606
+
607
+ // Build provenance index: start from checkpoint index if present, then add new patches
608
+ this._provenanceIndex = checkpoint.provenanceIndex
609
+ ? checkpoint.provenanceIndex.clone()
610
+ : new ProvenanceIndex();
611
+ for (const { patch, sha } of patches) {
612
+ this._provenanceIndex.addPatch(sha, patch.reads, patch.writes);
613
+ }
614
+ } else {
615
+ // 1. Discover all writers
616
+ const writerIds = await this.discoverWriters();
617
+
618
+ // 2. If no writers, return empty state
619
+ if (writerIds.length === 0) {
620
+ state = createEmptyStateV5();
621
+ this._provenanceIndex = new ProvenanceIndex();
622
+ if (collectReceipts) {
623
+ receipts = [];
624
+ }
625
+ } else {
626
+ // 3. For each writer, collect all patches
627
+ const allPatches = [];
628
+ for (const writerId of writerIds) {
629
+ const writerPatches = await this._loadWriterPatches(writerId);
630
+ allPatches.push(...writerPatches);
631
+ }
632
+
633
+ // 4. If no patches, return empty state
634
+ if (allPatches.length === 0) {
635
+ state = createEmptyStateV5();
636
+ this._provenanceIndex = new ProvenanceIndex();
637
+ if (collectReceipts) {
638
+ receipts = [];
639
+ }
640
+ } else {
641
+ // 5. Reduce all patches to state
642
+ if (collectReceipts) {
643
+ const result = reduceV5(allPatches, undefined, { receipts: true });
644
+ state = result.state;
645
+ receipts = result.receipts;
646
+ } else {
647
+ state = reduceV5(allPatches);
648
+ }
649
+ patchCount = allPatches.length;
650
+
651
+ // Build provenance index from all patches
652
+ this._provenanceIndex = new ProvenanceIndex();
653
+ for (const { patch, sha } of allPatches) {
654
+ this._provenanceIndex.addPatch(sha, patch.reads, patch.writes);
655
+ }
656
+ }
657
+ }
658
+ }
659
+
660
+ await this._setMaterializedState(state);
661
+ this._lastFrontier = await this.getFrontier();
662
+ this._patchesSinceCheckpoint = patchCount;
663
+
664
+ // Auto-checkpoint if policy is set and threshold exceeded.
665
+ // Guard prevents recursion: createCheckpoint() calls materialize() internally.
666
+ if (this._checkpointPolicy && !this._checkpointing && patchCount >= this._checkpointPolicy.every) {
667
+ try {
668
+ await this.createCheckpoint();
669
+ this._patchesSinceCheckpoint = 0;
670
+ } catch {
671
+ // Checkpoint failure does not break materialize — continue silently
672
+ }
673
+ }
674
+
675
+ this._maybeRunGC(state);
676
+
677
+ // Notify subscribers if state changed since last notification
678
+ // Also handles deferred replay for subscribers added with replay: true before cached state
679
+ if (this._subscribers.length > 0) {
680
+ const hasPendingReplay = this._subscribers.some(s => s.pendingReplay);
681
+ const diff = diffStates(this._lastNotifiedState, state);
682
+ if (!isEmptyDiff(diff) || hasPendingReplay) {
683
+ this._notifySubscribers(diff, state);
684
+ }
685
+ }
686
+ // Clone state to prevent eager path mutations from affecting the baseline
687
+ this._lastNotifiedState = cloneStateV5(state);
688
+
689
+ this._logTiming('materialize', t0, { metrics: `${patchCount} patches` });
690
+
691
+ if (collectReceipts) {
692
+ return { state, receipts };
693
+ }
694
+ return state;
695
+ } catch (err) {
696
+ this._logTiming('materialize', t0, { error: err });
697
+ throw err;
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Joins (merges) another state into the current cached state.
703
+ *
704
+ * This method allows manual merging of two graph states using the
705
+ * CRDT join semantics defined in JoinReducer. The merge is deterministic
706
+ * and commutative - joining A with B produces the same result as B with A.
707
+ *
708
+ * @param {import('./services/JoinReducer.js').WarpStateV5} otherState - The state to merge in
709
+ * @returns {{
710
+ * state: import('./services/JoinReducer.js').WarpStateV5,
711
+ * receipt: {
712
+ * nodesAdded: number,
713
+ * nodesRemoved: number,
714
+ * edgesAdded: number,
715
+ * edgesRemoved: number,
716
+ * propsChanged: number,
717
+ * frontierMerged: boolean
718
+ * }
719
+ * }} The merged state and a receipt describing the merge
720
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
721
+ *
722
+ * @example
723
+ * const graph = await WarpGraph.open({ persistence, graphName, writerId });
724
+ * await graph.materialize(); // Cache state first
725
+ *
726
+ * // Get state from another source (e.g., remote sync)
727
+ * const remoteState = await fetchRemoteState();
728
+ *
729
+ * // Merge the states
730
+ * const { state, receipt } = graph.join(remoteState);
731
+ * console.log(`Merged: ${receipt.nodesAdded} nodes added, ${receipt.propsChanged} props changed`);
732
+ */
733
+ join(otherState) {
734
+ if (!this._cachedState) {
735
+ throw new QueryError('No cached state. Call materialize() first.', {
736
+ code: 'E_NO_STATE',
737
+ });
738
+ }
739
+
740
+ if (!otherState || !otherState.nodeAlive || !otherState.edgeAlive) {
741
+ throw new Error('Invalid state: must be a valid WarpStateV5 object');
742
+ }
743
+
744
+ // Capture pre-merge counts for receipt
745
+ const beforeNodes = this._cachedState.nodeAlive.elements.size;
746
+ const beforeEdges = this._cachedState.edgeAlive.elements.size;
747
+ const beforeFrontierSize = this._cachedState.observedFrontier.size;
748
+
749
+ // Perform the join
750
+ const mergedState = joinStates(this._cachedState, otherState);
751
+
752
+ // Calculate receipt
753
+ const afterNodes = mergedState.nodeAlive.elements.size;
754
+ const afterEdges = mergedState.edgeAlive.elements.size;
755
+ const afterFrontierSize = mergedState.observedFrontier.size;
756
+
757
+ // Count property changes (keys that existed in both but have different values)
758
+ let propsChanged = 0;
759
+ for (const [key, reg] of mergedState.prop) {
760
+ const oldReg = this._cachedState.prop.get(key);
761
+ if (!oldReg || oldReg.value !== reg.value) {
762
+ propsChanged++;
763
+ }
764
+ }
765
+
766
+ const receipt = {
767
+ nodesAdded: Math.max(0, afterNodes - beforeNodes),
768
+ nodesRemoved: Math.max(0, beforeNodes - afterNodes),
769
+ edgesAdded: Math.max(0, afterEdges - beforeEdges),
770
+ edgesRemoved: Math.max(0, beforeEdges - afterEdges),
771
+ propsChanged,
772
+ frontierMerged: afterFrontierSize !== beforeFrontierSize ||
773
+ !this._frontierEquals(this._cachedState.observedFrontier, mergedState.observedFrontier),
774
+ };
775
+
776
+ // Update cached state
777
+ this._cachedState = mergedState;
778
+
779
+ return { state: mergedState, receipt };
780
+ }
781
+
782
+ /**
783
+ * Compares two version vectors for equality.
784
+ * @param {import('./crdt/VersionVector.js').VersionVector} a
785
+ * @param {import('./crdt/VersionVector.js').VersionVector} b
786
+ * @returns {boolean}
787
+ * @private
788
+ */
789
+ _frontierEquals(a, b) {
790
+ if (a.size !== b.size) {
791
+ return false;
792
+ }
793
+ for (const [key, val] of a) {
794
+ if (b.get(key) !== val) {
795
+ return false;
796
+ }
797
+ }
798
+ return true;
799
+ }
800
+
801
+ /**
802
+ * Materializes the graph state at a specific checkpoint.
803
+ *
804
+ * Loads the checkpoint state and frontier, discovers current writers,
805
+ * builds the target frontier from current writer tips, and applies
806
+ * incremental patches since the checkpoint.
807
+ *
808
+ * @param {string} checkpointSha - The checkpoint commit SHA
809
+ * @returns {Promise<import('./services/JoinReducer.js').WarpStateV5>} The materialized graph state at the checkpoint
810
+ * @throws {Error} If checkpoint SHA is invalid or not found
811
+ * @throws {Error} If checkpoint loading or patch decoding fails
812
+ *
813
+ * @example
814
+ * // Time-travel to a previous checkpoint
815
+ * const oldState = await graph.materializeAt('abc123');
816
+ * console.log('Nodes at checkpoint:', [...oldState.nodeAlive.elements.keys()]);
817
+ */
818
+ async materializeAt(checkpointSha) {
819
+ // 1. Discover current writers to build target frontier
820
+ const writerIds = await this.discoverWriters();
821
+
822
+ // 2. Build target frontier (current tips for all writers)
823
+ const targetFrontier = createFrontier();
824
+ for (const writerId of writerIds) {
825
+ const writerRef = buildWriterRef(this._graphName, writerId);
826
+ const tipSha = await this._persistence.readRef(writerRef);
827
+ if (tipSha) {
828
+ updateFrontier(targetFrontier, writerId, tipSha);
829
+ }
830
+ }
831
+
832
+ // 3. Create a patch loader function for incremental materialization
833
+ const patchLoader = async (writerId, fromSha, toSha) => {
834
+ // Load patches from fromSha (exclusive) to toSha (inclusive)
835
+ // Walk from toSha back to fromSha
836
+ const patches = [];
837
+ let currentSha = toSha;
838
+
839
+ while (currentSha && currentSha !== fromSha) {
840
+ const nodeInfo = await this._persistence.getNodeInfo(currentSha);
841
+ const {message} = nodeInfo;
842
+
843
+ const kind = detectMessageKind(message);
844
+ if (kind !== 'patch') {
845
+ break;
846
+ }
847
+
848
+ const patchMeta = decodePatchMessage(message);
849
+ const patchBuffer = await this._persistence.readBlob(patchMeta.patchOid);
850
+ const patch = this._codec.decode(patchBuffer);
851
+
852
+ patches.push({ patch, sha: currentSha });
853
+
854
+ if (nodeInfo.parents && nodeInfo.parents.length > 0) {
855
+ currentSha = nodeInfo.parents[0];
856
+ } else {
857
+ break;
858
+ }
859
+ }
860
+
861
+ return patches.reverse();
862
+ };
863
+
864
+ // 4. Call materializeIncremental with the checkpoint and target frontier
865
+ const state = await materializeIncremental({
866
+ persistence: this._persistence,
867
+ graphName: this._graphName,
868
+ checkpointSha,
869
+ targetFrontier,
870
+ patchLoader,
871
+ codec: this._codec,
872
+ });
873
+ await this._setMaterializedState(state);
874
+ return state;
875
+ }
876
+
877
+ /**
878
+ * Creates a new checkpoint of the current graph state.
879
+ *
880
+ * Materializes the current state, creates a checkpoint commit with
881
+ * frontier information, and updates the checkpoint ref.
882
+ *
883
+ * @returns {Promise<string>} The checkpoint commit SHA
884
+ * @throws {Error} If materialization fails
885
+ * @throws {Error} If checkpoint commit creation fails
886
+ * @throws {Error} If ref update fails
887
+ */
888
+ async createCheckpoint() {
889
+ const t0 = this._clock.now();
890
+ try {
891
+ // 1. Discover all writers
892
+ const writers = await this.discoverWriters();
893
+
894
+ // 2. Build frontier (map of writerId → tip SHA)
895
+ const frontier = createFrontier();
896
+ const parents = [];
897
+
898
+ for (const writerId of writers) {
899
+ const writerRef = buildWriterRef(this._graphName, writerId);
900
+ const sha = await this._persistence.readRef(writerRef);
901
+ if (sha) {
902
+ updateFrontier(frontier, writerId, sha);
903
+ parents.push(sha);
904
+ }
905
+ }
906
+
907
+ // 3. Materialize current state (reuse cached if fresh, guard against recursion)
908
+ const prevCheckpointing = this._checkpointing;
909
+ this._checkpointing = true;
910
+ let state;
911
+ try {
912
+ state = (this._cachedState && !this._stateDirty)
913
+ ? this._cachedState
914
+ : await this.materialize();
915
+ } finally {
916
+ this._checkpointing = prevCheckpointing;
917
+ }
918
+
919
+ // 4. Call CheckpointService.create() with provenance index if available
920
+ const checkpointSha = await createCheckpointCommit({
921
+ persistence: this._persistence,
922
+ graphName: this._graphName,
923
+ state,
924
+ frontier,
925
+ parents,
926
+ provenanceIndex: this._provenanceIndex,
927
+ crypto: this._crypto,
928
+ codec: this._codec,
929
+ });
930
+
931
+ // 5. Update checkpoint ref
932
+ const checkpointRef = buildCheckpointRef(this._graphName);
933
+ await this._persistence.updateRef(checkpointRef, checkpointSha);
934
+
935
+ this._logTiming('createCheckpoint', t0);
936
+
937
+ // 6. Return checkpoint SHA
938
+ return checkpointSha;
939
+ } catch (err) {
940
+ this._logTiming('createCheckpoint', t0, { error: err });
941
+ throw err;
942
+ }
943
+ }
944
+
945
+ /**
946
+ * Syncs coverage information across writers.
947
+ *
948
+ * Creates an octopus anchor commit with all writer tips as parents,
949
+ * then updates the coverage ref to point to this anchor. The "octopus anchor"
950
+ * is a merge commit that records which writer tips have been observed,
951
+ * enabling efficient replication and consistency checks.
952
+ *
953
+ * @returns {Promise<void>}
954
+ * @throws {Error} If ref access or commit creation fails
955
+ */
956
+ async syncCoverage() {
957
+ // 1. Discover all writers
958
+ const writers = await this.discoverWriters();
959
+
960
+ // If no writers exist, do nothing
961
+ if (writers.length === 0) {
962
+ return;
963
+ }
964
+
965
+ // 2. Get tip SHA for each writer's ref
966
+ const parents = [];
967
+ for (const writerId of writers) {
968
+ const writerRef = buildWriterRef(this._graphName, writerId);
969
+ const sha = await this._persistence.readRef(writerRef);
970
+ if (sha) {
971
+ parents.push(sha);
972
+ }
973
+ }
974
+
975
+ // If no refs have SHAs, do nothing
976
+ if (parents.length === 0) {
977
+ return;
978
+ }
979
+
980
+ // 3. Create octopus anchor commit with all tips as parents
981
+ const message = encodeAnchorMessage({ graph: this._graphName });
982
+ const anchorSha = await this._persistence.commitNode({ message, parents });
983
+
984
+ // 4. Update coverage ref
985
+ const coverageRef = buildCoverageRef(this._graphName);
986
+ await this._persistence.updateRef(coverageRef, anchorSha);
987
+ }
988
+
989
+ /**
990
+ * Discovers all writers that have contributed to this graph.
991
+ *
992
+ * Lists all refs under refs/warp/<graphName>/writers/ and
993
+ * extracts writer IDs from the ref paths.
994
+ *
995
+ * @returns {Promise<string[]>} Sorted array of writer IDs
996
+ * @throws {Error} If listing refs fails
997
+ */
998
+ async discoverWriters() {
999
+ const prefix = buildWritersPrefix(this._graphName);
1000
+ const refs = await this._persistence.listRefs(prefix);
1001
+
1002
+ const writerIds = [];
1003
+ for (const refPath of refs) {
1004
+ const writerId = parseWriterIdFromRef(refPath);
1005
+ if (writerId) {
1006
+ writerIds.push(writerId);
1007
+ }
1008
+ }
1009
+
1010
+ return writerIds.sort();
1011
+ }
1012
+
1013
+ // ============================================================================
1014
+ // Schema Migration Support
1015
+ // ============================================================================
1016
+
1017
+ /**
1018
+ * Validates migration boundary for graphs.
1019
+ *
1020
+ * Graphs cannot be opened if there is schema:1 history without
1021
+ * a migration checkpoint. This ensures data consistency during migration.
1022
+ *
1023
+ * @returns {Promise<void>}
1024
+ * @throws {Error} If v1 history exists without migration checkpoint
1025
+ * @private
1026
+ */
1027
+ async _validateMigrationBoundary() {
1028
+ const checkpoint = await this._loadLatestCheckpoint();
1029
+ if (checkpoint?.schema === 2 || checkpoint?.schema === 3) {
1030
+ return; // Already migrated
1031
+ }
1032
+
1033
+ const hasSchema1History = await this._hasSchema1Patches();
1034
+ if (hasSchema1History) {
1035
+ throw new Error(
1036
+ 'Cannot open graph with v1 history. ' +
1037
+ 'Run MigrationService.migrate() first to create migration checkpoint.'
1038
+ );
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * Loads the latest checkpoint for this graph.
1044
+ *
1045
+ * @returns {Promise<{state: Object, frontier: Map, stateHash: string, schema: number}|null>} The checkpoint or null
1046
+ * @private
1047
+ */
1048
+ async _loadLatestCheckpoint() {
1049
+ const checkpointRef = buildCheckpointRef(this._graphName);
1050
+ const checkpointSha = await this._persistence.readRef(checkpointRef);
1051
+
1052
+ if (!checkpointSha) {
1053
+ return null;
1054
+ }
1055
+
1056
+ try {
1057
+ return await loadCheckpoint(this._persistence, checkpointSha, { codec: this._codec });
1058
+ } catch {
1059
+ return null;
1060
+ }
1061
+ }
1062
+
1063
+ /**
1064
+ * Checks if there are any schema:1 patches in the graph.
1065
+ *
1066
+ * @returns {Promise<boolean>} True if schema:1 patches exist
1067
+ * @private
1068
+ */
1069
+ async _hasSchema1Patches() {
1070
+ const writerIds = await this.discoverWriters();
1071
+
1072
+ for (const writerId of writerIds) {
1073
+ const writerRef = buildWriterRef(this._graphName, writerId);
1074
+ const tipSha = await this._persistence.readRef(writerRef);
1075
+
1076
+ if (!tipSha) {
1077
+ continue;
1078
+ }
1079
+
1080
+ // Check the first (most recent) patch from this writer
1081
+ const nodeInfo = await this._persistence.getNodeInfo(tipSha);
1082
+ const kind = detectMessageKind(nodeInfo.message);
1083
+
1084
+ if (kind === 'patch') {
1085
+ const patchMeta = decodePatchMessage(nodeInfo.message);
1086
+ const patchBuffer = await this._persistence.readBlob(patchMeta.patchOid);
1087
+ const patch = this._codec.decode(patchBuffer);
1088
+
1089
+ // If any patch has schema:1, we have v1 history
1090
+ if (patch.schema === 1 || patch.schema === undefined) {
1091
+ return true;
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ return false;
1097
+ }
1098
+
1099
+ /**
1100
+ * Loads patches since a checkpoint for incremental materialization.
1101
+ *
1102
+ * @param {{state: Object, frontier: Map<string, string>, stateHash: string, schema: number}} checkpoint - The checkpoint to start from
1103
+ * @returns {Promise<Array<{patch: import('./types/WarpTypes.js').PatchV1, sha: string}>>} Patches since checkpoint
1104
+ * @private
1105
+ */
1106
+ async _loadPatchesSince(checkpoint) {
1107
+ const writerIds = await this.discoverWriters();
1108
+ const allPatches = [];
1109
+
1110
+ for (const writerId of writerIds) {
1111
+ const checkpointSha = checkpoint.frontier?.get(writerId) || null;
1112
+ const patches = await this._loadWriterPatches(writerId, checkpointSha);
1113
+
1114
+ // Validate each patch against checkpoint frontier
1115
+ for (const { sha } of patches) {
1116
+ await this._validatePatchAgainstCheckpoint(writerId, sha, checkpoint);
1117
+ }
1118
+
1119
+ allPatches.push(...patches);
1120
+ }
1121
+
1122
+ return allPatches;
1123
+ }
1124
+
1125
+ // ============================================================================
1126
+ // Backfill Rejection and Divergence Detection
1127
+ // ============================================================================
1128
+
1129
+ /**
1130
+ * Checks if ancestorSha is an ancestor of descendantSha.
1131
+ * Walks the commit graph (linear per-writer chain assumption).
1132
+ *
1133
+ * @param {string} ancestorSha - The potential ancestor commit SHA
1134
+ * @param {string} descendantSha - The potential descendant commit SHA
1135
+ * @returns {Promise<boolean>} True if ancestorSha is an ancestor of descendantSha
1136
+ * @private
1137
+ */
1138
+ async _isAncestor(ancestorSha, descendantSha) {
1139
+ if (!ancestorSha || !descendantSha) {
1140
+ return false;
1141
+ }
1142
+ if (ancestorSha === descendantSha) {
1143
+ return true;
1144
+ }
1145
+
1146
+ let cur = descendantSha;
1147
+ while (cur) {
1148
+ const nodeInfo = await this._persistence.getNodeInfo(cur);
1149
+ const parent = nodeInfo.parents?.[0] ?? null;
1150
+ if (parent === ancestorSha) {
1151
+ return true;
1152
+ }
1153
+ cur = parent;
1154
+ }
1155
+ return false;
1156
+ }
1157
+
1158
+ /**
1159
+ * Determines relationship between incoming patch and checkpoint head.
1160
+ *
1161
+ * @param {string} ckHead - The checkpoint head SHA for this writer
1162
+ * @param {string} incomingSha - The incoming patch commit SHA
1163
+ * @returns {Promise<'same' | 'ahead' | 'behind' | 'diverged'>} The relationship
1164
+ * @private
1165
+ */
1166
+ async _relationToCheckpointHead(ckHead, incomingSha) {
1167
+ if (incomingSha === ckHead) {
1168
+ return 'same';
1169
+ }
1170
+ if (await this._isAncestor(ckHead, incomingSha)) {
1171
+ return 'ahead';
1172
+ }
1173
+ if (await this._isAncestor(incomingSha, ckHead)) {
1174
+ return 'behind';
1175
+ }
1176
+ return 'diverged';
1177
+ }
1178
+
1179
+ /**
1180
+ * Validates an incoming patch against checkpoint frontier.
1181
+ * Uses graph reachability, NOT lamport timestamps.
1182
+ *
1183
+ * @param {string} writerId - The writer ID for this patch
1184
+ * @param {string} incomingSha - The incoming patch commit SHA
1185
+ * @param {{state: Object, frontier: Map<string, string>, stateHash: string, schema: number}} checkpoint - The checkpoint to validate against
1186
+ * @returns {Promise<void>}
1187
+ * @throws {Error} If patch is behind/same as checkpoint frontier (backfill rejected)
1188
+ * @throws {Error} If patch does not extend checkpoint head (writer fork detected)
1189
+ * @private
1190
+ */
1191
+ async _validatePatchAgainstCheckpoint(writerId, incomingSha, checkpoint) {
1192
+ if (!checkpoint || (checkpoint.schema !== 2 && checkpoint.schema !== 3)) {
1193
+ return;
1194
+ }
1195
+
1196
+ const ckHead = checkpoint.frontier?.get(writerId);
1197
+ if (!ckHead) {
1198
+ return; // Checkpoint didn't include this writer
1199
+ }
1200
+
1201
+ const relation = await this._relationToCheckpointHead(ckHead, incomingSha);
1202
+
1203
+ if (relation === 'same' || relation === 'behind') {
1204
+ throw new Error(
1205
+ `Backfill rejected for writer ${writerId}: ` +
1206
+ `incoming patch is ${relation} checkpoint frontier`
1207
+ );
1208
+ }
1209
+
1210
+ if (relation === 'diverged') {
1211
+ throw new Error(
1212
+ `Writer fork detected for ${writerId}: ` +
1213
+ `incoming patch does not extend checkpoint head`
1214
+ );
1215
+ }
1216
+ // relation === 'ahead' => OK
1217
+ }
1218
+
1219
+ // ============================================================================
1220
+ // Garbage Collection
1221
+ // ============================================================================
1222
+
1223
+ /**
1224
+ * Post-materialize GC check. Warn by default; execute only when enabled.
1225
+ * GC failure never breaks materialize.
1226
+ *
1227
+ * @param {import('./services/JoinReducer.js').WarpStateV5} state
1228
+ * @private
1229
+ */
1230
+ _maybeRunGC(state) {
1231
+ try {
1232
+ const metrics = collectGCMetrics(state);
1233
+ const inputMetrics = {
1234
+ ...metrics,
1235
+ patchesSinceCompaction: this._patchesSinceGC,
1236
+ timeSinceCompaction: Date.now() - this._lastGCTime,
1237
+ };
1238
+ const { shouldRun, reasons } = shouldRunGC(inputMetrics, this._gcPolicy);
1239
+
1240
+ if (!shouldRun) {
1241
+ return;
1242
+ }
1243
+
1244
+ if (this._gcPolicy.enabled) {
1245
+ const appliedVV = computeAppliedVV(state);
1246
+ const result = executeGC(state, appliedVV);
1247
+ this._lastGCTime = Date.now();
1248
+ this._patchesSinceGC = 0;
1249
+ if (this._logger) {
1250
+ this._logger.info('Auto-GC completed', { ...result, reasons });
1251
+ }
1252
+ } else if (this._logger) {
1253
+ this._logger.warn(
1254
+ 'GC thresholds exceeded but auto-GC is disabled. Set gcPolicy: { enabled: true } to auto-compact.',
1255
+ { reasons },
1256
+ );
1257
+ }
1258
+ } catch {
1259
+ // GC failure never breaks materialize
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * Checks if GC should run based on current metrics and policy.
1265
+ * If thresholds are exceeded, runs GC on the cached state.
1266
+ *
1267
+ * **Requires a cached state.**
1268
+ *
1269
+ * @returns {{ran: boolean, result: Object|null, reasons: string[]}} GC result
1270
+ *
1271
+ * @example
1272
+ * await graph.materialize();
1273
+ * const { ran, result, reasons } = graph.maybeRunGC();
1274
+ * if (ran) {
1275
+ * console.log(`GC ran: ${result.tombstonesRemoved} tombstones removed`);
1276
+ * }
1277
+ */
1278
+ maybeRunGC() {
1279
+ if (!this._cachedState) {
1280
+ return { ran: false, result: null, reasons: [] };
1281
+ }
1282
+
1283
+ const metrics = collectGCMetrics(this._cachedState);
1284
+ metrics.patchesSinceCompaction = this._patchesSinceGC;
1285
+ metrics.lastCompactionTime = this._lastGCTime;
1286
+
1287
+ const { shouldRun, reasons } = shouldRunGC(metrics, this._gcPolicy);
1288
+
1289
+ if (!shouldRun) {
1290
+ return { ran: false, result: null, reasons: [] };
1291
+ }
1292
+
1293
+ const result = this.runGC();
1294
+ return { ran: true, result, reasons };
1295
+ }
1296
+
1297
+ /**
1298
+ * Explicitly runs GC on the cached state.
1299
+ * Compacts tombstoned dots that are covered by the appliedVV.
1300
+ *
1301
+ * **Requires a cached state.**
1302
+ *
1303
+ * @returns {{nodesCompacted: number, edgesCompacted: number, tombstonesRemoved: number, durationMs: number}}
1304
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
1305
+ *
1306
+ * @example
1307
+ * await graph.materialize();
1308
+ * const result = graph.runGC();
1309
+ * console.log(`Removed ${result.tombstonesRemoved} tombstones in ${result.durationMs}ms`);
1310
+ */
1311
+ runGC() {
1312
+ const t0 = this._clock.now();
1313
+ try {
1314
+ if (!this._cachedState) {
1315
+ throw new QueryError('No cached state. Call materialize() first.', {
1316
+ code: 'E_NO_STATE',
1317
+ });
1318
+ }
1319
+
1320
+ // Compute appliedVV from current state
1321
+ const appliedVV = computeAppliedVV(this._cachedState);
1322
+
1323
+ // Execute GC (mutates cached state)
1324
+ const result = executeGC(this._cachedState, appliedVV);
1325
+
1326
+ // Update GC tracking
1327
+ this._lastGCTime = Date.now();
1328
+ this._patchesSinceGC = 0;
1329
+
1330
+ this._logTiming('runGC', t0, { metrics: `${result.tombstonesRemoved} tombstones removed` });
1331
+
1332
+ return result;
1333
+ } catch (err) {
1334
+ this._logTiming('runGC', t0, { error: err });
1335
+ throw err;
1336
+ }
1337
+ }
1338
+
1339
+ /**
1340
+ * Gets current GC metrics for the cached state.
1341
+ *
1342
+ * @returns {{
1343
+ * nodeCount: number,
1344
+ * edgeCount: number,
1345
+ * tombstoneCount: number,
1346
+ * tombstoneRatio: number,
1347
+ * patchesSinceCompaction: number,
1348
+ * lastCompactionTime: number
1349
+ * }|null} GC metrics or null if no cached state
1350
+ */
1351
+ getGCMetrics() {
1352
+ if (!this._cachedState) {
1353
+ return null;
1354
+ }
1355
+
1356
+ const metrics = collectGCMetrics(this._cachedState);
1357
+ metrics.patchesSinceCompaction = this._patchesSinceGC;
1358
+ metrics.lastCompactionTime = this._lastGCTime;
1359
+ return metrics;
1360
+ }
1361
+
1362
+ /**
1363
+ * Gets the current GC policy.
1364
+ *
1365
+ * @returns {Object} The GC policy configuration
1366
+ */
1367
+ get gcPolicy() {
1368
+ return { ...this._gcPolicy };
1369
+ }
1370
+
1371
+ // ============================================================================
1372
+ // Network Sync API
1373
+ // ============================================================================
1374
+
1375
+ /**
1376
+ * Gets the current frontier for this graph.
1377
+ * The frontier maps each writer ID to their current tip SHA.
1378
+ *
1379
+ * @returns {Promise<Map<string, string>>} Map of writerId to tip SHA
1380
+ * @throws {Error} If listing refs fails
1381
+ */
1382
+ async getFrontier() {
1383
+ const writerIds = await this.discoverWriters();
1384
+ const frontier = createFrontier();
1385
+
1386
+ for (const writerId of writerIds) {
1387
+ const writerRef = buildWriterRef(this._graphName, writerId);
1388
+ const tipSha = await this._persistence.readRef(writerRef);
1389
+ if (tipSha) {
1390
+ updateFrontier(frontier, writerId, tipSha);
1391
+ }
1392
+ }
1393
+
1394
+ return frontier;
1395
+ }
1396
+
1397
+ /**
1398
+ * Checks whether any writer tip has changed since the last materialize.
1399
+ *
1400
+ * O(writers) comparison of stored writer tip SHAs against current refs.
1401
+ * Cheap "has anything changed?" check without materialization.
1402
+ *
1403
+ * @returns {Promise<boolean>} True if frontier has changed (or never materialized)
1404
+ * @throws {Error} If listing refs fails
1405
+ */
1406
+ async hasFrontierChanged() {
1407
+ if (this._lastFrontier === null) {
1408
+ return true;
1409
+ }
1410
+
1411
+ const current = await this.getFrontier();
1412
+
1413
+ if (current.size !== this._lastFrontier.size) {
1414
+ return true;
1415
+ }
1416
+
1417
+ for (const [writerId, tipSha] of current) {
1418
+ if (this._lastFrontier.get(writerId) !== tipSha) {
1419
+ return true;
1420
+ }
1421
+ }
1422
+
1423
+ return false;
1424
+ }
1425
+
1426
+ /**
1427
+ * Returns a lightweight status snapshot of the graph's operational state.
1428
+ *
1429
+ * This method is O(writers) and does NOT trigger materialization.
1430
+ *
1431
+ * @returns {Promise<{
1432
+ * cachedState: 'fresh' | 'stale' | 'none',
1433
+ * patchesSinceCheckpoint: number,
1434
+ * tombstoneRatio: number,
1435
+ * writers: number,
1436
+ * frontier: Record<string, string>,
1437
+ * }>} The graph status
1438
+ * @throws {Error} If listing refs fails
1439
+ */
1440
+ async status() {
1441
+ // Determine cachedState
1442
+ let cachedState;
1443
+ if (this._cachedState === null) {
1444
+ cachedState = 'none';
1445
+ } else if (this._stateDirty || await this.hasFrontierChanged()) {
1446
+ cachedState = 'stale';
1447
+ } else {
1448
+ cachedState = 'fresh';
1449
+ }
1450
+
1451
+ // patchesSinceCheckpoint
1452
+ const patchesSinceCheckpoint = this._patchesSinceCheckpoint;
1453
+
1454
+ // tombstoneRatio
1455
+ let tombstoneRatio = 0;
1456
+ if (this._cachedState) {
1457
+ const metrics = collectGCMetrics(this._cachedState);
1458
+ tombstoneRatio = metrics.tombstoneRatio;
1459
+ }
1460
+
1461
+ // writers and frontier
1462
+ const frontier = await this.getFrontier();
1463
+ const writers = frontier.size;
1464
+
1465
+ // Convert frontier Map to plain object
1466
+ const frontierObj = Object.fromEntries(frontier);
1467
+
1468
+ return {
1469
+ cachedState,
1470
+ patchesSinceCheckpoint,
1471
+ tombstoneRatio,
1472
+ writers,
1473
+ frontier: frontierObj,
1474
+ };
1475
+ }
1476
+
1477
+ /**
1478
+ * Subscribes to graph changes.
1479
+ *
1480
+ * The `onChange` handler is called after each `materialize()` that results in
1481
+ * state changes. The handler receives a diff object describing what changed.
1482
+ *
1483
+ * When `replay: true` is set and `_cachedState` is available, immediately
1484
+ * fires `onChange` with a diff from empty state to current state. If
1485
+ * `_cachedState` is null, replay is deferred until the first materialize.
1486
+ *
1487
+ * Errors thrown by handlers are caught and forwarded to `onError` if provided.
1488
+ * One handler's error does not prevent other handlers from being called.
1489
+ *
1490
+ * @param {Object} options - Subscription options
1491
+ * @param {(diff: import('./services/StateDiff.js').StateDiff) => void} options.onChange - Called with diff when graph changes
1492
+ * @param {(error: Error) => void} [options.onError] - Called if onChange throws an error
1493
+ * @param {boolean} [options.replay=false] - If true, immediately fires onChange with initial state diff
1494
+ * @returns {{unsubscribe: () => void}} Subscription handle
1495
+ * @throws {Error} If onChange is not a function
1496
+ *
1497
+ * @example
1498
+ * const { unsubscribe } = graph.subscribe({
1499
+ * onChange: (diff) => {
1500
+ * console.log('Nodes added:', diff.nodes.added);
1501
+ * console.log('Nodes removed:', diff.nodes.removed);
1502
+ * },
1503
+ * onError: (err) => console.error('Handler error:', err),
1504
+ * });
1505
+ *
1506
+ * // Later, to stop receiving updates:
1507
+ * unsubscribe();
1508
+ *
1509
+ * @example
1510
+ * // With replay: get initial state immediately
1511
+ * await graph.materialize();
1512
+ * graph.subscribe({
1513
+ * onChange: (diff) => console.log('Initial or changed:', diff),
1514
+ * replay: true, // Immediately fires with current state as additions
1515
+ * });
1516
+ */
1517
+ subscribe({ onChange, onError, replay = false }) {
1518
+ if (typeof onChange !== 'function') {
1519
+ throw new Error('onChange must be a function');
1520
+ }
1521
+
1522
+ const subscriber = { onChange, onError, pendingReplay: replay && !this._cachedState };
1523
+ this._subscribers.push(subscriber);
1524
+
1525
+ // Immediate replay if requested and cached state is available
1526
+ if (replay && this._cachedState) {
1527
+ const diff = diffStates(null, this._cachedState);
1528
+ if (!isEmptyDiff(diff)) {
1529
+ try {
1530
+ onChange(diff);
1531
+ } catch (err) {
1532
+ if (onError) {
1533
+ try {
1534
+ onError(err);
1535
+ } catch {
1536
+ // onError itself threw — swallow to prevent cascade
1537
+ }
1538
+ }
1539
+ }
1540
+ }
1541
+ }
1542
+
1543
+ return {
1544
+ unsubscribe: () => {
1545
+ const index = this._subscribers.indexOf(subscriber);
1546
+ if (index !== -1) {
1547
+ this._subscribers.splice(index, 1);
1548
+ }
1549
+ },
1550
+ };
1551
+ }
1552
+
1553
+ /**
1554
+ * Watches for graph changes matching a pattern.
1555
+ *
1556
+ * Like `subscribe()`, but only fires for changes where node IDs match the
1557
+ * provided glob pattern. Uses the same pattern syntax as `query().match()`.
1558
+ *
1559
+ * - Nodes: filters `added` and `removed` to matching IDs
1560
+ * - Edges: filters to edges where `from` or `to` matches the pattern
1561
+ * - Props: filters to properties where `nodeId` matches the pattern
1562
+ *
1563
+ * If all changes are filtered out, the handler is not called.
1564
+ *
1565
+ * When `poll` is set, periodically checks `hasFrontierChanged()` and auto-materializes
1566
+ * if the frontier has changed (e.g., remote writes detected). The poll interval must
1567
+ * be at least 1000ms.
1568
+ *
1569
+ * @param {string} pattern - Glob pattern (e.g., 'user:*', 'order:123', '*')
1570
+ * @param {Object} options - Watch options
1571
+ * @param {(diff: import('./services/StateDiff.js').StateDiff) => void} options.onChange - Called with filtered diff when matching changes occur
1572
+ * @param {(error: Error) => void} [options.onError] - Called if onChange throws an error
1573
+ * @param {number} [options.poll] - Poll interval in ms (min 1000); checks frontier and auto-materializes
1574
+ * @returns {{unsubscribe: () => void}} Subscription handle
1575
+ * @throws {Error} If pattern is not a string
1576
+ * @throws {Error} If onChange is not a function
1577
+ * @throws {Error} If poll is provided but less than 1000
1578
+ *
1579
+ * @example
1580
+ * const { unsubscribe } = graph.watch('user:*', {
1581
+ * onChange: (diff) => {
1582
+ * // Only user node changes arrive here
1583
+ * console.log('User nodes added:', diff.nodes.added);
1584
+ * },
1585
+ * });
1586
+ *
1587
+ * @example
1588
+ * // With polling: checks every 5s for remote changes
1589
+ * const { unsubscribe } = graph.watch('user:*', {
1590
+ * onChange: (diff) => console.log('User changed:', diff),
1591
+ * poll: 5000,
1592
+ * });
1593
+ *
1594
+ * // Later, to stop receiving updates:
1595
+ * unsubscribe();
1596
+ */
1597
+ watch(pattern, { onChange, onError, poll }) {
1598
+ if (typeof pattern !== 'string') {
1599
+ throw new Error('pattern must be a string');
1600
+ }
1601
+ if (typeof onChange !== 'function') {
1602
+ throw new Error('onChange must be a function');
1603
+ }
1604
+ if (poll !== undefined) {
1605
+ if (typeof poll !== 'number' || poll < 1000) {
1606
+ throw new Error('poll must be a number >= 1000');
1607
+ }
1608
+ }
1609
+
1610
+ // Pattern matching: same logic as QueryBuilder.match()
1611
+ // Pre-compile pattern matcher once for performance
1612
+ let matchesPattern;
1613
+ if (pattern === '*') {
1614
+ matchesPattern = () => true;
1615
+ } else if (pattern.includes('*')) {
1616
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
1617
+ const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
1618
+ matchesPattern = (nodeId) => regex.test(nodeId);
1619
+ } else {
1620
+ matchesPattern = (nodeId) => nodeId === pattern;
1621
+ }
1622
+
1623
+ // Filtered onChange that only passes matching changes
1624
+ const filteredOnChange = (diff) => {
1625
+ const filteredDiff = {
1626
+ nodes: {
1627
+ added: diff.nodes.added.filter(matchesPattern),
1628
+ removed: diff.nodes.removed.filter(matchesPattern),
1629
+ },
1630
+ edges: {
1631
+ added: diff.edges.added.filter(e => matchesPattern(e.from) || matchesPattern(e.to)),
1632
+ removed: diff.edges.removed.filter(e => matchesPattern(e.from) || matchesPattern(e.to)),
1633
+ },
1634
+ props: {
1635
+ set: diff.props.set.filter(p => matchesPattern(p.nodeId)),
1636
+ removed: diff.props.removed.filter(p => matchesPattern(p.nodeId)),
1637
+ },
1638
+ };
1639
+
1640
+ // Only call handler if there are matching changes
1641
+ const hasChanges =
1642
+ filteredDiff.nodes.added.length > 0 ||
1643
+ filteredDiff.nodes.removed.length > 0 ||
1644
+ filteredDiff.edges.added.length > 0 ||
1645
+ filteredDiff.edges.removed.length > 0 ||
1646
+ filteredDiff.props.set.length > 0 ||
1647
+ filteredDiff.props.removed.length > 0;
1648
+
1649
+ if (hasChanges) {
1650
+ onChange(filteredDiff);
1651
+ }
1652
+ };
1653
+
1654
+ // Reuse subscription infrastructure
1655
+ const subscription = this.subscribe({ onChange: filteredOnChange, onError });
1656
+
1657
+ // Polling: periodically check frontier and auto-materialize if changed
1658
+ let pollIntervalId = null;
1659
+ let pollInFlight = false;
1660
+ if (poll) {
1661
+ pollIntervalId = setInterval(() => {
1662
+ if (pollInFlight) {
1663
+ return;
1664
+ }
1665
+ pollInFlight = true;
1666
+ this.hasFrontierChanged()
1667
+ .then(async (changed) => {
1668
+ if (changed) {
1669
+ await this.materialize();
1670
+ }
1671
+ })
1672
+ .catch((err) => {
1673
+ if (onError) {
1674
+ try {
1675
+ onError(err);
1676
+ } catch {
1677
+ // onError itself threw — swallow to prevent cascade
1678
+ }
1679
+ }
1680
+ })
1681
+ .finally(() => {
1682
+ pollInFlight = false;
1683
+ });
1684
+ }, poll);
1685
+ }
1686
+
1687
+ return {
1688
+ unsubscribe: () => {
1689
+ if (pollIntervalId !== null) {
1690
+ clearInterval(pollIntervalId);
1691
+ pollIntervalId = null;
1692
+ }
1693
+ subscription.unsubscribe();
1694
+ },
1695
+ };
1696
+ }
1697
+
1698
+ /**
1699
+ * Notifies all subscribers of state changes.
1700
+ * Handles deferred replay for subscribers added with `replay: true` before
1701
+ * cached state was available.
1702
+ * @param {import('./services/StateDiff.js').StateDiffResult} diff
1703
+ * @param {import('./services/JoinReducer.js').WarpStateV5} currentState - The current state for deferred replay
1704
+ * @private
1705
+ */
1706
+ _notifySubscribers(diff, currentState) {
1707
+ for (const subscriber of this._subscribers) {
1708
+ try {
1709
+ // Handle deferred replay: on first notification, send full state diff instead
1710
+ if (subscriber.pendingReplay) {
1711
+ subscriber.pendingReplay = false;
1712
+ const replayDiff = diffStates(null, currentState);
1713
+ if (!isEmptyDiff(replayDiff)) {
1714
+ subscriber.onChange(replayDiff);
1715
+ }
1716
+ } else {
1717
+ // Skip non-replay subscribers when diff is empty
1718
+ if (isEmptyDiff(diff)) {
1719
+ continue;
1720
+ }
1721
+ subscriber.onChange(diff);
1722
+ }
1723
+ } catch (err) {
1724
+ if (subscriber.onError) {
1725
+ try {
1726
+ subscriber.onError(err);
1727
+ } catch {
1728
+ // onError itself threw — swallow to prevent cascade
1729
+ }
1730
+ }
1731
+ }
1732
+ }
1733
+ }
1734
+
1735
+ /**
1736
+ * Creates a sync request to send to a remote peer.
1737
+ * The request contains the local frontier for comparison.
1738
+ *
1739
+ * @returns {Promise<{type: 'sync-request', frontier: Map<string, string>}>} The sync request
1740
+ * @throws {Error} If listing refs fails
1741
+ *
1742
+ * @example
1743
+ * const request = await graph.createSyncRequest();
1744
+ * // Send request to remote peer...
1745
+ */
1746
+ async createSyncRequest() {
1747
+ const frontier = await this.getFrontier();
1748
+ return createSyncRequest(frontier);
1749
+ }
1750
+
1751
+ /**
1752
+ * Processes an incoming sync request and returns patches the requester needs.
1753
+ *
1754
+ * @param {{type: 'sync-request', frontier: Map<string, string>}} request - The incoming sync request
1755
+ * @returns {Promise<{type: 'sync-response', frontier: Map, patches: Map}>} The sync response
1756
+ * @throws {Error} If listing refs or reading patches fails
1757
+ *
1758
+ * @example
1759
+ * // Receive request from remote peer
1760
+ * const response = await graph.processSyncRequest(request);
1761
+ * // Send response back to requester...
1762
+ */
1763
+ async processSyncRequest(request) {
1764
+ const localFrontier = await this.getFrontier();
1765
+ return await processSyncRequest(
1766
+ request,
1767
+ localFrontier,
1768
+ this._persistence,
1769
+ this._graphName,
1770
+ { codec: this._codec }
1771
+ );
1772
+ }
1773
+
1774
+ /**
1775
+ * Applies a sync response to the local graph state.
1776
+ * Updates the cached state with received patches.
1777
+ *
1778
+ * **Requires a cached state.**
1779
+ *
1780
+ * @param {{type: 'sync-response', frontier: Map, patches: Map}} response - The sync response
1781
+ * @returns {{state: Object, frontier: Map, applied: number}} Result with updated state
1782
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
1783
+ *
1784
+ * @example
1785
+ * await graph.materialize(); // Cache state first
1786
+ * const result = graph.applySyncResponse(response);
1787
+ * console.log(`Applied ${result.applied} patches from remote`);
1788
+ */
1789
+ applySyncResponse(response) {
1790
+ if (!this._cachedState) {
1791
+ throw new QueryError('No cached state. Call materialize() first.', {
1792
+ code: 'E_NO_STATE',
1793
+ });
1794
+ }
1795
+
1796
+ const currentFrontier = this._cachedState.observedFrontier;
1797
+ const result = applySyncResponse(response, this._cachedState, currentFrontier);
1798
+
1799
+ // Update cached state
1800
+ this._cachedState = result.state;
1801
+
1802
+ // Keep _lastFrontier in sync so hasFrontierChanged() won't misreport stale.
1803
+ // Merge the response's per-writer tips into the stored frontier snapshot.
1804
+ if (this._lastFrontier && Array.isArray(response.patches)) {
1805
+ for (const { writerId, sha } of response.patches) {
1806
+ if (writerId && sha) {
1807
+ this._lastFrontier.set(writerId, sha);
1808
+ }
1809
+ }
1810
+ }
1811
+
1812
+ // Track patches for GC
1813
+ this._patchesSinceGC += result.applied;
1814
+
1815
+ return result;
1816
+ }
1817
+
1818
+ /**
1819
+ * Checks if sync is needed with a remote frontier.
1820
+ *
1821
+ * @param {Map<string, string>} remoteFrontier - The remote peer's frontier
1822
+ * @returns {Promise<boolean>} True if sync would transfer any patches
1823
+ * @throws {Error} If listing refs fails
1824
+ */
1825
+ async syncNeeded(remoteFrontier) {
1826
+ const localFrontier = await this.getFrontier();
1827
+ return syncNeeded(localFrontier, remoteFrontier);
1828
+ }
1829
+
1830
+ /**
1831
+ * Syncs with a remote peer (HTTP or direct graph instance).
1832
+ *
1833
+ * @param {string|WarpGraph} remote - URL or peer graph instance
1834
+ * @param {Object} [options]
1835
+ * @param {string} [options.path='/sync'] - Sync path (HTTP mode)
1836
+ * @param {number} [options.retries=3] - Retry count for retryable failures
1837
+ * @param {number} [options.baseDelayMs=250] - Base backoff delay
1838
+ * @param {number} [options.maxDelayMs=2000] - Max backoff delay
1839
+ * @param {number} [options.timeoutMs=10000] - Request timeout (HTTP mode)
1840
+ * @param {AbortSignal} [options.signal] - Optional abort signal to cancel sync
1841
+ * @param {(event: {type: string, attempt: number, durationMs?: number, status?: number, error?: Error}) => void} [options.onStatus]
1842
+ * @param {boolean} [options.materialize=false] - If true, auto-materialize after sync and include state in result
1843
+ * @returns {Promise<{applied: number, attempts: number, state?: import('./services/JoinReducer.js').WarpStateV5}>}
1844
+ * @throws {SyncError} If remote URL is invalid (code: `E_SYNC_REMOTE_URL`)
1845
+ * @throws {SyncError} If remote returns error or invalid response (code: `E_SYNC_REMOTE`, `E_SYNC_PROTOCOL`)
1846
+ * @throws {SyncError} If request times out (code: `E_SYNC_TIMEOUT`)
1847
+ * @throws {OperationAbortedError} If abort signal fires
1848
+ */
1849
+ async syncWith(remote, options = {}) {
1850
+ const t0 = this._clock.now();
1851
+ const {
1852
+ path = '/sync',
1853
+ retries = DEFAULT_SYNC_WITH_RETRIES,
1854
+ baseDelayMs = DEFAULT_SYNC_WITH_BASE_DELAY_MS,
1855
+ maxDelayMs = DEFAULT_SYNC_WITH_MAX_DELAY_MS,
1856
+ timeoutMs = DEFAULT_SYNC_WITH_TIMEOUT_MS,
1857
+ signal,
1858
+ onStatus,
1859
+ materialize: materializeAfterSync = false,
1860
+ } = options;
1861
+
1862
+ const hasPathOverride = Object.prototype.hasOwnProperty.call(options, 'path');
1863
+ const isDirectPeer = remote && typeof remote === 'object' &&
1864
+ typeof remote.processSyncRequest === 'function';
1865
+ let targetUrl = null;
1866
+ if (!isDirectPeer) {
1867
+ try {
1868
+ targetUrl = remote instanceof URL ? new URL(remote.toString()) : new URL(remote);
1869
+ } catch {
1870
+ throw new SyncError('Invalid remote URL', {
1871
+ code: 'E_SYNC_REMOTE_URL',
1872
+ context: { remote },
1873
+ });
1874
+ }
1875
+
1876
+ if (!['http:', 'https:'].includes(targetUrl.protocol)) {
1877
+ throw new SyncError('Unsupported remote URL protocol', {
1878
+ code: 'E_SYNC_REMOTE_URL',
1879
+ context: { protocol: targetUrl.protocol },
1880
+ });
1881
+ }
1882
+
1883
+ const normalizedPath = normalizeSyncPath(path);
1884
+ if (!targetUrl.pathname || targetUrl.pathname === '/') {
1885
+ targetUrl.pathname = normalizedPath;
1886
+ } else if (hasPathOverride) {
1887
+ targetUrl.pathname = normalizedPath;
1888
+ }
1889
+ targetUrl.hash = '';
1890
+ }
1891
+
1892
+ let attempt = 0;
1893
+ const emit = (type, payload = {}) => {
1894
+ if (typeof onStatus === 'function') {
1895
+ onStatus({ type, attempt, ...payload });
1896
+ }
1897
+ };
1898
+
1899
+ const shouldRetry = (err) => {
1900
+ if (isDirectPeer) { return false; }
1901
+ if (err instanceof SyncError) {
1902
+ return ['E_SYNC_REMOTE', 'E_SYNC_TIMEOUT', 'E_SYNC_NETWORK'].includes(err.code);
1903
+ }
1904
+ return err instanceof TimeoutError;
1905
+ };
1906
+
1907
+ const executeAttempt = async () => {
1908
+ checkAborted(signal, 'syncWith');
1909
+ attempt += 1;
1910
+ const attemptStart = Date.now();
1911
+ emit('connecting');
1912
+
1913
+ const request = await this.createSyncRequest();
1914
+ emit('requestBuilt');
1915
+
1916
+ let response;
1917
+ if (isDirectPeer) {
1918
+ emit('requestSent');
1919
+ response = await remote.processSyncRequest(request);
1920
+ emit('responseReceived');
1921
+ } else {
1922
+ emit('requestSent');
1923
+ let res;
1924
+ try {
1925
+ res = await timeout(timeoutMs, (timeoutSignal) => {
1926
+ const combinedSignal = signal
1927
+ ? AbortSignal.any([timeoutSignal, signal])
1928
+ : timeoutSignal;
1929
+ return fetch(targetUrl.toString(), {
1930
+ method: 'POST',
1931
+ headers: {
1932
+ 'content-type': 'application/json',
1933
+ 'accept': 'application/json',
1934
+ },
1935
+ body: JSON.stringify(request),
1936
+ signal: combinedSignal,
1937
+ });
1938
+ });
1939
+ } catch (err) {
1940
+ if (err?.name === 'AbortError') {
1941
+ throw new OperationAbortedError('syncWith', { reason: 'Signal received' });
1942
+ }
1943
+ if (err instanceof TimeoutError) {
1944
+ throw new SyncError('Sync request timed out', {
1945
+ code: 'E_SYNC_TIMEOUT',
1946
+ context: { timeoutMs },
1947
+ });
1948
+ }
1949
+ throw new SyncError('Network error', {
1950
+ code: 'E_SYNC_NETWORK',
1951
+ context: { message: err?.message },
1952
+ });
1953
+ }
1954
+
1955
+ emit('responseReceived', { status: res.status });
1956
+
1957
+ if (res.status >= 500) {
1958
+ throw new SyncError(`Remote error: ${res.status}`, {
1959
+ code: 'E_SYNC_REMOTE',
1960
+ context: { status: res.status },
1961
+ });
1962
+ }
1963
+
1964
+ if (res.status >= 400) {
1965
+ throw new SyncError(`Protocol error: ${res.status}`, {
1966
+ code: 'E_SYNC_PROTOCOL',
1967
+ context: { status: res.status },
1968
+ });
1969
+ }
1970
+
1971
+ try {
1972
+ response = await res.json();
1973
+ } catch {
1974
+ throw new SyncError('Invalid JSON response', {
1975
+ code: 'E_SYNC_PROTOCOL',
1976
+ context: { status: res.status },
1977
+ });
1978
+ }
1979
+ }
1980
+
1981
+ if (!this._cachedState) {
1982
+ await this.materialize();
1983
+ emit('materialized');
1984
+ }
1985
+
1986
+ if (!response || typeof response !== 'object' ||
1987
+ response.type !== 'sync-response' ||
1988
+ !response.frontier || typeof response.frontier !== 'object' || Array.isArray(response.frontier) ||
1989
+ !Array.isArray(response.patches)) {
1990
+ throw new SyncError('Invalid sync response', {
1991
+ code: 'E_SYNC_PROTOCOL',
1992
+ });
1993
+ }
1994
+
1995
+ const result = this.applySyncResponse(response);
1996
+ emit('applied', { applied: result.applied });
1997
+
1998
+ const durationMs = Date.now() - attemptStart;
1999
+ emit('complete', { durationMs, applied: result.applied });
2000
+ return { applied: result.applied, attempts: attempt };
2001
+ };
2002
+
2003
+ try {
2004
+ const syncResult = await retry(executeAttempt, {
2005
+ retries,
2006
+ delay: baseDelayMs,
2007
+ maxDelay: maxDelayMs,
2008
+ backoff: 'exponential',
2009
+ jitter: 'decorrelated',
2010
+ signal,
2011
+ shouldRetry,
2012
+ onRetry: (error, attemptNumber, delayMs) => {
2013
+ if (typeof onStatus === 'function') {
2014
+ onStatus({ type: 'retrying', attempt: attemptNumber, delayMs, error });
2015
+ }
2016
+ },
2017
+ });
2018
+
2019
+ this._logTiming('syncWith', t0, { metrics: `${syncResult.applied} patches applied` });
2020
+
2021
+ if (materializeAfterSync) {
2022
+ if (!this._cachedState) { await this.materialize(); }
2023
+ return { ...syncResult, state: this._cachedState };
2024
+ }
2025
+ return syncResult;
2026
+ } catch (err) {
2027
+ this._logTiming('syncWith', t0, { error: err });
2028
+ if (err?.name === 'AbortError') {
2029
+ const abortedError = new OperationAbortedError('syncWith', { reason: 'Signal received' });
2030
+ if (typeof onStatus === 'function') {
2031
+ onStatus({ type: 'failed', attempt, error: abortedError });
2032
+ }
2033
+ throw abortedError;
2034
+ }
2035
+ if (err instanceof RetryExhaustedError) {
2036
+ const cause = err.cause || err;
2037
+ if (typeof onStatus === 'function') {
2038
+ onStatus({ type: 'failed', attempt: err.attempts, error: cause });
2039
+ }
2040
+ throw cause;
2041
+ }
2042
+ if (typeof onStatus === 'function') {
2043
+ onStatus({ type: 'failed', attempt, error: err });
2044
+ }
2045
+ throw err;
2046
+ }
2047
+ }
2048
+
2049
+ /**
2050
+ * Starts a built-in sync server for this graph.
2051
+ *
2052
+ * @param {Object} options
2053
+ * @param {number} options.port - Port to listen on
2054
+ * @param {string} [options.host='127.0.0.1'] - Host to bind
2055
+ * @param {string} [options.path='/sync'] - Path to handle sync requests
2056
+ * @param {number} [options.maxRequestBytes=4194304] - Max request size in bytes
2057
+ * @param {import('../ports/HttpServerPort.js').default} options.httpPort - HTTP server adapter (required)
2058
+ * @returns {Promise<{close: () => Promise<void>, url: string}>} Server handle
2059
+ * @throws {Error} If port is not a number
2060
+ * @throws {Error} If httpPort adapter is not provided
2061
+ */
2062
+ async serve({ port, host = '127.0.0.1', path = '/sync', maxRequestBytes = DEFAULT_SYNC_SERVER_MAX_BYTES, httpPort } = {}) {
2063
+ if (typeof port !== 'number') {
2064
+ throw new Error('serve() requires a numeric port');
2065
+ }
2066
+ if (!httpPort) {
2067
+ throw new Error('serve() requires an httpPort adapter');
2068
+ }
2069
+
2070
+ const httpServer = new HttpSyncServer({
2071
+ httpPort,
2072
+ graph: this,
2073
+ path,
2074
+ host,
2075
+ maxRequestBytes,
2076
+ });
2077
+
2078
+ return await httpServer.listen(port);
2079
+ }
2080
+
2081
+ // ============================================================================
2082
+ // Writer Factory Methods
2083
+ // ============================================================================
2084
+
2085
+ /**
2086
+ * Gets or creates a Writer for this graph.
2087
+ *
2088
+ * If an explicit writerId is provided, it is validated and used directly.
2089
+ * Otherwise, the writerId is resolved from git config using the key
2090
+ * `warp.writerId.<graphName>`. If no config exists, a new canonical ID
2091
+ * is generated and persisted.
2092
+ *
2093
+ * @param {string} [writerId] - Optional explicit writer ID. If not provided, resolves stable ID from git config.
2094
+ * @returns {Promise<Writer>} A Writer instance
2095
+ * @throws {Error} If writerId is invalid
2096
+ *
2097
+ * @example
2098
+ * // Use explicit writer ID
2099
+ * const writer = await graph.writer('alice');
2100
+ *
2101
+ * @example
2102
+ * // Resolve from git config (or generate new)
2103
+ * const writer = await graph.writer();
2104
+ */
2105
+ async writer(writerId) {
2106
+ // Build config adapters for resolveWriterId
2107
+ const configGet = async (key) => await this._persistence.configGet(key);
2108
+ const configSet = async (key, value) => await this._persistence.configSet(key, value);
2109
+
2110
+ // Resolve the writer ID
2111
+ const resolvedWriterId = await resolveWriterId({
2112
+ graphName: this._graphName,
2113
+ explicitWriterId: writerId,
2114
+ configGet,
2115
+ configSet,
2116
+ });
2117
+
2118
+ return new Writer({
2119
+ persistence: this._persistence,
2120
+ graphName: this._graphName,
2121
+ writerId: resolvedWriterId,
2122
+ versionVector: this._versionVector,
2123
+ getCurrentState: () => this._cachedState,
2124
+ onDeleteWithData: this._onDeleteWithData,
2125
+ onCommitSuccess: (opts) => this._onPatchCommitted(resolvedWriterId, opts),
2126
+ codec: this._codec,
2127
+ });
2128
+ }
2129
+
2130
+ /**
2131
+ * Creates a new Writer with a fresh canonical ID.
2132
+ *
2133
+ * This always generates a new unique writer ID, regardless of any
2134
+ * existing configuration. Use this when you need a guaranteed fresh
2135
+ * identity (e.g., spawning a new writer process).
2136
+ *
2137
+ * @deprecated Use `writer()` to resolve a stable ID from git config, or `writer(id)` with an explicit ID.
2138
+ * @param {Object} [opts]
2139
+ * @param {'config'|'none'} [opts.persist='none'] - Whether to persist the new ID to git config
2140
+ * @param {string} [opts.alias] - Optional alias for config key (used with persist:'config')
2141
+ * @returns {Promise<Writer>} A Writer instance with new canonical ID
2142
+ * @throws {Error} If config operations fail (when persist:'config')
2143
+ *
2144
+ * @example
2145
+ * // Create ephemeral writer (not persisted)
2146
+ * const writer = await graph.createWriter();
2147
+ *
2148
+ * @example
2149
+ * // Create and persist to git config
2150
+ * const writer = await graph.createWriter({ persist: 'config' });
2151
+ */
2152
+ async createWriter(opts = {}) {
2153
+ if (this._logger) {
2154
+ this._logger.warn('[warp] createWriter() is deprecated. Use writer() or writer(id) instead.');
2155
+ }
2156
+ // eslint-disable-next-line no-console
2157
+ console.warn('[warp] createWriter() is deprecated. Use writer() or writer(id) instead.');
2158
+
2159
+ const { persist = 'none', alias } = opts;
2160
+
2161
+ // Generate new canonical writerId
2162
+ const freshWriterId = generateWriterId();
2163
+
2164
+ // Optionally persist to git config
2165
+ if (persist === 'config') {
2166
+ const configKey = alias
2167
+ ? `warp.writerId.${alias}`
2168
+ : `warp.writerId.${this._graphName}`;
2169
+ await this._persistence.configSet(configKey, freshWriterId);
2170
+ }
2171
+
2172
+ return new Writer({
2173
+ persistence: this._persistence,
2174
+ graphName: this._graphName,
2175
+ writerId: freshWriterId,
2176
+ versionVector: this._versionVector,
2177
+ getCurrentState: () => this._cachedState,
2178
+ onDeleteWithData: this._onDeleteWithData,
2179
+ onCommitSuccess: (commitOpts) => this._onPatchCommitted(freshWriterId, commitOpts),
2180
+ codec: this._codec,
2181
+ });
2182
+ }
2183
+
2184
+ // ============================================================================
2185
+ // Auto-Materialize Guard
2186
+ // ============================================================================
2187
+
2188
+ /**
2189
+ * Ensures cached state is fresh. When autoMaterialize is enabled,
2190
+ * materializes if state is null or dirty. Otherwise throws.
2191
+ *
2192
+ * @returns {Promise<void>}
2193
+ * @throws {QueryError} If no cached state and autoMaterialize is off (code: `E_NO_STATE`)
2194
+ * @throws {QueryError} If cached state is dirty and autoMaterialize is off (code: `E_STALE_STATE`)
2195
+ * @private
2196
+ */
2197
+ async _ensureFreshState() {
2198
+ if (this._autoMaterialize && (!this._cachedState || this._stateDirty)) {
2199
+ await this.materialize();
2200
+ return;
2201
+ }
2202
+ if (!this._cachedState) {
2203
+ throw new QueryError(
2204
+ 'No cached state. Call materialize() to load initial state, or pass autoMaterialize: true to WarpGraph.open().',
2205
+ { code: 'E_NO_STATE' },
2206
+ );
2207
+ }
2208
+ if (this._stateDirty) {
2209
+ throw new QueryError(
2210
+ 'Cached state is stale. Call materialize() to refresh, or enable autoMaterialize.',
2211
+ { code: 'E_STALE_STATE' },
2212
+ );
2213
+ }
2214
+ }
2215
+
2216
+ // ============================================================================
2217
+ // Query API (Task 7) - Queries on Materialized WARP State
2218
+ // ============================================================================
2219
+
2220
+ /**
2221
+ * Creates a fluent query builder for the logical graph.
2222
+ *
2223
+ * The query builder provides a chainable API for querying nodes, filtering
2224
+ * by patterns and properties, traversing edges, and selecting results.
2225
+ *
2226
+ * **Requires a cached state.** Call materialize() first if not already cached,
2227
+ * or use autoMaterialize option when opening the graph.
2228
+ *
2229
+ * @returns {import('./services/QueryBuilder.js').default} A fluent query builder
2230
+ *
2231
+ * @example
2232
+ * await graph.materialize();
2233
+ * const users = await graph.query()
2234
+ * .match('user:*')
2235
+ * .where('active', true)
2236
+ * .outgoing('follows')
2237
+ * .select('*');
2238
+ */
2239
+ query() {
2240
+ return new QueryBuilder(this);
2241
+ }
2242
+
2243
+ /**
2244
+ * Creates a read-only observer view of the current materialized state.
2245
+ *
2246
+ * The observer sees only nodes matching the `match` glob pattern, with
2247
+ * property visibility controlled by `expose` and `redact` lists.
2248
+ * Edges are only visible when both endpoints pass the match filter.
2249
+ *
2250
+ * **Requires a cached state.** Call materialize() first if not already cached,
2251
+ * or use autoMaterialize option when opening the graph.
2252
+ *
2253
+ * @param {string} name - Observer name
2254
+ * @param {Object} config - Observer configuration
2255
+ * @param {string} config.match - Glob pattern for visible nodes (e.g. 'user:*')
2256
+ * @param {string[]} [config.expose] - Property keys to include (whitelist)
2257
+ * @param {string[]} [config.redact] - Property keys to exclude (blacklist, takes precedence over expose)
2258
+ * @returns {Promise<import('./services/ObserverView.js').default>} A read-only observer view
2259
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
2260
+ * @throws {QueryError} If cached state is dirty (code: `E_STALE_STATE`)
2261
+ *
2262
+ * @example
2263
+ * await graph.materialize();
2264
+ * const view = await graph.observer('userView', {
2265
+ * match: 'user:*',
2266
+ * redact: ['ssn', 'password'],
2267
+ * });
2268
+ * const users = await view.getNodes();
2269
+ * const result = await view.query().match('user:*').run();
2270
+ */
2271
+ async observer(name, config) {
2272
+ if (!config || typeof config.match !== 'string') {
2273
+ throw new Error('observer config.match must be a string');
2274
+ }
2275
+ await this._ensureFreshState();
2276
+ return new ObserverView({ name, config, graph: this });
2277
+ }
2278
+
2279
+ /**
2280
+ * Computes the directed MDL translation cost from observer A to observer B.
2281
+ *
2282
+ * The cost measures how much information is lost when translating from
2283
+ * A's view to B's view. It is asymmetric: cost(A->B) != cost(B->A).
2284
+ *
2285
+ * **Requires a cached state.** Call materialize() first if not already cached,
2286
+ * or use autoMaterialize option when opening the graph.
2287
+ *
2288
+ * @param {Object} configA - Observer configuration for A
2289
+ * @param {string} configA.match - Glob pattern for visible nodes
2290
+ * @param {string[]} [configA.expose] - Property keys to include
2291
+ * @param {string[]} [configA.redact] - Property keys to exclude
2292
+ * @param {Object} configB - Observer configuration for B
2293
+ * @param {string} configB.match - Glob pattern for visible nodes
2294
+ * @param {string[]} [configB.expose] - Property keys to include
2295
+ * @param {string[]} [configB.redact] - Property keys to exclude
2296
+ * @returns {Promise<{cost: number, breakdown: {nodeLoss: number, edgeLoss: number, propLoss: number}}>}
2297
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
2298
+ * @throws {QueryError} If cached state is dirty (code: `E_STALE_STATE`)
2299
+ *
2300
+ * @see Paper IV, Section 4 -- Directed rulial cost
2301
+ *
2302
+ * @example
2303
+ * await graph.materialize();
2304
+ * const result = await graph.translationCost(
2305
+ * { match: 'user:*' },
2306
+ * { match: 'user:*', redact: ['ssn'] }
2307
+ * );
2308
+ * console.log(result.cost); // e.g. 0.04
2309
+ * console.log(result.breakdown); // { nodeLoss: 0, edgeLoss: 0, propLoss: 0.2 }
2310
+ */
2311
+ async translationCost(configA, configB) {
2312
+ await this._ensureFreshState();
2313
+ return computeTranslationCost(configA, configB, this._cachedState);
2314
+ }
2315
+
2316
+ /**
2317
+ * Checks if a node exists in the materialized graph state.
2318
+ *
2319
+ * **Requires a cached state.** Call materialize() first if not already cached.
2320
+ *
2321
+ * @param {string} nodeId - The node ID to check
2322
+ * @returns {Promise<boolean>} True if the node exists in the materialized state
2323
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
2324
+ * @throws {QueryError} If cached state is dirty (code: `E_STALE_STATE`)
2325
+ *
2326
+ * @example
2327
+ * await graph.materialize();
2328
+ * if (await graph.hasNode('user:alice')) {
2329
+ * console.log('Alice exists in the graph');
2330
+ * }
2331
+ */
2332
+ async hasNode(nodeId) {
2333
+ await this._ensureFreshState();
2334
+ return orsetContains(this._cachedState.nodeAlive, nodeId);
2335
+ }
2336
+
2337
+ /**
2338
+ * Gets all properties for a node from the materialized state.
2339
+ *
2340
+ * Returns properties as a Map of key → value. Only returns properties
2341
+ * for nodes that exist in the materialized state.
2342
+ *
2343
+ * **Requires a cached state.** Call materialize() first if not already cached.
2344
+ *
2345
+ * @param {string} nodeId - The node ID to get properties for
2346
+ * @returns {Promise<Map<string, *>|null>} Map of property key → value, or null if node doesn't exist
2347
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
2348
+ * @throws {QueryError} If cached state is dirty (code: `E_STALE_STATE`)
2349
+ *
2350
+ * @example
2351
+ * await graph.materialize();
2352
+ * const props = await graph.getNodeProps('user:alice');
2353
+ * if (props) {
2354
+ * console.log('Name:', props.get('name'));
2355
+ * }
2356
+ */
2357
+ async getNodeProps(nodeId) {
2358
+ await this._ensureFreshState();
2359
+
2360
+ // Check if node exists
2361
+ if (!orsetContains(this._cachedState.nodeAlive, nodeId)) {
2362
+ return null;
2363
+ }
2364
+
2365
+ // Collect all properties for this node
2366
+ const props = new Map();
2367
+ for (const [propKey, register] of this._cachedState.prop) {
2368
+ const decoded = decodePropKey(propKey);
2369
+ if (decoded.nodeId === nodeId) {
2370
+ props.set(decoded.propKey, register.value);
2371
+ }
2372
+ }
2373
+
2374
+ return props;
2375
+ }
2376
+
2377
+ /**
2378
+ * Gets all properties for an edge from the materialized state.
2379
+ *
2380
+ * Returns properties as a plain object of key → value. Only returns
2381
+ * properties for edges that exist in the materialized state.
2382
+ *
2383
+ * **Requires a cached state.** Call materialize() first if not already cached.
2384
+ *
2385
+ * @param {string} from - Source node ID
2386
+ * @param {string} to - Target node ID
2387
+ * @param {string} label - Edge label
2388
+ * @returns {Promise<Record<string, *>|null>} Object of property key → value, or null if edge doesn't exist
2389
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
2390
+ * @throws {QueryError} If cached state is dirty (code: `E_STALE_STATE`)
2391
+ *
2392
+ * @example
2393
+ * await graph.materialize();
2394
+ * const props = await graph.getEdgeProps('user:alice', 'user:bob', 'follows');
2395
+ * if (props) {
2396
+ * console.log('Weight:', props.weight);
2397
+ * }
2398
+ */
2399
+ async getEdgeProps(from, to, label) {
2400
+ await this._ensureFreshState();
2401
+
2402
+ // Check if edge exists
2403
+ const edgeKey = encodeEdgeKey(from, to, label);
2404
+ if (!orsetContains(this._cachedState.edgeAlive, edgeKey)) {
2405
+ return null;
2406
+ }
2407
+
2408
+ // Check node liveness for both endpoints
2409
+ if (!orsetContains(this._cachedState.nodeAlive, from) ||
2410
+ !orsetContains(this._cachedState.nodeAlive, to)) {
2411
+ return null;
2412
+ }
2413
+
2414
+ // Determine the birth EventId for clean-slate filtering
2415
+ const birthEvent = this._cachedState.edgeBirthEvent?.get(edgeKey);
2416
+
2417
+ // Collect all properties for this edge, filtering out stale props
2418
+ // (props set before the edge's most recent re-add)
2419
+ const props = {};
2420
+ for (const [propKey, register] of this._cachedState.prop) {
2421
+ if (!isEdgePropKey(propKey)) {
2422
+ continue;
2423
+ }
2424
+ const decoded = decodeEdgePropKey(propKey);
2425
+ if (decoded.from === from && decoded.to === to && decoded.label === label) {
2426
+ if (birthEvent && register.eventId && compareEventIds(register.eventId, birthEvent) < 0) {
2427
+ continue; // stale prop from before the edge's current incarnation
2428
+ }
2429
+ props[decoded.propKey] = register.value;
2430
+ }
2431
+ }
2432
+
2433
+ return props;
2434
+ }
2435
+
2436
+ /**
2437
+ * Gets neighbors of a node from the materialized state.
2438
+ *
2439
+ * Returns node IDs connected to the given node by edges in the specified direction.
2440
+ * Direction 'outgoing' returns nodes where the given node is the edge source.
2441
+ * Direction 'incoming' returns nodes where the given node is the edge target.
2442
+ * Direction 'both' returns all connected nodes.
2443
+ *
2444
+ * **Requires a cached state.** Call materialize() first if not already cached.
2445
+ *
2446
+ * @param {string} nodeId - The node ID to get neighbors for
2447
+ * @param {'outgoing' | 'incoming' | 'both'} [direction='both'] - Edge direction to follow
2448
+ * @param {string} [edgeLabel] - Optional edge label filter
2449
+ * @returns {Promise<Array<{nodeId: string, label: string, direction: 'outgoing' | 'incoming'}>>} Array of neighbor info
2450
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
2451
+ * @throws {QueryError} If cached state is dirty (code: `E_STALE_STATE`)
2452
+ *
2453
+ * @example
2454
+ * await graph.materialize();
2455
+ * // Get all outgoing neighbors
2456
+ * const outgoing = await graph.neighbors('user:alice', 'outgoing');
2457
+ * // Get neighbors connected by 'follows' edges
2458
+ * const follows = await graph.neighbors('user:alice', 'outgoing', 'follows');
2459
+ */
2460
+ async neighbors(nodeId, direction = 'both', edgeLabel = undefined) {
2461
+ await this._ensureFreshState();
2462
+
2463
+ const neighbors = [];
2464
+
2465
+ // Iterate over all visible edges
2466
+ for (const edgeKey of orsetElements(this._cachedState.edgeAlive)) {
2467
+ const { from, to, label } = decodeEdgeKey(edgeKey);
2468
+
2469
+ // Filter by label if specified
2470
+ if (edgeLabel !== undefined && label !== edgeLabel) {
2471
+ continue;
2472
+ }
2473
+
2474
+ // Check edge direction and collect neighbors
2475
+ if ((direction === 'outgoing' || direction === 'both') && from === nodeId) {
2476
+ // Ensure target node is visible
2477
+ if (orsetContains(this._cachedState.nodeAlive, to)) {
2478
+ neighbors.push({ nodeId: to, label, direction: 'outgoing' });
2479
+ }
2480
+ }
2481
+
2482
+ if ((direction === 'incoming' || direction === 'both') && to === nodeId) {
2483
+ // Ensure source node is visible
2484
+ if (orsetContains(this._cachedState.nodeAlive, from)) {
2485
+ neighbors.push({ nodeId: from, label, direction: 'incoming' });
2486
+ }
2487
+ }
2488
+ }
2489
+
2490
+ return neighbors;
2491
+ }
2492
+
2493
+ /**
2494
+ * Gets all visible nodes in the materialized state.
2495
+ *
2496
+ * **Requires a cached state.** Call materialize() first if not already cached.
2497
+ *
2498
+ * @returns {Promise<string[]>} Array of node IDs
2499
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
2500
+ * @throws {QueryError} If cached state is dirty (code: `E_STALE_STATE`)
2501
+ *
2502
+ * @example
2503
+ * await graph.materialize();
2504
+ * for (const nodeId of await graph.getNodes()) {
2505
+ * console.log(nodeId);
2506
+ * }
2507
+ */
2508
+ async getNodes() {
2509
+ await this._ensureFreshState();
2510
+ return [...orsetElements(this._cachedState.nodeAlive)];
2511
+ }
2512
+
2513
+ /**
2514
+ * Gets all visible edges in the materialized state.
2515
+ *
2516
+ * Each edge includes a `props` object containing any edge properties
2517
+ * from the materialized state.
2518
+ *
2519
+ * **Requires a cached state.** Call materialize() first if not already cached.
2520
+ *
2521
+ * @returns {Promise<Array<{from: string, to: string, label: string, props: Record<string, *>}>>} Array of edge info
2522
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
2523
+ * @throws {QueryError} If cached state is dirty (code: `E_STALE_STATE`)
2524
+ *
2525
+ * @example
2526
+ * await graph.materialize();
2527
+ * for (const edge of await graph.getEdges()) {
2528
+ * console.log(`${edge.from} --${edge.label}--> ${edge.to}`, edge.props);
2529
+ * }
2530
+ */
2531
+ async getEdges() {
2532
+ await this._ensureFreshState();
2533
+
2534
+ // Pre-collect edge props into a lookup: "from\0to\0label" → {propKey: value}
2535
+ // Filters out stale props using full EventId ordering via compareEventIds
2536
+ // against the edge's birth EventId (clean-slate semantics on re-add)
2537
+ const edgePropsByKey = new Map();
2538
+ for (const [propKey, register] of this._cachedState.prop) {
2539
+ if (!isEdgePropKey(propKey)) {
2540
+ continue;
2541
+ }
2542
+ const decoded = decodeEdgePropKey(propKey);
2543
+ const ek = encodeEdgeKey(decoded.from, decoded.to, decoded.label);
2544
+
2545
+ // Clean-slate filter: skip props from before the edge's current incarnation
2546
+ const birthEvent = this._cachedState.edgeBirthEvent?.get(ek);
2547
+ if (birthEvent && register.eventId && compareEventIds(register.eventId, birthEvent) < 0) {
2548
+ continue;
2549
+ }
2550
+
2551
+ let bag = edgePropsByKey.get(ek);
2552
+ if (!bag) {
2553
+ bag = {};
2554
+ edgePropsByKey.set(ek, bag);
2555
+ }
2556
+ bag[decoded.propKey] = register.value;
2557
+ }
2558
+
2559
+ const edges = [];
2560
+ for (const edgeKey of orsetElements(this._cachedState.edgeAlive)) {
2561
+ const { from, to, label } = decodeEdgeKey(edgeKey);
2562
+ // Only include edges where both endpoints are visible
2563
+ if (orsetContains(this._cachedState.nodeAlive, from) &&
2564
+ orsetContains(this._cachedState.nodeAlive, to)) {
2565
+ const props = edgePropsByKey.get(edgeKey) || {};
2566
+ edges.push({ from, to, label, props });
2567
+ }
2568
+ }
2569
+ return edges;
2570
+ }
2571
+
2572
+ /**
2573
+ * Returns the number of property entries in the materialized state.
2574
+ *
2575
+ * **Requires a cached state.** Call materialize() first if not already cached.
2576
+ *
2577
+ * @returns {Promise<number>} Number of property entries
2578
+ * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`)
2579
+ * @throws {QueryError} If cached state is dirty (code: `E_STALE_STATE`)
2580
+ */
2581
+ async getPropertyCount() {
2582
+ await this._ensureFreshState();
2583
+ return this._cachedState.prop.size;
2584
+ }
2585
+
2586
+ // ============================================================================
2587
+ // Fork API (HOLOGRAM)
2588
+ // ============================================================================
2589
+
2590
+ /**
2591
+ * Creates a fork of this graph at a specific point in a writer's history.
2592
+ *
2593
+ * A fork creates a new WarpGraph instance that shares history up to the
2594
+ * specified patch SHA. Due to Git's content-addressed storage, the shared
2595
+ * history is automatically deduplicated. The fork gets a new writer ID and
2596
+ * operates independently from the original graph.
2597
+ *
2598
+ * **Key Properties:**
2599
+ * - Fork materializes the same state as the original at the fork point
2600
+ * - Writes to the fork don't appear in the original
2601
+ * - Writes to the original after fork don't appear in the fork
2602
+ * - History up to the fork point is shared (content-addressed dedup)
2603
+ *
2604
+ * @param {Object} options - Fork configuration
2605
+ * @param {string} options.from - Writer ID whose chain to fork from
2606
+ * @param {string} options.at - Patch SHA to fork at (must be in the writer's chain)
2607
+ * @param {string} [options.forkName] - Name for the forked graph. Defaults to `<graphName>-fork-<timestamp>`
2608
+ * @param {string} [options.forkWriterId] - Writer ID for the fork. Defaults to a new canonical ID.
2609
+ * @returns {Promise<WarpGraph>} A new WarpGraph instance for the fork
2610
+ * @throws {ForkError} If `from` writer does not exist (code: `E_FORK_WRITER_NOT_FOUND`)
2611
+ * @throws {ForkError} If `at` SHA does not exist (code: `E_FORK_PATCH_NOT_FOUND`)
2612
+ * @throws {ForkError} If `at` SHA is not in the writer's chain (code: `E_FORK_PATCH_NOT_IN_CHAIN`)
2613
+ * @throws {ForkError} If fork graph name is invalid (code: `E_FORK_NAME_INVALID`)
2614
+ * @throws {ForkError} If a graph with the fork name already has refs (code: `E_FORK_ALREADY_EXISTS`)
2615
+ * @throws {ForkError} If required parameters are missing or invalid (code: `E_FORK_INVALID_ARGS`)
2616
+ * @throws {ForkError} If forkWriterId is invalid (code: `E_FORK_WRITER_ID_INVALID`)
2617
+ *
2618
+ * @example
2619
+ * // Fork from alice's chain at a specific commit
2620
+ * const fork = await graph.fork({
2621
+ * from: 'alice',
2622
+ * at: 'abc123def456',
2623
+ * });
2624
+ *
2625
+ * // Fork materializes same state as original at that point
2626
+ * const originalState = await graph.materializeAt('abc123def456');
2627
+ * const forkState = await fork.materialize();
2628
+ * // originalState and forkState are equivalent
2629
+ *
2630
+ * @example
2631
+ * // Fork with custom name and writer ID
2632
+ * const fork = await graph.fork({
2633
+ * from: 'alice',
2634
+ * at: 'abc123def456',
2635
+ * forkName: 'events-experiment',
2636
+ * forkWriterId: 'experiment-writer',
2637
+ * });
2638
+ */
2639
+ async fork({ from, at, forkName, forkWriterId }) {
2640
+ const t0 = this._clock.now();
2641
+
2642
+ try {
2643
+ // Validate required parameters
2644
+ if (!from || typeof from !== 'string') {
2645
+ throw new ForkError("Required parameter 'from' is missing or not a string", {
2646
+ code: 'E_FORK_INVALID_ARGS',
2647
+ context: { from },
2648
+ });
2649
+ }
2650
+
2651
+ if (!at || typeof at !== 'string') {
2652
+ throw new ForkError("Required parameter 'at' is missing or not a string", {
2653
+ code: 'E_FORK_INVALID_ARGS',
2654
+ context: { at },
2655
+ });
2656
+ }
2657
+
2658
+ // 1. Validate that the `from` writer exists
2659
+ const writers = await this.discoverWriters();
2660
+ if (!writers.includes(from)) {
2661
+ throw new ForkError(`Writer '${from}' does not exist in graph '${this._graphName}'`, {
2662
+ code: 'E_FORK_WRITER_NOT_FOUND',
2663
+ context: { writerId: from, graphName: this._graphName, existingWriters: writers },
2664
+ });
2665
+ }
2666
+
2667
+ // 2. Validate that `at` SHA exists in the repository
2668
+ const nodeExists = await this._persistence.nodeExists(at);
2669
+ if (!nodeExists) {
2670
+ throw new ForkError(`Patch SHA '${at}' does not exist`, {
2671
+ code: 'E_FORK_PATCH_NOT_FOUND',
2672
+ context: { patchSha: at, writerId: from },
2673
+ });
2674
+ }
2675
+
2676
+ // 3. Validate that `at` SHA is in the writer's chain
2677
+ const writerRef = buildWriterRef(this._graphName, from);
2678
+ const tipSha = await this._persistence.readRef(writerRef);
2679
+
2680
+ if (!tipSha) {
2681
+ throw new ForkError(`Writer '${from}' has no commits`, {
2682
+ code: 'E_FORK_WRITER_NOT_FOUND',
2683
+ context: { writerId: from },
2684
+ });
2685
+ }
2686
+
2687
+ // Walk the chain to verify `at` is reachable from the tip
2688
+ const isInChain = await this._isAncestor(at, tipSha);
2689
+ if (!isInChain) {
2690
+ throw new ForkError(`Patch SHA '${at}' is not in writer '${from}' chain`, {
2691
+ code: 'E_FORK_PATCH_NOT_IN_CHAIN',
2692
+ context: { patchSha: at, writerId: from, tipSha },
2693
+ });
2694
+ }
2695
+
2696
+ // 4. Generate or validate fork name (add random suffix to prevent collisions)
2697
+ const resolvedForkName =
2698
+ forkName ?? `${this._graphName}-fork-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
2699
+ try {
2700
+ validateGraphName(resolvedForkName);
2701
+ } catch (err) {
2702
+ throw new ForkError(`Invalid fork name: ${err.message}`, {
2703
+ code: 'E_FORK_NAME_INVALID',
2704
+ context: { forkName: resolvedForkName, originalError: err.message },
2705
+ });
2706
+ }
2707
+
2708
+ // 5. Check that the fork graph doesn't already exist (has any refs)
2709
+ const forkWritersPrefix = buildWritersPrefix(resolvedForkName);
2710
+ const existingForkRefs = await this._persistence.listRefs(forkWritersPrefix);
2711
+ if (existingForkRefs.length > 0) {
2712
+ throw new ForkError(`Graph '${resolvedForkName}' already exists`, {
2713
+ code: 'E_FORK_ALREADY_EXISTS',
2714
+ context: { forkName: resolvedForkName, existingRefs: existingForkRefs },
2715
+ });
2716
+ }
2717
+
2718
+ // 6. Generate or validate fork writer ID
2719
+ const resolvedForkWriterId = forkWriterId || generateWriterId();
2720
+ try {
2721
+ validateWriterId(resolvedForkWriterId);
2722
+ } catch (err) {
2723
+ throw new ForkError(`Invalid fork writer ID: ${err.message}`, {
2724
+ code: 'E_FORK_WRITER_ID_INVALID',
2725
+ context: { forkWriterId: resolvedForkWriterId, originalError: err.message },
2726
+ });
2727
+ }
2728
+
2729
+ // 7. Create the fork's writer ref pointing to the `at` commit
2730
+ const forkWriterRef = buildWriterRef(resolvedForkName, resolvedForkWriterId);
2731
+ await this._persistence.updateRef(forkWriterRef, at);
2732
+
2733
+ // 8. Open and return a new WarpGraph instance for the fork
2734
+ const forkGraph = await WarpGraph.open({
2735
+ persistence: this._persistence,
2736
+ graphName: resolvedForkName,
2737
+ writerId: resolvedForkWriterId,
2738
+ gcPolicy: this._gcPolicy,
2739
+ adjacencyCacheSize: this._adjacencyCache?.maxSize ?? DEFAULT_ADJACENCY_CACHE_SIZE,
2740
+ checkpointPolicy: this._checkpointPolicy,
2741
+ autoMaterialize: this._autoMaterialize,
2742
+ onDeleteWithData: this._onDeleteWithData,
2743
+ logger: this._logger,
2744
+ clock: this._clock,
2745
+ crypto: this._crypto,
2746
+ codec: this._codec,
2747
+ });
2748
+
2749
+ this._logTiming('fork', t0, {
2750
+ metrics: `from=${from} at=${at.slice(0, 7)} name=${resolvedForkName}`,
2751
+ });
2752
+
2753
+ return forkGraph;
2754
+ } catch (err) {
2755
+ this._logTiming('fork', t0, { error: err });
2756
+ throw err;
2757
+ }
2758
+ }
2759
+
2760
+ // ============================================================================
2761
+ // Wormhole API (HOLOGRAM)
2762
+ // ============================================================================
2763
+
2764
+ /**
2765
+ * Creates a wormhole compressing a range of patches.
2766
+ *
2767
+ * A wormhole is a compressed representation of a contiguous range of patches
2768
+ * from a single writer. It preserves provenance by storing the original
2769
+ * patches as a ProvenancePayload that can be replayed during materialization.
2770
+ *
2771
+ * **Key Properties:**
2772
+ * - **Provenance Preservation**: The wormhole contains the full sub-payload,
2773
+ * allowing exact replay of the compressed segment.
2774
+ * - **Monoid Composition**: Two consecutive wormholes can be composed by
2775
+ * concatenating their sub-payloads (use `WormholeService.composeWormholes`).
2776
+ * - **Materialization Equivalence**: A wormhole + remaining patches produces
2777
+ * the same state as materializing all patches.
2778
+ *
2779
+ * @param {string} fromSha - SHA of the first (oldest) patch commit in the range
2780
+ * @param {string} toSha - SHA of the last (newest) patch commit in the range
2781
+ * @returns {Promise<{fromSha: string, toSha: string, writerId: string, payload: import('./services/ProvenancePayload.js').default, patchCount: number}>} The created wormhole edge
2782
+ * @throws {WormholeError} If fromSha or toSha doesn't exist (E_WORMHOLE_SHA_NOT_FOUND)
2783
+ * @throws {WormholeError} If fromSha is not an ancestor of toSha (E_WORMHOLE_INVALID_RANGE)
2784
+ * @throws {WormholeError} If commits span multiple writers (E_WORMHOLE_MULTI_WRITER)
2785
+ * @throws {WormholeError} If a commit is not a patch commit (E_WORMHOLE_NOT_PATCH)
2786
+ *
2787
+ * @example
2788
+ * // Compress a range of patches into a wormhole
2789
+ * const wormhole = await graph.createWormhole('abc123...', 'def456...');
2790
+ * console.log(`Compressed ${wormhole.patchCount} patches`);
2791
+ *
2792
+ * // The wormhole payload can be replayed to get the same state
2793
+ * const state = wormhole.payload.replay();
2794
+ *
2795
+ * @example
2796
+ * // Compress first 50 patches, then materialize with remaining
2797
+ * const patches = await graph.getWriterPatches('alice');
2798
+ * const wormhole = await graph.createWormhole(patches[0].sha, patches[49].sha);
2799
+ *
2800
+ * // Replay wormhole then remaining patches produces same state
2801
+ * const wormholeState = wormhole.payload.replay();
2802
+ * const remainingPayload = new ProvenancePayload(patches.slice(50));
2803
+ * const finalState = remainingPayload.replay(wormholeState);
2804
+ */
2805
+ async createWormhole(fromSha, toSha) {
2806
+ const t0 = this._clock.now();
2807
+
2808
+ try {
2809
+ const wormhole = await createWormholeImpl({
2810
+ persistence: this._persistence,
2811
+ graphName: this._graphName,
2812
+ fromSha,
2813
+ toSha,
2814
+ codec: this._codec,
2815
+ });
2816
+
2817
+ this._logTiming('createWormhole', t0, {
2818
+ metrics: `${wormhole.patchCount} patches from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`,
2819
+ });
2820
+
2821
+ return wormhole;
2822
+ } catch (err) {
2823
+ this._logTiming('createWormhole', t0, { error: err });
2824
+ throw err;
2825
+ }
2826
+ }
2827
+
2828
+ // ============================================================================
2829
+ // Provenance Index API (HG/IO/2)
2830
+ // ============================================================================
2831
+
2832
+ /**
2833
+ * Returns all patch SHAs that affected a given node or edge.
2834
+ *
2835
+ * "Affected" means the patch either read from or wrote to the entity
2836
+ * (based on the patch's I/O declarations from HG/IO/1).
2837
+ *
2838
+ * If `autoMaterialize` is enabled, this will automatically materialize
2839
+ * the state if dirty. Otherwise, call `materialize()` first.
2840
+ *
2841
+ * @param {string} entityId - The node ID or edge key to query
2842
+ * @returns {Promise<string[]>} Array of patch SHAs that affected the entity, sorted alphabetically
2843
+ * @throws {QueryError} If no cached state exists and autoMaterialize is off (code: `E_NO_STATE`)
2844
+ *
2845
+ * @example
2846
+ * const shas = await graph.patchesFor('user:alice');
2847
+ * console.log(`Node user:alice was affected by ${shas.length} patches:`, shas);
2848
+ *
2849
+ * @example
2850
+ * // Query which patches affected an edge
2851
+ * const edgeKey = encodeEdgeKey('user:alice', 'user:bob', 'follows');
2852
+ * const edgeShas = await graph.patchesFor(edgeKey);
2853
+ */
2854
+ async patchesFor(entityId) {
2855
+ await this._ensureFreshState();
2856
+
2857
+ if (!this._provenanceIndex) {
2858
+ throw new QueryError('No provenance index. Call materialize() first.', {
2859
+ code: 'E_NO_STATE',
2860
+ });
2861
+ }
2862
+ return this._provenanceIndex.patchesFor(entityId);
2863
+ }
2864
+
2865
+ // ============================================================================
2866
+ // Slice Materialization (HG/SLICE/1)
2867
+ // ============================================================================
2868
+
2869
+ /**
2870
+ * Materializes only the backward causal cone for a specific node.
2871
+ *
2872
+ * This implements the slicing theorem from Paper III (Computational Holography):
2873
+ * Given a target node v, compute its backward causal cone D(v) - the set of
2874
+ * all patches that contributed to v's current state - and replay only those.
2875
+ *
2876
+ * The algorithm:
2877
+ * 1. Start with patches that directly wrote to the target node
2878
+ * 2. For each patch in the cone, find patches it depends on (via reads)
2879
+ * 3. Recursively gather all dependencies
2880
+ * 4. Topologically sort by Lamport timestamp (causal order)
2881
+ * 5. Replay the sorted patches against empty state
2882
+ *
2883
+ * **Requires a cached state.** Call materialize() first to build the provenance index.
2884
+ *
2885
+ * @param {string} nodeId - The target node ID to materialize the cone for
2886
+ * @param {{receipts?: boolean}} [options] - Optional configuration
2887
+ * @returns {Promise<{state: import('./services/JoinReducer.js').WarpStateV5, patchCount: number, receipts?: import('./types/TickReceipt.js').TickReceipt[]}>}
2888
+ * Returns the sliced state with the patch count (for comparison with full materialization)
2889
+ * @throws {QueryError} If no provenance index exists (code: `E_NO_STATE`)
2890
+ * @throws {Error} If patch loading fails
2891
+ *
2892
+ * @example
2893
+ * await graph.materialize();
2894
+ *
2895
+ * // Materialize only the causal cone for a specific node
2896
+ * const slice = await graph.materializeSlice('user:alice');
2897
+ * console.log(`Slice required ${slice.patchCount} patches`);
2898
+ *
2899
+ * // The sliced state contains only the target node and its dependencies
2900
+ * const props = slice.state.prop;
2901
+ *
2902
+ * @example
2903
+ * // Compare with full materialization
2904
+ * const fullState = await graph.materialize();
2905
+ * const slice = await graph.materializeSlice('node:target');
2906
+ *
2907
+ * // Slice should have fewer patches (unless the entire graph is connected)
2908
+ * console.log(`Full: all patches, Slice: ${slice.patchCount} patches`);
2909
+ */
2910
+ async materializeSlice(nodeId, options) {
2911
+ const t0 = this._clock.now();
2912
+ const collectReceipts = options && options.receipts;
2913
+
2914
+ try {
2915
+ // Ensure fresh state before accessing provenance index
2916
+ await this._ensureFreshState();
2917
+
2918
+ if (!this._provenanceIndex) {
2919
+ throw new QueryError('No provenance index. Call materialize() first.', {
2920
+ code: 'E_NO_STATE',
2921
+ });
2922
+ }
2923
+
2924
+ // 1. Compute backward causal cone using BFS over the provenance index
2925
+ // Returns Map<sha, patch> with patches already loaded (avoids double I/O)
2926
+ const conePatchMap = await this._computeBackwardCone(nodeId);
2927
+
2928
+ // 2. If no patches in cone, return empty state
2929
+ if (conePatchMap.size === 0) {
2930
+ const emptyState = createEmptyStateV5();
2931
+ this._logTiming('materializeSlice', t0, { metrics: '0 patches (empty cone)' });
2932
+ return {
2933
+ state: emptyState,
2934
+ patchCount: 0,
2935
+ ...(collectReceipts ? { receipts: [] } : {}),
2936
+ };
2937
+ }
2938
+
2939
+ // 3. Convert cached patches to entry format (patches already loaded by _computeBackwardCone)
2940
+ const patchEntries = [];
2941
+ for (const [sha, patch] of conePatchMap) {
2942
+ patchEntries.push({ patch, sha });
2943
+ }
2944
+
2945
+ // 4. Topologically sort by causal order (Lamport timestamp, then writer, then SHA)
2946
+ const sortedPatches = this._sortPatchesCausally(patchEntries);
2947
+
2948
+ // 5. Replay: use reduceV5 directly when collecting receipts, otherwise use ProvenancePayload
2949
+ this._logTiming('materializeSlice', t0, { metrics: `${sortedPatches.length} patches` });
2950
+
2951
+ if (collectReceipts) {
2952
+ const result = reduceV5(sortedPatches, undefined, { receipts: true });
2953
+ return {
2954
+ state: result.state,
2955
+ patchCount: sortedPatches.length,
2956
+ receipts: result.receipts,
2957
+ };
2958
+ }
2959
+
2960
+ const payload = new ProvenancePayload(sortedPatches);
2961
+ return {
2962
+ state: payload.replay(),
2963
+ patchCount: sortedPatches.length,
2964
+ };
2965
+ } catch (err) {
2966
+ this._logTiming('materializeSlice', t0, { error: err });
2967
+ throw err;
2968
+ }
2969
+ }
2970
+
2971
+ /**
2972
+ * Computes the backward causal cone for a node.
2973
+ *
2974
+ * Uses BFS over the provenance index:
2975
+ * 1. Find all patches that wrote to the target node
2976
+ * 2. For each patch, find entities it read from
2977
+ * 3. Find all patches that wrote to those entities
2978
+ * 4. Repeat until no new patches are found
2979
+ *
2980
+ * Returns a Map of SHA → patch to avoid double-loading (the cone
2981
+ * computation needs to read patches for their read-dependencies,
2982
+ * so we cache them for later replay).
2983
+ *
2984
+ * @param {string} nodeId - The target node ID
2985
+ * @returns {Promise<Map<string, Object>>} Map of patch SHA to loaded patch object
2986
+ * @private
2987
+ */
2988
+ async _computeBackwardCone(nodeId) {
2989
+ const cone = new Map(); // sha → patch (cache loaded patches)
2990
+ const visited = new Set(); // Visited entities
2991
+ const queue = [nodeId]; // BFS queue of entities to process
2992
+ let qi = 0;
2993
+
2994
+ while (qi < queue.length) {
2995
+ const entityId = queue[qi++];
2996
+
2997
+ if (visited.has(entityId)) {
2998
+ continue;
2999
+ }
3000
+ visited.add(entityId);
3001
+
3002
+ // Get all patches that affected this entity
3003
+ const patchShas = this._provenanceIndex.patchesFor(entityId);
3004
+
3005
+ for (const sha of patchShas) {
3006
+ if (cone.has(sha)) {
3007
+ continue;
3008
+ }
3009
+
3010
+ // Load the patch and cache it
3011
+ const patch = await this._loadPatchBySha(sha);
3012
+ cone.set(sha, patch);
3013
+
3014
+ // Add read dependencies to the queue
3015
+ if (patch && patch.reads) {
3016
+ for (const readEntity of patch.reads) {
3017
+ if (!visited.has(readEntity)) {
3018
+ queue.push(readEntity);
3019
+ }
3020
+ }
3021
+ }
3022
+ }
3023
+ }
3024
+
3025
+ return cone;
3026
+ }
3027
+
3028
+ /**
3029
+ * Loads a single patch by its SHA.
3030
+ *
3031
+ * @param {string} sha - The patch commit SHA
3032
+ * @returns {Promise<Object>} The decoded patch object
3033
+ * @throws {Error} If the commit is not a patch or loading fails
3034
+ * @private
3035
+ */
3036
+ async _loadPatchBySha(sha) {
3037
+ const nodeInfo = await this._persistence.getNodeInfo(sha);
3038
+ const kind = detectMessageKind(nodeInfo.message);
3039
+
3040
+ if (kind !== 'patch') {
3041
+ throw new Error(`Commit ${sha} is not a patch`);
3042
+ }
3043
+
3044
+ const patchMeta = decodePatchMessage(nodeInfo.message);
3045
+ const patchBuffer = await this._persistence.readBlob(patchMeta.patchOid);
3046
+ return this._codec.decode(patchBuffer);
3047
+ }
3048
+
3049
+ /**
3050
+ * Loads multiple patches by their SHAs.
3051
+ *
3052
+ * @param {string[]} shas - Array of patch commit SHAs
3053
+ * @returns {Promise<Array<{patch: Object, sha: string}>>} Array of patch entries
3054
+ * @throws {Error} If any SHA is not a patch or loading fails
3055
+ * @private
3056
+ */
3057
+ async _loadPatchesBySha(shas) {
3058
+ const entries = [];
3059
+
3060
+ for (const sha of shas) {
3061
+ const patch = await this._loadPatchBySha(sha);
3062
+ entries.push({ patch, sha });
3063
+ }
3064
+
3065
+ return entries;
3066
+ }
3067
+
3068
+ /**
3069
+ * Sorts patches in causal order for deterministic replay.
3070
+ *
3071
+ * Sort order: Lamport timestamp (ascending), then writer ID, then SHA.
3072
+ * This ensures deterministic ordering regardless of discovery order.
3073
+ *
3074
+ * @param {Array<{patch: Object, sha: string}>} patches - Unsorted patch entries
3075
+ * @returns {Array<{patch: Object, sha: string}>} Sorted patch entries
3076
+ * @private
3077
+ */
3078
+ _sortPatchesCausally(patches) {
3079
+ return [...patches].sort((a, b) => {
3080
+ // Primary: Lamport timestamp (ascending - earlier patches first)
3081
+ const lamportDiff = (a.patch.lamport || 0) - (b.patch.lamport || 0);
3082
+ if (lamportDiff !== 0) {
3083
+ return lamportDiff;
3084
+ }
3085
+
3086
+ // Secondary: Writer ID (lexicographic)
3087
+ const writerCmp = (a.patch.writer || '').localeCompare(b.patch.writer || '');
3088
+ if (writerCmp !== 0) {
3089
+ return writerCmp;
3090
+ }
3091
+
3092
+ // Tertiary: SHA (lexicographic) for total ordering
3093
+ return a.sha.localeCompare(b.sha);
3094
+ });
3095
+ }
3096
+
3097
+ /**
3098
+ * Gets the temporal query interface for CTL*-style temporal operators.
3099
+ *
3100
+ * Returns a TemporalQuery instance that provides `always` and `eventually`
3101
+ * operators for evaluating predicates across the graph's history.
3102
+ *
3103
+ * The instance is lazily created on first access and reused thereafter.
3104
+ *
3105
+ * @returns {import('./services/TemporalQuery.js').TemporalQuery} Temporal query interface
3106
+ *
3107
+ * @example
3108
+ * const alwaysActive = await graph.temporal.always(
3109
+ * 'user:alice',
3110
+ * n => n.props.status === 'active',
3111
+ * { since: 0 }
3112
+ * );
3113
+ *
3114
+ * @example
3115
+ * const eventuallyMerged = await graph.temporal.eventually(
3116
+ * 'user:alice',
3117
+ * n => n.props.status === 'merged'
3118
+ * );
3119
+ */
3120
+ get temporal() {
3121
+ if (!this._temporalQuery) {
3122
+ this._temporalQuery = new TemporalQuery({
3123
+ loadAllPatches: async () => {
3124
+ const writerIds = await this.discoverWriters();
3125
+ const allPatches = [];
3126
+ for (const writerId of writerIds) {
3127
+ const writerPatches = await this._loadWriterPatches(writerId);
3128
+ allPatches.push(...writerPatches);
3129
+ }
3130
+ return this._sortPatchesCausally(allPatches);
3131
+ },
3132
+ });
3133
+ }
3134
+ return this._temporalQuery;
3135
+ }
3136
+
3137
+ /**
3138
+ * Gets the current provenance index for this graph.
3139
+ *
3140
+ * The provenance index maps node/edge IDs to the patch SHAs that affected them.
3141
+ * It is built during materialization from the patches' I/O declarations.
3142
+ *
3143
+ * **Requires a cached state.** Call materialize() first if not already cached.
3144
+ *
3145
+ * @returns {import('./services/ProvenanceIndex.js').ProvenanceIndex|null} The provenance index, or null if not materialized
3146
+ *
3147
+ * @example
3148
+ * await graph.materialize();
3149
+ * const index = graph.provenanceIndex;
3150
+ * if (index) {
3151
+ * console.log(`Index contains ${index.size} entities`);
3152
+ * }
3153
+ */
3154
+ get provenanceIndex() {
3155
+ return this._provenanceIndex;
3156
+ }
3157
+ }