@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.
- package/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +480 -0
- package/SECURITY.md +30 -0
- package/bin/git-warp +24 -0
- package/bin/warp-graph.js +1574 -0
- package/index.d.ts +2366 -0
- package/index.js +180 -0
- package/package.json +129 -0
- package/scripts/install-git-warp.sh +258 -0
- package/scripts/uninstall-git-warp.sh +139 -0
- package/src/domain/WarpGraph.js +3157 -0
- package/src/domain/crdt/Dot.js +160 -0
- package/src/domain/crdt/LWW.js +154 -0
- package/src/domain/crdt/ORSet.js +371 -0
- package/src/domain/crdt/VersionVector.js +222 -0
- package/src/domain/entities/GraphNode.js +60 -0
- package/src/domain/errors/EmptyMessageError.js +47 -0
- package/src/domain/errors/ForkError.js +30 -0
- package/src/domain/errors/IndexError.js +23 -0
- package/src/domain/errors/OperationAbortedError.js +22 -0
- package/src/domain/errors/QueryError.js +39 -0
- package/src/domain/errors/SchemaUnsupportedError.js +17 -0
- package/src/domain/errors/ShardCorruptionError.js +56 -0
- package/src/domain/errors/ShardLoadError.js +57 -0
- package/src/domain/errors/ShardValidationError.js +61 -0
- package/src/domain/errors/StorageError.js +57 -0
- package/src/domain/errors/SyncError.js +30 -0
- package/src/domain/errors/TraversalError.js +23 -0
- package/src/domain/errors/WarpError.js +31 -0
- package/src/domain/errors/WormholeError.js +28 -0
- package/src/domain/errors/WriterError.js +39 -0
- package/src/domain/errors/index.js +21 -0
- package/src/domain/services/AnchorMessageCodec.js +99 -0
- package/src/domain/services/BitmapIndexBuilder.js +225 -0
- package/src/domain/services/BitmapIndexReader.js +435 -0
- package/src/domain/services/BoundaryTransitionRecord.js +463 -0
- package/src/domain/services/CheckpointMessageCodec.js +147 -0
- package/src/domain/services/CheckpointSerializerV5.js +281 -0
- package/src/domain/services/CheckpointService.js +384 -0
- package/src/domain/services/CommitDagTraversalService.js +156 -0
- package/src/domain/services/DagPathFinding.js +712 -0
- package/src/domain/services/DagTopology.js +239 -0
- package/src/domain/services/DagTraversal.js +245 -0
- package/src/domain/services/Frontier.js +108 -0
- package/src/domain/services/GCMetrics.js +101 -0
- package/src/domain/services/GCPolicy.js +122 -0
- package/src/domain/services/GitLogParser.js +205 -0
- package/src/domain/services/HealthCheckService.js +246 -0
- package/src/domain/services/HookInstaller.js +326 -0
- package/src/domain/services/HttpSyncServer.js +262 -0
- package/src/domain/services/IndexRebuildService.js +426 -0
- package/src/domain/services/IndexStalenessChecker.js +103 -0
- package/src/domain/services/JoinReducer.js +582 -0
- package/src/domain/services/KeyCodec.js +113 -0
- package/src/domain/services/LegacyAnchorDetector.js +67 -0
- package/src/domain/services/LogicalTraversal.js +351 -0
- package/src/domain/services/MessageCodecInternal.js +132 -0
- package/src/domain/services/MessageSchemaDetector.js +145 -0
- package/src/domain/services/MigrationService.js +55 -0
- package/src/domain/services/ObserverView.js +265 -0
- package/src/domain/services/PatchBuilderV2.js +669 -0
- package/src/domain/services/PatchMessageCodec.js +140 -0
- package/src/domain/services/ProvenanceIndex.js +337 -0
- package/src/domain/services/ProvenancePayload.js +242 -0
- package/src/domain/services/QueryBuilder.js +835 -0
- package/src/domain/services/StateDiff.js +300 -0
- package/src/domain/services/StateSerializerV5.js +156 -0
- package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
- package/src/domain/services/SyncProtocol.js +593 -0
- package/src/domain/services/TemporalQuery.js +201 -0
- package/src/domain/services/TranslationCost.js +221 -0
- package/src/domain/services/TraversalService.js +8 -0
- package/src/domain/services/WarpMessageCodec.js +29 -0
- package/src/domain/services/WarpStateIndexBuilder.js +127 -0
- package/src/domain/services/WormholeService.js +353 -0
- package/src/domain/types/TickReceipt.js +285 -0
- package/src/domain/types/WarpTypes.js +209 -0
- package/src/domain/types/WarpTypesV2.js +200 -0
- package/src/domain/utils/CachedValue.js +140 -0
- package/src/domain/utils/EventId.js +89 -0
- package/src/domain/utils/LRUCache.js +112 -0
- package/src/domain/utils/MinHeap.js +114 -0
- package/src/domain/utils/RefLayout.js +280 -0
- package/src/domain/utils/WriterId.js +205 -0
- package/src/domain/utils/cancellation.js +33 -0
- package/src/domain/utils/canonicalStringify.js +42 -0
- package/src/domain/utils/defaultClock.js +20 -0
- package/src/domain/utils/defaultCodec.js +51 -0
- package/src/domain/utils/nullLogger.js +21 -0
- package/src/domain/utils/roaring.js +181 -0
- package/src/domain/utils/shardVersion.js +9 -0
- package/src/domain/warp/PatchSession.js +217 -0
- package/src/domain/warp/Writer.js +181 -0
- package/src/hooks/post-merge.sh +60 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
- package/src/infrastructure/adapters/ClockAdapter.js +57 -0
- package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
- package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
- package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
- package/src/infrastructure/adapters/NoOpLogger.js +62 -0
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
- package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
- package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
- package/src/infrastructure/codecs/CborCodec.js +384 -0
- package/src/ports/BlobPort.js +30 -0
- package/src/ports/ClockPort.js +25 -0
- package/src/ports/CodecPort.js +25 -0
- package/src/ports/CommitPort.js +114 -0
- package/src/ports/ConfigPort.js +31 -0
- package/src/ports/CryptoPort.js +38 -0
- package/src/ports/GraphPersistencePort.js +57 -0
- package/src/ports/HttpServerPort.js +25 -0
- package/src/ports/IndexStoragePort.js +39 -0
- package/src/ports/LoggerPort.js +68 -0
- package/src/ports/RefPort.js +51 -0
- package/src/ports/TreePort.js +51 -0
- package/src/visualization/index.js +26 -0
- package/src/visualization/layouts/converters.js +75 -0
- package/src/visualization/layouts/elkAdapter.js +86 -0
- package/src/visualization/layouts/elkLayout.js +95 -0
- package/src/visualization/layouts/index.js +29 -0
- package/src/visualization/renderers/ascii/box.js +16 -0
- package/src/visualization/renderers/ascii/check.js +271 -0
- package/src/visualization/renderers/ascii/colors.js +13 -0
- package/src/visualization/renderers/ascii/formatters.js +73 -0
- package/src/visualization/renderers/ascii/graph.js +344 -0
- package/src/visualization/renderers/ascii/history.js +335 -0
- package/src/visualization/renderers/ascii/index.js +14 -0
- package/src/visualization/renderers/ascii/info.js +245 -0
- package/src/visualization/renderers/ascii/materialize.js +255 -0
- package/src/visualization/renderers/ascii/path.js +240 -0
- package/src/visualization/renderers/ascii/progress.js +32 -0
- package/src/visualization/renderers/ascii/symbols.js +33 -0
- package/src/visualization/renderers/ascii/table.js +19 -0
- package/src/visualization/renderers/browser/index.js +1 -0
- package/src/visualization/renderers/svg/index.js +159 -0
- package/src/visualization/utils/ansi.js +14 -0
- package/src/visualization/utils/time.js +40 -0
- package/src/visualization/utils/truncate.js +40 -0
- 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
|
+
}
|