@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,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PatchBuilderV2 - Fluent API for building WARP v5 (schema:2) patches.
|
|
3
|
+
*
|
|
4
|
+
* Key differences from PatchBuilder:
|
|
5
|
+
* 1. Maintains a VersionVector per writer
|
|
6
|
+
* 2. Assigns dots on add operations using vvIncrement
|
|
7
|
+
* 3. Reads current state to populate observedDots for removes
|
|
8
|
+
* 4. Includes context VersionVector in patch
|
|
9
|
+
*
|
|
10
|
+
* @module domain/services/PatchBuilderV2
|
|
11
|
+
* @see WARP v5 Spec
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import defaultCodec from '../utils/defaultCodec.js';
|
|
15
|
+
import nullLogger from '../utils/nullLogger.js';
|
|
16
|
+
import { vvIncrement, vvClone, vvSerialize } from '../crdt/VersionVector.js';
|
|
17
|
+
import { orsetGetDots, orsetContains, orsetElements } from '../crdt/ORSet.js';
|
|
18
|
+
import {
|
|
19
|
+
createNodeAddV2,
|
|
20
|
+
createNodeRemoveV2,
|
|
21
|
+
createEdgeAddV2,
|
|
22
|
+
createEdgeRemoveV2,
|
|
23
|
+
createPropSetV2,
|
|
24
|
+
createPatchV2,
|
|
25
|
+
} from '../types/WarpTypesV2.js';
|
|
26
|
+
import { encodeEdgeKey, EDGE_PROP_PREFIX } from './KeyCodec.js';
|
|
27
|
+
import { encodePatchMessage, decodePatchMessage } from './WarpMessageCodec.js';
|
|
28
|
+
import { buildWriterRef } from '../utils/RefLayout.js';
|
|
29
|
+
import WriterError from '../errors/WriterError.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Inspects materialized state for edges and properties attached to a node.
|
|
33
|
+
*
|
|
34
|
+
* Used internally by `removeNode` to detect attached data before deletion.
|
|
35
|
+
* When a node has connected edges or properties, the builder can reject,
|
|
36
|
+
* warn, or cascade delete based on the `onDeleteWithData` policy.
|
|
37
|
+
*
|
|
38
|
+
* @param {import('./JoinReducer.js').WarpStateV5} state - Materialized state to inspect
|
|
39
|
+
* @param {string} nodeId - Node ID to check for attached data
|
|
40
|
+
* @returns {{ edges: string[], props: string[], hasData: boolean }} Object containing:
|
|
41
|
+
* - `edges`: Array of encoded edge keys (`from\0to\0label`) connected to this node
|
|
42
|
+
* - `props`: Array of property keys (`nodeId\0key`) belonging to this node
|
|
43
|
+
* - `hasData`: Boolean indicating whether any edges or properties are attached
|
|
44
|
+
*/
|
|
45
|
+
function findAttachedData(state, nodeId) {
|
|
46
|
+
const edges = [];
|
|
47
|
+
const props = [];
|
|
48
|
+
|
|
49
|
+
for (const key of orsetElements(state.edgeAlive)) {
|
|
50
|
+
const parts = key.split('\0');
|
|
51
|
+
if (parts[0] === nodeId || parts[1] === nodeId) {
|
|
52
|
+
edges.push(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const propPrefix = `${nodeId}\0`;
|
|
57
|
+
for (const key of state.prop.keys()) {
|
|
58
|
+
if (key.startsWith(propPrefix)) {
|
|
59
|
+
props.push(key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { edges, props, hasData: edges.length > 0 || props.length > 0 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fluent builder for creating WARP v5 patches with dots and observed-remove semantics.
|
|
68
|
+
*/
|
|
69
|
+
export class PatchBuilderV2 {
|
|
70
|
+
/**
|
|
71
|
+
* Creates a new PatchBuilderV2.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object} options
|
|
74
|
+
* @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Git adapter
|
|
75
|
+
* (uses CommitPort + RefPort + BlobPort + TreePort methods)
|
|
76
|
+
* @param {string} options.graphName - Graph namespace
|
|
77
|
+
* @param {string} options.writerId - This writer's ID
|
|
78
|
+
* @param {number} options.lamport - Lamport timestamp for this patch
|
|
79
|
+
* @param {import('../crdt/VersionVector.js').VersionVector} options.versionVector - Current version vector
|
|
80
|
+
* @param {Function} options.getCurrentState - Function that returns the current materialized state
|
|
81
|
+
* @param {string|null} [options.expectedParentSha] - Expected parent SHA for race detection
|
|
82
|
+
* @param {Function|null} [options.onCommitSuccess] - Callback invoked after successful commit
|
|
83
|
+
* @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
|
|
84
|
+
* @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization
|
|
85
|
+
* @param {{ warn: Function }} [options.logger] - Logger for non-fatal warnings
|
|
86
|
+
*/
|
|
87
|
+
constructor({ persistence, graphName, writerId, lamport, versionVector, getCurrentState, expectedParentSha = null, onCommitSuccess = null, onDeleteWithData = 'warn', codec, logger }) {
|
|
88
|
+
/** @type {import('../../ports/GraphPersistencePort.js').default} */
|
|
89
|
+
this._persistence = persistence;
|
|
90
|
+
|
|
91
|
+
/** @type {string} */
|
|
92
|
+
this._graphName = graphName;
|
|
93
|
+
|
|
94
|
+
/** @type {string} */
|
|
95
|
+
this._writerId = writerId;
|
|
96
|
+
|
|
97
|
+
/** @type {number} */
|
|
98
|
+
this._lamport = lamport;
|
|
99
|
+
|
|
100
|
+
/** @type {import('../crdt/VersionVector.js').VersionVector} */
|
|
101
|
+
this._vv = vvClone(versionVector); // Clone to track local increments
|
|
102
|
+
|
|
103
|
+
/** @type {Function} */
|
|
104
|
+
this._getCurrentState = getCurrentState; // Function to get current materialized state
|
|
105
|
+
|
|
106
|
+
/** @type {string|null} */
|
|
107
|
+
this._expectedParentSha = expectedParentSha;
|
|
108
|
+
|
|
109
|
+
/** @type {Function|null} */
|
|
110
|
+
this._onCommitSuccess = onCommitSuccess;
|
|
111
|
+
|
|
112
|
+
/** @type {import('../types/WarpTypesV2.js').OpV2[]} */
|
|
113
|
+
this._ops = [];
|
|
114
|
+
|
|
115
|
+
/** @type {Set<string>} Edge keys added in this patch (for setEdgeProperty validation) */
|
|
116
|
+
this._edgesAdded = new Set();
|
|
117
|
+
|
|
118
|
+
/** @type {'reject'|'cascade'|'warn'} */
|
|
119
|
+
this._onDeleteWithData = onDeleteWithData;
|
|
120
|
+
|
|
121
|
+
/** @type {import('../../ports/CodecPort.js').default} */
|
|
122
|
+
this._codec = codec || defaultCodec;
|
|
123
|
+
|
|
124
|
+
/** @type {{ warn: Function }} */
|
|
125
|
+
this._logger = logger || nullLogger;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Nodes/edges read by this patch (for provenance tracking).
|
|
129
|
+
*
|
|
130
|
+
* Design note: "reads" track observed-dot dependencies — entities whose
|
|
131
|
+
* state was consulted to build this patch. Remove operations read the
|
|
132
|
+
* entity to observe its dots (OR-Set semantics). "Writes" track new data
|
|
133
|
+
* creation (adds). This distinction enables finer-grained provenance
|
|
134
|
+
* queries like "which patches wrote to X?" vs "which patches depended on X?"
|
|
135
|
+
*
|
|
136
|
+
* @type {Set<string>}
|
|
137
|
+
*/
|
|
138
|
+
this._reads = new Set();
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Nodes/edges written by this patch (for provenance tracking).
|
|
142
|
+
*
|
|
143
|
+
* Writes represent new data creation: NodeAdd writes the node, EdgeAdd
|
|
144
|
+
* writes the edge key, PropSet writes the node. Remove operations are
|
|
145
|
+
* intentionally tracked only as reads (see _reads comment above).
|
|
146
|
+
*
|
|
147
|
+
* @type {Set<string>}
|
|
148
|
+
*/
|
|
149
|
+
this._writes = new Set();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Adds a node to the graph.
|
|
154
|
+
*
|
|
155
|
+
* Generates a new dot (version vector increment) for the add operation,
|
|
156
|
+
* enabling proper OR-Set semantics. The dot uniquely identifies this
|
|
157
|
+
* add event for later observed-remove operations.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} nodeId - The node ID to add. Should be unique within the graph.
|
|
160
|
+
* Convention: use namespaced IDs like `'user:alice'` or `'doc:123'`.
|
|
161
|
+
* @returns {PatchBuilderV2} This builder instance for method chaining
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* builder.addNode('user:alice');
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* // Chained with other operations
|
|
168
|
+
* builder
|
|
169
|
+
* .addNode('user:alice')
|
|
170
|
+
* .addNode('user:bob')
|
|
171
|
+
* .addEdge('user:alice', 'user:bob', 'follows');
|
|
172
|
+
*/
|
|
173
|
+
addNode(nodeId) {
|
|
174
|
+
const dot = vvIncrement(this._vv, this._writerId);
|
|
175
|
+
this._ops.push(createNodeAddV2(nodeId, dot));
|
|
176
|
+
// Provenance: NodeAdd writes the node
|
|
177
|
+
this._writes.add(nodeId);
|
|
178
|
+
return this;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Removes a node from the graph.
|
|
183
|
+
*
|
|
184
|
+
* Reads observed dots from the current materialized state to enable proper
|
|
185
|
+
* OR-Set removal semantics. The removal only affects add events that have
|
|
186
|
+
* been observed at the time of removal; concurrent adds will survive.
|
|
187
|
+
*
|
|
188
|
+
* Behavior when the node has attached data (edges or properties) is controlled
|
|
189
|
+
* by the `onDeleteWithData` constructor option:
|
|
190
|
+
* - `'reject'`: Throws an error, preventing the deletion
|
|
191
|
+
* - `'cascade'`: Automatically generates `removeEdge` operations for all connected edges
|
|
192
|
+
* - `'warn'` (default): Logs a warning but allows the deletion, leaving orphaned data
|
|
193
|
+
*
|
|
194
|
+
* @param {string} nodeId - The node ID to remove
|
|
195
|
+
* @returns {PatchBuilderV2} This builder instance for method chaining
|
|
196
|
+
* @throws {Error} When `onDeleteWithData` is `'reject'` and the node has attached
|
|
197
|
+
* edges or properties. Error message includes counts of attached data.
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* builder.removeNode('user:alice');
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* // With cascade mode enabled in constructor
|
|
204
|
+
* const builder = graph.createPatch({ onDeleteWithData: 'cascade' });
|
|
205
|
+
* builder.removeNode('user:alice'); // Also removes all connected edges
|
|
206
|
+
*/
|
|
207
|
+
removeNode(nodeId) {
|
|
208
|
+
// Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
|
|
209
|
+
const state = this._getCurrentState();
|
|
210
|
+
|
|
211
|
+
// Cascade mode: auto-generate EdgeRemove ops for all connected edges before NodeRemove.
|
|
212
|
+
// Generated ops appear in the patch for auditability.
|
|
213
|
+
if (this._onDeleteWithData === 'cascade' && state) {
|
|
214
|
+
const { edges } = findAttachedData(state, nodeId);
|
|
215
|
+
for (const edgeKey of edges) {
|
|
216
|
+
const [from, to, label] = edgeKey.split('\0');
|
|
217
|
+
const edgeDots = [...orsetGetDots(state.edgeAlive, edgeKey)];
|
|
218
|
+
this._ops.push(createEdgeRemoveV2(from, to, label, edgeDots));
|
|
219
|
+
// Provenance: cascade-generated EdgeRemove reads the edge key (to observe its dots)
|
|
220
|
+
this._reads.add(edgeKey);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Best-effort delete-guard validation at build time (reject/warn modes)
|
|
225
|
+
if (state && this._onDeleteWithData !== 'cascade') {
|
|
226
|
+
const { edges, props, hasData } = findAttachedData(state, nodeId);
|
|
227
|
+
if (hasData) {
|
|
228
|
+
const details = [];
|
|
229
|
+
if (edges.length > 0) {
|
|
230
|
+
details.push(`${edges.length} edge(s)`);
|
|
231
|
+
}
|
|
232
|
+
if (props.length > 0) {
|
|
233
|
+
details.push(`${props.length} propert${props.length === 1 ? 'y' : 'ies'}`);
|
|
234
|
+
}
|
|
235
|
+
const summary = details.join(' and ');
|
|
236
|
+
|
|
237
|
+
if (this._onDeleteWithData === 'reject') {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Cannot delete node '${nodeId}': node has attached data (${summary}). ` +
|
|
240
|
+
`Remove edges and properties first, or set onDeleteWithData to 'cascade'.`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (this._onDeleteWithData === 'warn') {
|
|
245
|
+
// eslint-disable-next-line no-console
|
|
246
|
+
console.warn(
|
|
247
|
+
`[warp] Deleting node '${nodeId}' which has attached data (${summary}). ` +
|
|
248
|
+
`Orphaned data will remain in state.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const observedDots = state ? [...orsetGetDots(state.nodeAlive, nodeId)] : [];
|
|
255
|
+
this._ops.push(createNodeRemoveV2(nodeId, observedDots));
|
|
256
|
+
// Provenance: NodeRemove reads the node (to observe its dots)
|
|
257
|
+
this._reads.add(nodeId);
|
|
258
|
+
return this;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Adds a directed edge between two nodes.
|
|
263
|
+
*
|
|
264
|
+
* Generates a new dot (version vector increment) for the add operation,
|
|
265
|
+
* enabling proper OR-Set semantics. The edge is identified by the triple
|
|
266
|
+
* `(from, to, label)`, allowing multiple edges between the same nodes
|
|
267
|
+
* with different labels.
|
|
268
|
+
*
|
|
269
|
+
* Note: This does not validate that the source and target nodes exist.
|
|
270
|
+
* Edges can reference nodes that will be added later in the same patch
|
|
271
|
+
* or that exist in the materialized state.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} from - Source node ID (edge origin)
|
|
274
|
+
* @param {string} to - Target node ID (edge destination)
|
|
275
|
+
* @param {string} label - Edge label/type describing the relationship
|
|
276
|
+
* @returns {PatchBuilderV2} This builder instance for method chaining
|
|
277
|
+
*
|
|
278
|
+
* @example
|
|
279
|
+
* builder.addEdge('user:alice', 'user:bob', 'follows');
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* // Multiple edges between same nodes with different labels
|
|
283
|
+
* builder
|
|
284
|
+
* .addEdge('user:alice', 'user:bob', 'follows')
|
|
285
|
+
* .addEdge('user:alice', 'user:bob', 'collaborates_with');
|
|
286
|
+
*/
|
|
287
|
+
addEdge(from, to, label) {
|
|
288
|
+
const dot = vvIncrement(this._vv, this._writerId);
|
|
289
|
+
this._ops.push(createEdgeAddV2(from, to, label, dot));
|
|
290
|
+
const edgeKey = encodeEdgeKey(from, to, label);
|
|
291
|
+
this._edgesAdded.add(edgeKey);
|
|
292
|
+
// Provenance: EdgeAdd reads both endpoint nodes, writes the edge key
|
|
293
|
+
this._reads.add(from);
|
|
294
|
+
this._reads.add(to);
|
|
295
|
+
this._writes.add(edgeKey);
|
|
296
|
+
return this;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Removes a directed edge between two nodes.
|
|
301
|
+
*
|
|
302
|
+
* Reads observed dots from the current materialized state to enable proper
|
|
303
|
+
* OR-Set removal semantics. The removal only affects add events that have
|
|
304
|
+
* been observed at the time of removal; concurrent adds will survive.
|
|
305
|
+
*
|
|
306
|
+
* The edge is identified by the exact triple `(from, to, label)`. Removing
|
|
307
|
+
* an edge that doesn't exist is a no-op (the removal will have no observed
|
|
308
|
+
* dots and will not affect the materialized state).
|
|
309
|
+
*
|
|
310
|
+
* @param {string} from - Source node ID (edge origin)
|
|
311
|
+
* @param {string} to - Target node ID (edge destination)
|
|
312
|
+
* @param {string} label - Edge label/type describing the relationship
|
|
313
|
+
* @returns {PatchBuilderV2} This builder instance for method chaining
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* builder.removeEdge('user:alice', 'user:bob', 'follows');
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* // Remove edge before removing connected nodes
|
|
320
|
+
* builder
|
|
321
|
+
* .removeEdge('user:alice', 'user:bob', 'follows')
|
|
322
|
+
* .removeNode('user:alice');
|
|
323
|
+
*/
|
|
324
|
+
removeEdge(from, to, label) {
|
|
325
|
+
// Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
|
|
326
|
+
const state = this._getCurrentState();
|
|
327
|
+
const edgeKey = encodeEdgeKey(from, to, label);
|
|
328
|
+
const observedDots = state ? [...orsetGetDots(state.edgeAlive, edgeKey)] : [];
|
|
329
|
+
this._ops.push(createEdgeRemoveV2(from, to, label, observedDots));
|
|
330
|
+
// Provenance: EdgeRemove reads the edge key (to observe its dots)
|
|
331
|
+
this._reads.add(edgeKey);
|
|
332
|
+
return this;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Sets a property on a node.
|
|
337
|
+
*
|
|
338
|
+
* Properties use Last-Write-Wins (LWW) semantics ordered by EventId
|
|
339
|
+
* (lamport timestamp, then writer ID, then patch SHA). Unlike node/edge
|
|
340
|
+
* operations which use OR-Set dots, properties are simple registers
|
|
341
|
+
* where the latest write wins deterministically.
|
|
342
|
+
*
|
|
343
|
+
* Note: This does not validate that the node exists. Properties can be
|
|
344
|
+
* set on nodes that will be added later in the same patch or that exist
|
|
345
|
+
* in the materialized state.
|
|
346
|
+
*
|
|
347
|
+
* @param {string} nodeId - The node ID to set the property on
|
|
348
|
+
* @param {string} key - Property key (should not contain null bytes)
|
|
349
|
+
* @param {*} value - Property value. Must be JSON-serializable (strings,
|
|
350
|
+
* numbers, booleans, arrays, plain objects, or null). Use `null` to
|
|
351
|
+
* effectively delete a property (LWW semantics).
|
|
352
|
+
* @returns {PatchBuilderV2} This builder instance for method chaining
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* builder.setProperty('user:alice', 'name', 'Alice');
|
|
356
|
+
*
|
|
357
|
+
* @example
|
|
358
|
+
* // Set multiple properties on the same node
|
|
359
|
+
* builder
|
|
360
|
+
* .setProperty('user:alice', 'name', 'Alice')
|
|
361
|
+
* .setProperty('user:alice', 'email', 'alice@example.com')
|
|
362
|
+
* .setProperty('user:alice', 'age', 30);
|
|
363
|
+
*/
|
|
364
|
+
setProperty(nodeId, key, value) {
|
|
365
|
+
// Props don't use dots - they use EventId from patch context
|
|
366
|
+
this._ops.push(createPropSetV2(nodeId, key, value));
|
|
367
|
+
// Provenance: PropSet reads the node (implicit existence check) and writes the node
|
|
368
|
+
this._reads.add(nodeId);
|
|
369
|
+
this._writes.add(nodeId);
|
|
370
|
+
return this;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Sets a property on an edge.
|
|
375
|
+
*
|
|
376
|
+
* Properties use Last-Write-Wins (LWW) semantics ordered by EventId
|
|
377
|
+
* (lamport timestamp, then writer ID, then patch SHA). The edge is
|
|
378
|
+
* identified by the triple `(from, to, label)`.
|
|
379
|
+
*
|
|
380
|
+
* Internally, edge properties are stored using a special encoding with
|
|
381
|
+
* the `\x01` prefix to distinguish them from node properties. The
|
|
382
|
+
* JoinReducer processes them using the canonical edge property key format.
|
|
383
|
+
*
|
|
384
|
+
* Unlike `setProperty`, this method validates that the edge exists either
|
|
385
|
+
* in the current patch (added via `addEdge`) or in the materialized state.
|
|
386
|
+
* This prevents setting properties on edges that don't exist.
|
|
387
|
+
*
|
|
388
|
+
* @param {string} from - Source node ID (edge origin)
|
|
389
|
+
* @param {string} to - Target node ID (edge destination)
|
|
390
|
+
* @param {string} label - Edge label/type identifying which edge to modify
|
|
391
|
+
* @param {string} key - Property key (should not contain null bytes)
|
|
392
|
+
* @param {*} value - Property value. Must be JSON-serializable (strings,
|
|
393
|
+
* numbers, booleans, arrays, plain objects, or null). Use `null` to
|
|
394
|
+
* effectively delete a property (LWW semantics).
|
|
395
|
+
* @returns {PatchBuilderV2} This builder instance for method chaining
|
|
396
|
+
* @throws {Error} When the edge `(from, to, label)` does not exist in
|
|
397
|
+
* either this patch or the current materialized state. Message format:
|
|
398
|
+
* `"Cannot set property on unknown edge (from -> to [label]): add the edge first"`
|
|
399
|
+
*
|
|
400
|
+
* @example
|
|
401
|
+
* builder.setEdgeProperty('user:alice', 'user:bob', 'follows', 'since', '2025-01-01');
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* // Add edge and set property in the same patch
|
|
405
|
+
* builder
|
|
406
|
+
* .addEdge('user:alice', 'user:bob', 'follows')
|
|
407
|
+
* .setEdgeProperty('user:alice', 'user:bob', 'follows', 'since', '2025-01-01')
|
|
408
|
+
* .setEdgeProperty('user:alice', 'user:bob', 'follows', 'public', true);
|
|
409
|
+
*/
|
|
410
|
+
setEdgeProperty(from, to, label, key, value) {
|
|
411
|
+
// Validate edge exists in this patch or in current state
|
|
412
|
+
const ek = encodeEdgeKey(from, to, label);
|
|
413
|
+
if (!this._edgesAdded.has(ek)) {
|
|
414
|
+
const state = this._getCurrentState();
|
|
415
|
+
if (!state || !orsetContains(state.edgeAlive, ek)) {
|
|
416
|
+
throw new Error(`Cannot set property on unknown edge (${from} → ${to} [${label}]): add the edge first`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Encode the edge identity as the "node" field with the \x01 prefix.
|
|
421
|
+
// When JoinReducer processes: encodePropKey(op.node, op.key)
|
|
422
|
+
// = `\x01from\0to\0label` + `\0` + key
|
|
423
|
+
// = `\x01from\0to\0label\0key`
|
|
424
|
+
// = encodeEdgePropKey(from, to, label, key)
|
|
425
|
+
const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
|
|
426
|
+
this._ops.push(createPropSetV2(edgeNode, key, value));
|
|
427
|
+
// Provenance: setEdgeProperty reads the edge (implicit existence check) and writes the edge
|
|
428
|
+
this._reads.add(ek);
|
|
429
|
+
this._writes.add(ek);
|
|
430
|
+
return this;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Builds the PatchV2 object without committing.
|
|
435
|
+
*
|
|
436
|
+
* This method constructs the patch structure from all queued operations.
|
|
437
|
+
* The patch includes the schema version (2 or 3 depending on whether edge
|
|
438
|
+
* properties are present), writer ID, lamport timestamp, version vector
|
|
439
|
+
* context, and all operations.
|
|
440
|
+
*
|
|
441
|
+
* Note: This method is primarily for testing and inspection. For normal
|
|
442
|
+
* usage, prefer `commit()` which builds and persists the patch atomically.
|
|
443
|
+
*
|
|
444
|
+
* @returns {import('../types/WarpTypesV2.js').PatchV2} The constructed patch object containing:
|
|
445
|
+
* - `schema`: Version number (2 for node/edge ops, 3 if edge properties present)
|
|
446
|
+
* - `writer`: Writer ID string
|
|
447
|
+
* - `lamport`: Lamport timestamp for ordering
|
|
448
|
+
* - `context`: Version vector for causal context
|
|
449
|
+
* - `ops`: Array of operations (NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, PropSet)
|
|
450
|
+
*/
|
|
451
|
+
build() {
|
|
452
|
+
const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2;
|
|
453
|
+
return createPatchV2({
|
|
454
|
+
schema,
|
|
455
|
+
writer: this._writerId,
|
|
456
|
+
lamport: this._lamport,
|
|
457
|
+
context: this._vv,
|
|
458
|
+
ops: this._ops,
|
|
459
|
+
reads: [...this._reads].sort(),
|
|
460
|
+
writes: [...this._writes].sort(),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Commits the patch to the graph.
|
|
466
|
+
*
|
|
467
|
+
* This method performs the following steps atomically:
|
|
468
|
+
* 1. Validates the patch is non-empty
|
|
469
|
+
* 2. Checks for concurrent modifications (compare-and-swap on writer ref)
|
|
470
|
+
* 3. Calculates the next lamport timestamp from the parent commit
|
|
471
|
+
* 4. Encodes the patch as CBOR and writes it as a Git blob
|
|
472
|
+
* 5. Creates a Git tree containing the patch blob
|
|
473
|
+
* 6. Creates a commit with proper trailers linking to the parent
|
|
474
|
+
* 7. Updates the writer ref to point to the new commit
|
|
475
|
+
* 8. Invokes the success callback if provided (for eager re-materialization)
|
|
476
|
+
*
|
|
477
|
+
* The commit is written to the writer's patch chain at:
|
|
478
|
+
* `refs/warp/<graphName>/writers/<writerId>`
|
|
479
|
+
*
|
|
480
|
+
* @returns {Promise<string>} The commit SHA of the new patch commit
|
|
481
|
+
* @throws {Error} If the patch is empty (no operations were added).
|
|
482
|
+
* Message: `"Cannot commit empty patch: no operations added"`
|
|
483
|
+
* @throws {WriterError} If a concurrent commit was detected (another process
|
|
484
|
+
* advanced the writer ref since this builder was created). Error has
|
|
485
|
+
* `code: 'WRITER_CAS_CONFLICT'` and properties `expectedSha`, `actualSha`.
|
|
486
|
+
* Recovery: call `graph.materialize()` and retry with a new builder.
|
|
487
|
+
*
|
|
488
|
+
* @example
|
|
489
|
+
* const sha = await builder
|
|
490
|
+
* .addNode('user:alice')
|
|
491
|
+
* .setProperty('user:alice', 'name', 'Alice')
|
|
492
|
+
* .addEdge('user:alice', 'user:bob', 'follows')
|
|
493
|
+
* .commit();
|
|
494
|
+
* console.log(`Committed patch: ${sha}`);
|
|
495
|
+
*
|
|
496
|
+
* @example
|
|
497
|
+
* // Handling concurrent modification
|
|
498
|
+
* try {
|
|
499
|
+
* await builder.commit();
|
|
500
|
+
* } catch (err) {
|
|
501
|
+
* if (err.code === 'WRITER_CAS_CONFLICT') {
|
|
502
|
+
* await graph.materialize(); // Refresh state
|
|
503
|
+
* // Retry with new builder...
|
|
504
|
+
* }
|
|
505
|
+
* }
|
|
506
|
+
*/
|
|
507
|
+
async commit() {
|
|
508
|
+
// 1. Reject empty patches
|
|
509
|
+
if (this._ops.length === 0) {
|
|
510
|
+
throw new Error('Cannot commit empty patch: no operations added');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 2. Race detection: check if writer ref has advanced since builder creation
|
|
514
|
+
const writerRef = buildWriterRef(this._graphName, this._writerId);
|
|
515
|
+
const currentRefSha = await this._persistence.readRef(writerRef);
|
|
516
|
+
|
|
517
|
+
if (currentRefSha !== this._expectedParentSha) {
|
|
518
|
+
const err = new WriterError(
|
|
519
|
+
'WRITER_CAS_CONFLICT',
|
|
520
|
+
'Commit failed: writer ref was updated by another process. Re-materialize and retry.'
|
|
521
|
+
);
|
|
522
|
+
err.expectedSha = this._expectedParentSha;
|
|
523
|
+
err.actualSha = currentRefSha;
|
|
524
|
+
throw err;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// 3. Calculate lamport and parent from current ref state
|
|
528
|
+
let lamport = 1;
|
|
529
|
+
let parentCommit = null;
|
|
530
|
+
|
|
531
|
+
if (currentRefSha) {
|
|
532
|
+
// Read the current patch commit to get its lamport timestamp
|
|
533
|
+
const commitMessage = await this._persistence.showNode(currentRefSha);
|
|
534
|
+
const patchInfo = decodePatchMessage(commitMessage);
|
|
535
|
+
lamport = patchInfo.lamport + 1;
|
|
536
|
+
parentCommit = currentRefSha;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// 3. Build PatchV2 structure with correct lamport
|
|
540
|
+
// Note: Dots were assigned using constructor lamport, but commit lamport may differ.
|
|
541
|
+
// For now, we use the calculated lamport for the patch metadata.
|
|
542
|
+
// The dots themselves are independent of patch lamport (they use VV counters).
|
|
543
|
+
const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2;
|
|
544
|
+
// Use createPatchV2 for consistent patch construction (DRY with build())
|
|
545
|
+
const patch = createPatchV2({
|
|
546
|
+
schema,
|
|
547
|
+
writer: this._writerId,
|
|
548
|
+
lamport,
|
|
549
|
+
context: vvSerialize(this._vv),
|
|
550
|
+
ops: this._ops,
|
|
551
|
+
reads: [...this._reads].sort(),
|
|
552
|
+
writes: [...this._writes].sort(),
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// 4. Encode patch as CBOR
|
|
556
|
+
const patchCbor = this._codec.encode(patch);
|
|
557
|
+
|
|
558
|
+
// 5. Write patch.cbor blob
|
|
559
|
+
const patchBlobOid = await this._persistence.writeBlob(patchCbor);
|
|
560
|
+
|
|
561
|
+
// 6. Create tree with the blob
|
|
562
|
+
// Format for mktree: "mode type oid\tpath"
|
|
563
|
+
const treeEntry = `100644 blob ${patchBlobOid}\tpatch.cbor`;
|
|
564
|
+
const treeOid = await this._persistence.writeTree([treeEntry]);
|
|
565
|
+
|
|
566
|
+
// 7. Create patch commit message with trailers (schema:2)
|
|
567
|
+
const commitMessage = encodePatchMessage({
|
|
568
|
+
graph: this._graphName,
|
|
569
|
+
writer: this._writerId,
|
|
570
|
+
lamport,
|
|
571
|
+
patchOid: patchBlobOid,
|
|
572
|
+
schema,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// 8. Create commit with tree, linking to previous patch as parent if exists
|
|
576
|
+
const parents = parentCommit ? [parentCommit] : [];
|
|
577
|
+
const newCommitSha = await this._persistence.commitNodeWithTree({
|
|
578
|
+
treeOid,
|
|
579
|
+
parents,
|
|
580
|
+
message: commitMessage,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// 9. Update writer ref to point to new commit
|
|
584
|
+
await this._persistence.updateRef(writerRef, newCommitSha);
|
|
585
|
+
|
|
586
|
+
// 10. Notify success callback (updates graph's version vector + eager re-materialize)
|
|
587
|
+
if (this._onCommitSuccess) {
|
|
588
|
+
try {
|
|
589
|
+
await this._onCommitSuccess({ patch, sha: newCommitSha });
|
|
590
|
+
} catch (err) {
|
|
591
|
+
// Commit is already persisted — log but don't fail the caller.
|
|
592
|
+
this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, err);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 11. Return the new commit SHA
|
|
597
|
+
return newCommitSha;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Gets the operations array.
|
|
602
|
+
*
|
|
603
|
+
* Returns the internal array of operations queued in this builder.
|
|
604
|
+
* Useful for inspection and testing. Modifying the returned array
|
|
605
|
+
* will affect the builder's state.
|
|
606
|
+
*
|
|
607
|
+
* @returns {import('../types/WarpTypesV2.js').OpV2[]} Array of operations, each being one of:
|
|
608
|
+
* - `NodeAdd`: `{ type: 'NodeAdd', id, dot }`
|
|
609
|
+
* - `NodeRemove`: `{ type: 'NodeRemove', id, observed }`
|
|
610
|
+
* - `EdgeAdd`: `{ type: 'EdgeAdd', from, to, label, dot }`
|
|
611
|
+
* - `EdgeRemove`: `{ type: 'EdgeRemove', from, to, label, observed }`
|
|
612
|
+
* - `PropSet`: `{ type: 'PropSet', node, key, value }`
|
|
613
|
+
*/
|
|
614
|
+
get ops() {
|
|
615
|
+
return this._ops;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Gets the current version vector (with local increments).
|
|
620
|
+
*
|
|
621
|
+
* Returns the builder's version vector, which is cloned from the graph's
|
|
622
|
+
* version vector at construction time and then incremented for each
|
|
623
|
+
* `addNode` and `addEdge` operation. This tracks the causal context
|
|
624
|
+
* for OR-Set dot generation.
|
|
625
|
+
*
|
|
626
|
+
* @returns {import('../crdt/VersionVector.js').VersionVector} The version vector as a
|
|
627
|
+
* `Map<string, number>` mapping writer IDs to their logical clock values
|
|
628
|
+
*/
|
|
629
|
+
get versionVector() {
|
|
630
|
+
return this._vv;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Gets the set of node/edge IDs read by this patch.
|
|
635
|
+
*
|
|
636
|
+
* Returns a frozen copy of the reads tracked for provenance. This includes:
|
|
637
|
+
* - Nodes read via `removeNode` (to observe dots)
|
|
638
|
+
* - Endpoint nodes read via `addEdge` (implicit existence reference)
|
|
639
|
+
* - Edge keys read via `removeEdge` (to observe dots)
|
|
640
|
+
* - Nodes read via `setProperty` (implicit existence reference)
|
|
641
|
+
* - Edge keys read via `setEdgeProperty` (implicit existence reference)
|
|
642
|
+
*
|
|
643
|
+
* Note: Returns a defensive copy to prevent external mutation of internal state.
|
|
644
|
+
* The returned Set is a copy, so mutations to it do not affect the builder.
|
|
645
|
+
*
|
|
646
|
+
* @returns {ReadonlySet<string>} Copy of node IDs and encoded edge keys that were read
|
|
647
|
+
*/
|
|
648
|
+
get reads() {
|
|
649
|
+
return new Set(this._reads);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Gets the set of node/edge IDs written by this patch.
|
|
654
|
+
*
|
|
655
|
+
* Returns a copy of the writes tracked for provenance. This includes:
|
|
656
|
+
* - Nodes written via `addNode`
|
|
657
|
+
* - Edge keys written via `addEdge`
|
|
658
|
+
* - Nodes written via `setProperty`
|
|
659
|
+
* - Edge keys written via `setEdgeProperty`
|
|
660
|
+
*
|
|
661
|
+
* Note: Returns a defensive copy to prevent external mutation of internal state.
|
|
662
|
+
* The returned Set is a copy, so mutations to it do not affect the builder.
|
|
663
|
+
*
|
|
664
|
+
* @returns {ReadonlySet<string>} Copy of node IDs and encoded edge keys that were written
|
|
665
|
+
*/
|
|
666
|
+
get writes() {
|
|
667
|
+
return new Set(this._writes);
|
|
668
|
+
}
|
|
669
|
+
}
|