@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,582 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JoinReducer - WARP v5 OR-Set based reducer
|
|
3
|
+
*
|
|
4
|
+
* WarpStateV5 = {
|
|
5
|
+
* nodeAlive: ORSet<NodeId>, // GLOBAL OR-Set
|
|
6
|
+
* edgeAlive: ORSet<EdgeKey>, // GLOBAL OR-Set
|
|
7
|
+
* prop: Map<PropKey, LWWRegister>, // Keep v4 LWW with EventId
|
|
8
|
+
* observedFrontier: VersionVector
|
|
9
|
+
* }
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createORSet, orsetAdd, orsetRemove, orsetJoin } from '../crdt/ORSet.js';
|
|
13
|
+
import { createVersionVector, vvMerge, vvClone, vvDeserialize } from '../crdt/VersionVector.js';
|
|
14
|
+
import { lwwSet, lwwMax } from '../crdt/LWW.js';
|
|
15
|
+
import { createEventId, compareEventIds } from '../utils/EventId.js';
|
|
16
|
+
import { createTickReceipt, OP_TYPES } from '../types/TickReceipt.js';
|
|
17
|
+
import { encodeDot } from '../crdt/Dot.js';
|
|
18
|
+
import { encodeEdgeKey, encodePropKey } from './KeyCodec.js';
|
|
19
|
+
|
|
20
|
+
// Re-export key codec functions for backward compatibility
|
|
21
|
+
export {
|
|
22
|
+
encodeEdgeKey, decodeEdgeKey,
|
|
23
|
+
encodePropKey, decodePropKey,
|
|
24
|
+
EDGE_PROP_PREFIX,
|
|
25
|
+
encodeEdgePropKey, isEdgePropKey, decodeEdgePropKey,
|
|
26
|
+
} from './KeyCodec.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} WarpStateV5
|
|
30
|
+
* @property {import('../crdt/ORSet.js').ORSet} nodeAlive - ORSet of alive nodes
|
|
31
|
+
* @property {import('../crdt/ORSet.js').ORSet} edgeAlive - ORSet of alive edges
|
|
32
|
+
* @property {Map<string, import('../crdt/LWW.js').LWWRegister>} prop - Properties with LWW
|
|
33
|
+
* @property {import('../crdt/VersionVector.js').VersionVector} observedFrontier - Observed version vector
|
|
34
|
+
* @property {Map<string, import('../utils/EventId.js').EventId>} edgeBirthEvent - EdgeKey → EventId of most recent EdgeAdd (for clean-slate prop visibility)
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Creates an empty V5 state with all CRDT structures initialized.
|
|
39
|
+
*
|
|
40
|
+
* This is the starting point for fresh materialization. The returned state has:
|
|
41
|
+
* - Empty `nodeAlive` OR-Set (no nodes)
|
|
42
|
+
* - Empty `edgeAlive` OR-Set (no edges)
|
|
43
|
+
* - Empty `prop` Map (no properties)
|
|
44
|
+
* - Zero `observedFrontier` version vector (no patches observed)
|
|
45
|
+
* - Empty `edgeBirthEvent` Map (no edge birth events tracked)
|
|
46
|
+
*
|
|
47
|
+
* @returns {WarpStateV5} A fresh, empty WARP state ready for patch application
|
|
48
|
+
*/
|
|
49
|
+
export function createEmptyStateV5() {
|
|
50
|
+
return {
|
|
51
|
+
nodeAlive: createORSet(),
|
|
52
|
+
edgeAlive: createORSet(),
|
|
53
|
+
prop: new Map(),
|
|
54
|
+
observedFrontier: createVersionVector(),
|
|
55
|
+
edgeBirthEvent: new Map(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Applies a single V2 operation to state.
|
|
61
|
+
*
|
|
62
|
+
* This is the core mutation function for WARP state. It handles six operation types:
|
|
63
|
+
* - `NodeAdd`: Adds a node to the nodeAlive OR-Set with its dot identifier
|
|
64
|
+
* - `NodeRemove`: Removes observed dots from the nodeAlive OR-Set (tombstoning)
|
|
65
|
+
* - `EdgeAdd`: Adds an edge to the edgeAlive OR-Set and tracks its birth event
|
|
66
|
+
* - `EdgeRemove`: Removes observed dots from the edgeAlive OR-Set (tombstoning)
|
|
67
|
+
* - `PropSet`: Sets a property using LWW (Last-Write-Wins) semantics based on EventId
|
|
68
|
+
* - `BlobValue`: No-op in state; recorded in tick receipts for provenance tracking
|
|
69
|
+
* - Unknown types: Silently ignored for forward compatibility
|
|
70
|
+
*
|
|
71
|
+
* **Warning**: This function mutates `state` in place. For immutable operations,
|
|
72
|
+
* clone the state first using `cloneStateV5()`.
|
|
73
|
+
*
|
|
74
|
+
* @param {WarpStateV5} state - The state to mutate. Modified in place.
|
|
75
|
+
* @param {Object} op - The operation to apply
|
|
76
|
+
* @param {string} op.type - One of: 'NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove', 'PropSet', 'BlobValue'
|
|
77
|
+
* @param {string} [op.node] - Node ID (for NodeAdd, NodeRemove, PropSet)
|
|
78
|
+
* @param {import('../crdt/Dot.js').Dot} [op.dot] - Dot identifier (for NodeAdd, EdgeAdd)
|
|
79
|
+
* @param {string[]} [op.observedDots] - Encoded dots to remove (for NodeRemove, EdgeRemove)
|
|
80
|
+
* @param {string} [op.from] - Source node ID (for EdgeAdd, EdgeRemove)
|
|
81
|
+
* @param {string} [op.to] - Target node ID (for EdgeAdd, EdgeRemove)
|
|
82
|
+
* @param {string} [op.label] - Edge label (for EdgeAdd, EdgeRemove)
|
|
83
|
+
* @param {string} [op.key] - Property key (for PropSet)
|
|
84
|
+
* @param {*} [op.value] - Property value (for PropSet)
|
|
85
|
+
* @param {import('../utils/EventId.js').EventId} eventId - Event ID for causality tracking
|
|
86
|
+
* @returns {void}
|
|
87
|
+
*/
|
|
88
|
+
export function applyOpV2(state, op, eventId) {
|
|
89
|
+
switch (op.type) {
|
|
90
|
+
case 'NodeAdd':
|
|
91
|
+
orsetAdd(state.nodeAlive, op.node, op.dot);
|
|
92
|
+
break;
|
|
93
|
+
case 'NodeRemove':
|
|
94
|
+
orsetRemove(state.nodeAlive, op.observedDots);
|
|
95
|
+
break;
|
|
96
|
+
case 'EdgeAdd': {
|
|
97
|
+
const edgeKey = encodeEdgeKey(op.from, op.to, op.label);
|
|
98
|
+
orsetAdd(state.edgeAlive, edgeKey, op.dot);
|
|
99
|
+
// Track the EventId at which this edge incarnation was born.
|
|
100
|
+
// On re-add after remove, the greater EventId replaces the old one,
|
|
101
|
+
// allowing the query layer to filter out stale properties.
|
|
102
|
+
if (state.edgeBirthEvent) {
|
|
103
|
+
const prev = state.edgeBirthEvent.get(edgeKey);
|
|
104
|
+
if (!prev || compareEventIds(eventId, prev) > 0) {
|
|
105
|
+
state.edgeBirthEvent.set(edgeKey, eventId);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'EdgeRemove':
|
|
111
|
+
orsetRemove(state.edgeAlive, op.observedDots);
|
|
112
|
+
break;
|
|
113
|
+
case 'PropSet': {
|
|
114
|
+
// Uses EventId-based LWW, same as v4
|
|
115
|
+
const key = encodePropKey(op.node, op.key);
|
|
116
|
+
const current = state.prop.get(key);
|
|
117
|
+
state.prop.set(key, lwwMax(current, lwwSet(eventId, op.value)));
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
default:
|
|
121
|
+
// Unknown op types are silently ignored (forward-compat)
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Maps internal operation type names to TickReceipt-compatible operation type names.
|
|
128
|
+
*
|
|
129
|
+
* The internal representation uses "Remove" for tombstone operations, but the
|
|
130
|
+
* TickReceipt API uses "Tombstone" to be more explicit about CRDT semantics.
|
|
131
|
+
* This mapping ensures receipt consumers see the canonical operation names.
|
|
132
|
+
*
|
|
133
|
+
* Mappings:
|
|
134
|
+
* - NodeRemove -> NodeTombstone (CRDT tombstone semantics)
|
|
135
|
+
* - EdgeRemove -> EdgeTombstone (CRDT tombstone semantics)
|
|
136
|
+
* - All others pass through unchanged
|
|
137
|
+
*
|
|
138
|
+
* @const {Object<string, string>}
|
|
139
|
+
*/
|
|
140
|
+
const RECEIPT_OP_TYPE = {
|
|
141
|
+
NodeAdd: 'NodeAdd',
|
|
142
|
+
NodeRemove: 'NodeTombstone',
|
|
143
|
+
EdgeAdd: 'EdgeAdd',
|
|
144
|
+
EdgeRemove: 'EdgeTombstone',
|
|
145
|
+
PropSet: 'PropSet',
|
|
146
|
+
BlobValue: 'BlobValue',
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Set of valid receipt op types (from TickReceipt) for fast membership checks.
|
|
151
|
+
* Used to filter out forward-compatible unknown operation types from receipts.
|
|
152
|
+
* @const {Set<string>}
|
|
153
|
+
*/
|
|
154
|
+
const VALID_RECEIPT_OPS = new Set(OP_TYPES);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Determines the receipt outcome for a NodeAdd operation.
|
|
158
|
+
*
|
|
159
|
+
* Checks if the node's dot already exists in the OR-Set to determine whether
|
|
160
|
+
* this add operation is effective or redundant (idempotent re-delivery).
|
|
161
|
+
*
|
|
162
|
+
* @param {import('../crdt/ORSet.js').ORSet} orset - The node OR-Set containing alive nodes
|
|
163
|
+
* @param {Object} op - The NodeAdd operation
|
|
164
|
+
* @param {string} op.node - The node ID being added
|
|
165
|
+
* @param {import('../crdt/Dot.js').Dot} op.dot - The dot uniquely identifying this add event
|
|
166
|
+
* @returns {{target: string, result: 'applied'|'redundant'}} Outcome with node ID as target
|
|
167
|
+
*/
|
|
168
|
+
function nodeAddOutcome(orset, op) {
|
|
169
|
+
const encoded = encodeDot(op.dot);
|
|
170
|
+
const existingDots = orset.entries.get(op.node);
|
|
171
|
+
if (existingDots && existingDots.has(encoded)) {
|
|
172
|
+
return { target: op.node, result: 'redundant' };
|
|
173
|
+
}
|
|
174
|
+
return { target: op.node, result: 'applied' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Determines the receipt outcome for a NodeRemove (tombstone) operation.
|
|
179
|
+
*
|
|
180
|
+
* Checks if any of the observed dots exist in the OR-Set and are not yet tombstoned.
|
|
181
|
+
* A remove is only effective if it actually removes at least one existing, non-tombstoned dot.
|
|
182
|
+
* This implements OR-Set remove semantics where removes only affect dots that were
|
|
183
|
+
* observed at the time the remove was issued.
|
|
184
|
+
*
|
|
185
|
+
* @param {import('../crdt/ORSet.js').ORSet} orset - The node OR-Set containing alive nodes
|
|
186
|
+
* @param {Object} op - The NodeRemove operation
|
|
187
|
+
* @param {string} [op.node] - The node ID being removed (may be absent for dot-only removes)
|
|
188
|
+
* @param {string[]} op.observedDots - Array of encoded dots that were observed when the remove was issued
|
|
189
|
+
* @returns {{target: string, result: 'applied'|'redundant'}} Outcome with node ID (or '*') as target
|
|
190
|
+
*/
|
|
191
|
+
function nodeRemoveOutcome(orset, op) {
|
|
192
|
+
// Check if any of the observed dots are currently non-tombstoned
|
|
193
|
+
let effective = false;
|
|
194
|
+
for (const encodedDot of op.observedDots) {
|
|
195
|
+
if (!orset.tombstones.has(encodedDot)) {
|
|
196
|
+
// This dot exists and is not yet tombstoned, so the remove is effective
|
|
197
|
+
// Check if any entry actually has this dot
|
|
198
|
+
for (const dots of orset.entries.values()) {
|
|
199
|
+
if (dots.has(encodedDot)) {
|
|
200
|
+
effective = true;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (effective) {
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const target = op.node || '*';
|
|
210
|
+
return { target, result: effective ? 'applied' : 'redundant' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Determines the receipt outcome for an EdgeAdd operation.
|
|
215
|
+
*
|
|
216
|
+
* Checks if the edge's dot already exists in the OR-Set to determine whether
|
|
217
|
+
* this add operation is effective or redundant (idempotent re-delivery).
|
|
218
|
+
* Unlike nodes, edges are keyed by the composite (from, to, label) tuple.
|
|
219
|
+
*
|
|
220
|
+
* @param {import('../crdt/ORSet.js').ORSet} orset - The edge OR-Set containing alive edges
|
|
221
|
+
* @param {Object} op - The EdgeAdd operation
|
|
222
|
+
* @param {string} op.from - Source node ID
|
|
223
|
+
* @param {string} op.to - Target node ID
|
|
224
|
+
* @param {string} op.label - Edge label
|
|
225
|
+
* @param {import('../crdt/Dot.js').Dot} op.dot - The dot uniquely identifying this add event
|
|
226
|
+
* @param {string} edgeKey - Pre-encoded edge key (from\0to\0label format)
|
|
227
|
+
* @returns {{target: string, result: 'applied'|'redundant'}} Outcome with encoded edge key as target
|
|
228
|
+
*/
|
|
229
|
+
function edgeAddOutcome(orset, op, edgeKey) {
|
|
230
|
+
const encoded = encodeDot(op.dot);
|
|
231
|
+
const existingDots = orset.entries.get(edgeKey);
|
|
232
|
+
if (existingDots && existingDots.has(encoded)) {
|
|
233
|
+
return { target: edgeKey, result: 'redundant' };
|
|
234
|
+
}
|
|
235
|
+
return { target: edgeKey, result: 'applied' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Determines the receipt outcome for an EdgeRemove (tombstone) operation.
|
|
240
|
+
*
|
|
241
|
+
* Checks if any of the observed dots exist in the OR-Set and are not yet tombstoned.
|
|
242
|
+
* A remove is only effective if it actually removes at least one existing, non-tombstoned dot.
|
|
243
|
+
* This implements OR-Set remove semantics where removes only affect dots that were
|
|
244
|
+
* observed at the time the remove was issued.
|
|
245
|
+
*
|
|
246
|
+
* The target is computed from the operation's (from, to, label) fields if available,
|
|
247
|
+
* otherwise falls back to '*' for wildcard/unknown targets.
|
|
248
|
+
*
|
|
249
|
+
* @param {import('../crdt/ORSet.js').ORSet} orset - The edge OR-Set containing alive edges
|
|
250
|
+
* @param {Object} op - The EdgeRemove operation
|
|
251
|
+
* @param {string} [op.from] - Source node ID (optional for computing target)
|
|
252
|
+
* @param {string} [op.to] - Target node ID (optional for computing target)
|
|
253
|
+
* @param {string} [op.label] - Edge label (optional for computing target)
|
|
254
|
+
* @param {string[]} op.observedDots - Array of encoded dots that were observed when the remove was issued
|
|
255
|
+
* @returns {{target: string, result: 'applied'|'redundant'}} Outcome with encoded edge key (or '*') as target
|
|
256
|
+
*/
|
|
257
|
+
function edgeRemoveOutcome(orset, op) {
|
|
258
|
+
let effective = false;
|
|
259
|
+
for (const encodedDot of op.observedDots) {
|
|
260
|
+
if (!orset.tombstones.has(encodedDot)) {
|
|
261
|
+
for (const dots of orset.entries.values()) {
|
|
262
|
+
if (dots.has(encodedDot)) {
|
|
263
|
+
effective = true;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (effective) {
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Construct target from op fields if available
|
|
273
|
+
const target = (op.from && op.to && op.label)
|
|
274
|
+
? encodeEdgeKey(op.from, op.to, op.label)
|
|
275
|
+
: '*';
|
|
276
|
+
return { target, result: effective ? 'applied' : 'redundant' };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Determines the receipt outcome for a PropSet operation.
|
|
281
|
+
*
|
|
282
|
+
* Uses LWW (Last-Write-Wins) semantics to determine whether the incoming property
|
|
283
|
+
* value wins over any existing value. The comparison is based on EventId ordering:
|
|
284
|
+
* 1. Higher Lamport timestamp wins
|
|
285
|
+
* 2. On tie, higher writer ID wins (lexicographic)
|
|
286
|
+
* 3. On tie, higher patch SHA wins (lexicographic)
|
|
287
|
+
*
|
|
288
|
+
* Possible outcomes:
|
|
289
|
+
* - `applied`: The incoming value wins (no existing value or higher EventId)
|
|
290
|
+
* - `superseded`: An existing value with higher EventId wins
|
|
291
|
+
* - `redundant`: Exact same write (identical EventId)
|
|
292
|
+
*
|
|
293
|
+
* @param {Map<string, import('../crdt/LWW.js').LWWRegister>} propMap - The properties map keyed by encoded prop keys
|
|
294
|
+
* @param {Object} op - The PropSet operation
|
|
295
|
+
* @param {string} op.node - Node ID owning the property
|
|
296
|
+
* @param {string} op.key - Property key/name
|
|
297
|
+
* @param {*} op.value - Property value to set
|
|
298
|
+
* @param {import('../utils/EventId.js').EventId} eventId - The event ID for this operation, used for LWW comparison
|
|
299
|
+
* @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
|
|
300
|
+
* Outcome with encoded prop key as target; includes reason when superseded
|
|
301
|
+
*/
|
|
302
|
+
function propSetOutcome(propMap, op, eventId) {
|
|
303
|
+
const key = encodePropKey(op.node, op.key);
|
|
304
|
+
const current = propMap.get(key);
|
|
305
|
+
const target = key;
|
|
306
|
+
|
|
307
|
+
if (!current) {
|
|
308
|
+
// No existing value -- this write wins
|
|
309
|
+
return { target, result: 'applied' };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Compare the incoming EventId with the existing register's EventId
|
|
313
|
+
const cmp = compareEventIds(eventId, current.eventId);
|
|
314
|
+
if (cmp > 0) {
|
|
315
|
+
// Incoming write wins
|
|
316
|
+
return { target, result: 'applied' };
|
|
317
|
+
}
|
|
318
|
+
if (cmp < 0) {
|
|
319
|
+
// Existing write wins
|
|
320
|
+
const winner = current.eventId;
|
|
321
|
+
return {
|
|
322
|
+
target,
|
|
323
|
+
result: 'superseded',
|
|
324
|
+
reason: `LWW: writer ${winner.writerId} at lamport ${winner.lamport} wins`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
// Same EventId -- redundant (exact same write)
|
|
328
|
+
return { target, result: 'redundant' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Joins a patch into state, applying all operations in order.
|
|
333
|
+
*
|
|
334
|
+
* This is the primary function for incorporating a single patch into WARP state.
|
|
335
|
+
* It iterates through all operations in the patch, creates EventIds for causality
|
|
336
|
+
* tracking, and applies each operation using `applyOpV2`.
|
|
337
|
+
*
|
|
338
|
+
* **Receipt Collection Mode**:
|
|
339
|
+
* When `collectReceipts` is true, this function also computes the outcome of each
|
|
340
|
+
* operation (applied, redundant, or superseded) and returns a TickReceipt for
|
|
341
|
+
* provenance tracking. This has a small performance cost, so it's disabled by default.
|
|
342
|
+
*
|
|
343
|
+
* **Warning**: This function mutates `state` in place. For immutable operations,
|
|
344
|
+
* clone the state first using `cloneStateV5()`.
|
|
345
|
+
*
|
|
346
|
+
* @param {WarpStateV5} state - The state to mutate. Modified in place.
|
|
347
|
+
* @param {Object} patch - The patch to apply
|
|
348
|
+
* @param {string} patch.writer - Writer ID who created this patch
|
|
349
|
+
* @param {number} patch.lamport - Lamport timestamp of this patch
|
|
350
|
+
* @param {Object[]} patch.ops - Array of operations to apply
|
|
351
|
+
* @param {Map|Object} patch.context - Version vector context (Map or serialized form)
|
|
352
|
+
* @param {string} patchSha - The Git SHA of the patch commit (used for EventId creation)
|
|
353
|
+
* @param {boolean} [collectReceipts=false] - When true, computes and returns receipt data
|
|
354
|
+
* @returns {WarpStateV5|{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}}
|
|
355
|
+
* Returns mutated state directly when collectReceipts is false;
|
|
356
|
+
* returns {state, receipt} object when collectReceipts is true
|
|
357
|
+
*/
|
|
358
|
+
export function join(state, patch, patchSha, collectReceipts) {
|
|
359
|
+
// ZERO-COST: when collectReceipts is falsy, skip all receipt logic
|
|
360
|
+
if (!collectReceipts) {
|
|
361
|
+
for (let i = 0; i < patch.ops.length; i++) {
|
|
362
|
+
const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
|
|
363
|
+
applyOpV2(state, patch.ops[i], eventId);
|
|
364
|
+
}
|
|
365
|
+
const contextVV = patch.context instanceof Map
|
|
366
|
+
? patch.context
|
|
367
|
+
: vvDeserialize(patch.context);
|
|
368
|
+
state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
|
|
369
|
+
return state;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Receipt-enabled path
|
|
373
|
+
const opResults = [];
|
|
374
|
+
for (let i = 0; i < patch.ops.length; i++) {
|
|
375
|
+
const op = patch.ops[i];
|
|
376
|
+
const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
|
|
377
|
+
|
|
378
|
+
// Determine outcome BEFORE applying the op (state is pre-op)
|
|
379
|
+
let outcome;
|
|
380
|
+
switch (op.type) {
|
|
381
|
+
case 'NodeAdd':
|
|
382
|
+
outcome = nodeAddOutcome(state.nodeAlive, op);
|
|
383
|
+
break;
|
|
384
|
+
case 'NodeRemove':
|
|
385
|
+
outcome = nodeRemoveOutcome(state.nodeAlive, op);
|
|
386
|
+
break;
|
|
387
|
+
case 'EdgeAdd': {
|
|
388
|
+
const edgeKey = encodeEdgeKey(op.from, op.to, op.label);
|
|
389
|
+
outcome = edgeAddOutcome(state.edgeAlive, op, edgeKey);
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
case 'EdgeRemove':
|
|
393
|
+
outcome = edgeRemoveOutcome(state.edgeAlive, op);
|
|
394
|
+
break;
|
|
395
|
+
case 'PropSet':
|
|
396
|
+
outcome = propSetOutcome(state.prop, op, eventId);
|
|
397
|
+
break;
|
|
398
|
+
default:
|
|
399
|
+
// Unknown or BlobValue — always applied
|
|
400
|
+
outcome = { target: op.node || op.oid || '*', result: 'applied' };
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Apply the op (mutates state)
|
|
405
|
+
applyOpV2(state, op, eventId);
|
|
406
|
+
|
|
407
|
+
const receiptOp = RECEIPT_OP_TYPE[op.type] || op.type;
|
|
408
|
+
// Skip unknown/forward-compatible op types that aren't valid receipt ops
|
|
409
|
+
if (!VALID_RECEIPT_OPS.has(receiptOp)) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const entry = { op: receiptOp, target: outcome.target, result: outcome.result };
|
|
413
|
+
if (outcome.reason) {
|
|
414
|
+
entry.reason = outcome.reason;
|
|
415
|
+
}
|
|
416
|
+
opResults.push(entry);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const contextVV = patch.context instanceof Map
|
|
420
|
+
? patch.context
|
|
421
|
+
: vvDeserialize(patch.context);
|
|
422
|
+
state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
|
|
423
|
+
|
|
424
|
+
const receipt = createTickReceipt({
|
|
425
|
+
patchSha,
|
|
426
|
+
writer: patch.writer,
|
|
427
|
+
lamport: patch.lamport,
|
|
428
|
+
ops: opResults,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
return { state, receipt };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Joins two V5 states together using CRDT merge semantics.
|
|
436
|
+
*
|
|
437
|
+
* This function implements the state-based CRDT join operation for WARP state.
|
|
438
|
+
* Each component is merged using its appropriate CRDT join:
|
|
439
|
+
* - `nodeAlive` and `edgeAlive`: OR-Set join (union of dots, tombstones)
|
|
440
|
+
* - `prop`: LWW-Max per property key (higher EventId wins)
|
|
441
|
+
* - `observedFrontier`: Version vector merge (component-wise max)
|
|
442
|
+
* - `edgeBirthEvent`: EventId max per edge key
|
|
443
|
+
*
|
|
444
|
+
* This is a pure function that does not mutate its inputs.
|
|
445
|
+
* The result is deterministic regardless of the order of arguments (commutativity).
|
|
446
|
+
*
|
|
447
|
+
* @param {WarpStateV5} a - First state to merge
|
|
448
|
+
* @param {WarpStateV5} b - Second state to merge
|
|
449
|
+
* @returns {WarpStateV5} New state representing the join of a and b
|
|
450
|
+
*/
|
|
451
|
+
export function joinStates(a, b) {
|
|
452
|
+
return {
|
|
453
|
+
nodeAlive: orsetJoin(a.nodeAlive, b.nodeAlive),
|
|
454
|
+
edgeAlive: orsetJoin(a.edgeAlive, b.edgeAlive),
|
|
455
|
+
prop: mergeProps(a.prop, b.prop),
|
|
456
|
+
observedFrontier: vvMerge(a.observedFrontier, b.observedFrontier),
|
|
457
|
+
edgeBirthEvent: mergeEdgeBirthEvent(a.edgeBirthEvent, b.edgeBirthEvent),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Merges two property maps using LWW-Max semantics per key.
|
|
463
|
+
*
|
|
464
|
+
* For each property key present in either map, the resulting map contains
|
|
465
|
+
* the register with the greater EventId (using LWW comparison). This ensures
|
|
466
|
+
* deterministic merge regardless of the order in which states are joined.
|
|
467
|
+
*
|
|
468
|
+
* This is a pure function that does not mutate its inputs.
|
|
469
|
+
*
|
|
470
|
+
* @param {Map<string, import('../crdt/LWW.js').LWWRegister>} a - First property map
|
|
471
|
+
* @param {Map<string, import('../crdt/LWW.js').LWWRegister>} b - Second property map
|
|
472
|
+
* @returns {Map<string, import('../crdt/LWW.js').LWWRegister>} New map containing merged properties
|
|
473
|
+
*/
|
|
474
|
+
function mergeProps(a, b) {
|
|
475
|
+
const result = new Map(a);
|
|
476
|
+
|
|
477
|
+
for (const [key, regB] of b) {
|
|
478
|
+
const regA = result.get(key);
|
|
479
|
+
result.set(key, lwwMax(regA, regB));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return result;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Merges two edgeBirthEvent maps by taking the greater EventId per edge key.
|
|
487
|
+
*
|
|
488
|
+
* The edgeBirthEvent map tracks when each edge was most recently added (born),
|
|
489
|
+
* which is used by the query layer to filter out stale properties from previous
|
|
490
|
+
* edge incarnations. When an edge is removed and re-added, properties from
|
|
491
|
+
* before the re-add should not be visible.
|
|
492
|
+
*
|
|
493
|
+
* This function handles null/undefined inputs gracefully, treating them as empty maps.
|
|
494
|
+
* For each edge key present in either map, the resulting map contains the greater
|
|
495
|
+
* EventId (using EventId comparison).
|
|
496
|
+
*
|
|
497
|
+
* This is a pure function that does not mutate its inputs.
|
|
498
|
+
*
|
|
499
|
+
* @param {Map<string, import('../utils/EventId.js').EventId>|null|undefined} a - First edge birth event map
|
|
500
|
+
* @param {Map<string, import('../utils/EventId.js').EventId>|null|undefined} b - Second edge birth event map
|
|
501
|
+
* @returns {Map<string, import('../utils/EventId.js').EventId>} New map containing merged edge birth events
|
|
502
|
+
*/
|
|
503
|
+
function mergeEdgeBirthEvent(a, b) {
|
|
504
|
+
const result = new Map(a || []);
|
|
505
|
+
if (b) {
|
|
506
|
+
for (const [key, eventId] of b) {
|
|
507
|
+
const existing = result.get(key);
|
|
508
|
+
if (!existing || compareEventIds(eventId, existing) > 0) {
|
|
509
|
+
result.set(key, eventId);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return result;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Reduces an array of patches to a V5 state by applying them sequentially.
|
|
518
|
+
*
|
|
519
|
+
* This is the main materialization function that replays a sequence of patches
|
|
520
|
+
* to compute the current graph state. It supports both fresh materialization
|
|
521
|
+
* (starting from empty state) and incremental materialization (starting from
|
|
522
|
+
* a checkpoint state).
|
|
523
|
+
*
|
|
524
|
+
* **Performance Notes**:
|
|
525
|
+
* - When `options.receipts` is false (default), receipt computation is completely
|
|
526
|
+
* skipped, resulting in zero overhead for the common read path.
|
|
527
|
+
* - When `options.receipts` is true, returns a TickReceipt per patch for
|
|
528
|
+
* provenance tracking and debugging.
|
|
529
|
+
*
|
|
530
|
+
* @param {Array<{patch: Object, sha: string}>} patches - Array of patch objects with their Git SHAs
|
|
531
|
+
* @param {Object} patches[].patch - The decoded patch object (writer, lamport, ops, context)
|
|
532
|
+
* @param {string} patches[].sha - The Git SHA of the patch commit
|
|
533
|
+
* @param {WarpStateV5} [initialState] - Optional starting state (for incremental materialization from checkpoint)
|
|
534
|
+
* @param {Object} [options] - Optional configuration
|
|
535
|
+
* @param {boolean} [options.receipts=false] - When true, collect and return TickReceipts
|
|
536
|
+
* @returns {WarpStateV5|{state: WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}}
|
|
537
|
+
* Returns state directly when receipts is false;
|
|
538
|
+
* returns {state, receipts} when receipts is true
|
|
539
|
+
*/
|
|
540
|
+
export function reduceV5(patches, initialState, options) {
|
|
541
|
+
const state = initialState ? cloneStateV5(initialState) : createEmptyStateV5();
|
|
542
|
+
|
|
543
|
+
// ZERO-COST: only check options when provided and truthy
|
|
544
|
+
if (options && options.receipts) {
|
|
545
|
+
const receipts = [];
|
|
546
|
+
for (const { patch, sha } of patches) {
|
|
547
|
+
const result = join(state, patch, sha, true);
|
|
548
|
+
receipts.push(result.receipt);
|
|
549
|
+
}
|
|
550
|
+
return { state, receipts };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (const { patch, sha } of patches) {
|
|
554
|
+
join(state, patch, sha);
|
|
555
|
+
}
|
|
556
|
+
return state;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Creates a deep clone of a V5 state.
|
|
561
|
+
*
|
|
562
|
+
* All mutable components are cloned to ensure the returned state is fully
|
|
563
|
+
* independent of the input. This is useful for:
|
|
564
|
+
* - Preserving a checkpoint state before applying more patches
|
|
565
|
+
* - Creating a branch point for speculative execution
|
|
566
|
+
* - Ensuring immutability when passing state across API boundaries
|
|
567
|
+
*
|
|
568
|
+
* **Implementation Note**: OR-Sets are cloned by joining with an empty set,
|
|
569
|
+
* which creates new data structures with identical contents.
|
|
570
|
+
*
|
|
571
|
+
* @param {WarpStateV5} state - The state to clone
|
|
572
|
+
* @returns {WarpStateV5} A new state with identical contents but independent data structures
|
|
573
|
+
*/
|
|
574
|
+
export function cloneStateV5(state) {
|
|
575
|
+
return {
|
|
576
|
+
nodeAlive: orsetJoin(state.nodeAlive, createORSet()),
|
|
577
|
+
edgeAlive: orsetJoin(state.edgeAlive, createORSet()),
|
|
578
|
+
prop: new Map(state.prop),
|
|
579
|
+
observedFrontier: vvClone(state.observedFrontier),
|
|
580
|
+
edgeBirthEvent: new Map(state.edgeBirthEvent || []),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key encoding/decoding for WARP graph CRDT state maps.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for encoding composite keys (edges, properties,
|
|
5
|
+
* edge properties) used as Map keys in WarpStateV5. Uses null character
|
|
6
|
+
* (\0) as field separator and \x01 prefix for edge property keys.
|
|
7
|
+
*
|
|
8
|
+
* @module domain/services/KeyCodec
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Field separator used in all encoded keys. */
|
|
12
|
+
export const FIELD_SEPARATOR = '\0';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Prefix byte for edge property keys. Guarantees no collision with node
|
|
16
|
+
* property keys (which start with a node-ID character, never \x01).
|
|
17
|
+
* @const {string}
|
|
18
|
+
*/
|
|
19
|
+
export const EDGE_PROP_PREFIX = '\x01';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Encodes an edge key to a string for Map storage.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} from - Source node ID
|
|
25
|
+
* @param {string} to - Target node ID
|
|
26
|
+
* @param {string} label - Edge label/type
|
|
27
|
+
* @returns {string} Encoded edge key in format "from\0to\0label"
|
|
28
|
+
* @see decodeEdgeKey - The inverse operation
|
|
29
|
+
*/
|
|
30
|
+
export function encodeEdgeKey(from, to, label) {
|
|
31
|
+
return `${from}\0${to}\0${label}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decodes an edge key string back to its component parts.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} key - Encoded edge key in format "from\0to\0label"
|
|
38
|
+
* @returns {{from: string, to: string, label: string}}
|
|
39
|
+
* @see encodeEdgeKey - The inverse operation
|
|
40
|
+
*/
|
|
41
|
+
export function decodeEdgeKey(key) {
|
|
42
|
+
const [from, to, label] = key.split('\0');
|
|
43
|
+
return { from, to, label };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Encodes a node property key for Map storage.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} nodeId - The ID of the node owning the property
|
|
50
|
+
* @param {string} propKey - The property name/key
|
|
51
|
+
* @returns {string} Encoded property key in format "nodeId\0propKey"
|
|
52
|
+
* @see decodePropKey - The inverse operation
|
|
53
|
+
*/
|
|
54
|
+
export function encodePropKey(nodeId, propKey) {
|
|
55
|
+
return `${nodeId}\0${propKey}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decodes a node property key string back to its component parts.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} key - Encoded property key in format "nodeId\0propKey"
|
|
62
|
+
* @returns {{nodeId: string, propKey: string}}
|
|
63
|
+
* @see encodePropKey - The inverse operation
|
|
64
|
+
*/
|
|
65
|
+
export function decodePropKey(key) {
|
|
66
|
+
const [nodeId, propKey] = key.split('\0');
|
|
67
|
+
return { nodeId, propKey };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Encodes an edge property key for Map storage.
|
|
72
|
+
*
|
|
73
|
+
* Format: `\x01from\0to\0label\0propKey`
|
|
74
|
+
*
|
|
75
|
+
* The \x01 prefix guarantees collision-freedom with node property keys.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} from - Source node ID
|
|
78
|
+
* @param {string} to - Target node ID
|
|
79
|
+
* @param {string} label - Edge label
|
|
80
|
+
* @param {string} propKey - Property name
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function encodeEdgePropKey(from, to, label, propKey) {
|
|
84
|
+
return `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}\0${propKey}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns true if the encoded key is an edge property key.
|
|
89
|
+
* @param {string} key - Encoded property key
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
export function isEdgePropKey(key) {
|
|
93
|
+
return key[0] === EDGE_PROP_PREFIX;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decodes an edge property key string.
|
|
98
|
+
* @param {string} encoded - Encoded edge property key (must start with \x01)
|
|
99
|
+
* @returns {{from: string, to: string, label: string, propKey: string}}
|
|
100
|
+
* @throws {Error} If the encoded key is missing the edge property prefix
|
|
101
|
+
* @throws {Error} If the encoded key does not contain exactly 4 segments
|
|
102
|
+
*/
|
|
103
|
+
export function decodeEdgePropKey(encoded) {
|
|
104
|
+
if (!isEdgePropKey(encoded)) {
|
|
105
|
+
throw new Error('Invalid edge property key: missing prefix');
|
|
106
|
+
}
|
|
107
|
+
const parts = encoded.slice(1).split('\0');
|
|
108
|
+
if (parts.length !== 4) {
|
|
109
|
+
throw new Error('Invalid edge property key: expected 4 segments');
|
|
110
|
+
}
|
|
111
|
+
const [from, to, label, propKey] = parts;
|
|
112
|
+
return { from, to, label, propKey };
|
|
113
|
+
}
|