@git-stunts/git-warp 12.2.0 → 12.3.0
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/README.md +9 -6
- package/bin/cli/commands/trust.js +37 -1
- package/bin/cli/infrastructure.js +14 -1
- package/bin/cli/schemas.js +4 -4
- package/bin/presenters/text.js +10 -3
- package/bin/warp-graph.js +4 -1
- package/index.d.ts +17 -1
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +1 -1
- package/src/domain/crdt/Dot.js +5 -0
- package/src/domain/crdt/LWW.js +3 -1
- package/src/domain/crdt/ORSet.js +33 -23
- package/src/domain/crdt/VersionVector.js +12 -0
- package/src/domain/errors/PatchError.js +27 -0
- package/src/domain/errors/StorageError.js +8 -0
- package/src/domain/errors/WriterError.js +5 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditReceiptService.js +2 -1
- package/src/domain/services/AuditVerifierService.js +33 -2
- package/src/domain/services/BitmapIndexBuilder.js +14 -9
- package/src/domain/services/BoundaryTransitionRecord.js +1 -0
- package/src/domain/services/CheckpointMessageCodec.js +5 -0
- package/src/domain/services/CheckpointService.js +29 -2
- package/src/domain/services/GCPolicy.js +25 -4
- package/src/domain/services/GraphTraversal.js +3 -1
- package/src/domain/services/IncrementalIndexUpdater.js +179 -36
- package/src/domain/services/JoinReducer.js +311 -75
- package/src/domain/services/KeyCodec.js +48 -0
- package/src/domain/services/MaterializedViewService.js +14 -3
- package/src/domain/services/MessageSchemaDetector.js +35 -5
- package/src/domain/services/OpNormalizer.js +79 -0
- package/src/domain/services/PatchBuilderV2.js +240 -160
- package/src/domain/services/QueryBuilder.js +4 -0
- package/src/domain/services/SyncAuthService.js +3 -0
- package/src/domain/services/SyncController.js +12 -31
- package/src/domain/services/SyncProtocol.js +76 -32
- package/src/domain/services/WarpMessageCodec.js +2 -0
- package/src/domain/trust/TrustCrypto.js +8 -5
- package/src/domain/trust/TrustRecordService.js +50 -36
- package/src/domain/types/TickReceipt.js +6 -4
- package/src/domain/types/WarpTypesV2.js +77 -5
- package/src/domain/utils/CachedValue.js +34 -5
- package/src/domain/utils/EventId.js +4 -1
- package/src/domain/utils/LRUCache.js +3 -1
- package/src/domain/utils/RefLayout.js +4 -0
- package/src/domain/utils/canonicalStringify.js +48 -18
- package/src/domain/utils/defaultClock.js +1 -0
- package/src/domain/utils/matchGlob.js +7 -0
- package/src/domain/warp/PatchSession.js +30 -24
- package/src/domain/warp/Writer.js +12 -1
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/checkpoint.methods.js +36 -7
- package/src/domain/warp/fork.methods.js +1 -1
- package/src/domain/warp/materialize.methods.js +44 -5
- package/src/domain/warp/materializeAdvanced.methods.js +50 -10
- package/src/domain/warp/patch.methods.js +21 -11
- package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
- package/src/infrastructure/codecs/CborCodec.js +2 -0
- package/src/domain/utils/fnv1a.js +0 -20
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
* }
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { createORSet, orsetAdd, orsetRemove, orsetJoin, orsetContains } from '../crdt/ORSet.js';
|
|
12
|
+
import { createORSet, orsetAdd, orsetRemove, orsetJoin, orsetContains, orsetClone } from '../crdt/ORSet.js';
|
|
13
13
|
import { createVersionVector, vvMerge, vvClone, vvDeserialize } from '../crdt/VersionVector.js';
|
|
14
14
|
import { lwwSet, lwwMax } from '../crdt/LWW.js';
|
|
15
15
|
import { createEventId, compareEventIds } from '../utils/EventId.js';
|
|
16
16
|
import { createTickReceipt, OP_TYPES } from '../types/TickReceipt.js';
|
|
17
17
|
import { encodeDot } from '../crdt/Dot.js';
|
|
18
|
-
import { encodeEdgeKey, decodeEdgeKey, encodePropKey } from './KeyCodec.js';
|
|
18
|
+
import { encodeEdgeKey, decodeEdgeKey, encodePropKey, encodeEdgePropKey, EDGE_PROP_PREFIX } from './KeyCodec.js';
|
|
19
|
+
import { normalizeRawOp } from './OpNormalizer.js';
|
|
19
20
|
import { createEmptyDiff, mergeDiffs } from '../types/PatchDiff.js';
|
|
21
|
+
import PatchError from '../errors/PatchError.js';
|
|
20
22
|
|
|
21
23
|
// Re-export key codec functions for backward compatibility
|
|
22
24
|
export {
|
|
@@ -26,13 +28,20 @@ export {
|
|
|
26
28
|
encodeEdgePropKey, isEdgePropKey, decodeEdgePropKey,
|
|
27
29
|
} from './KeyCodec.js';
|
|
28
30
|
|
|
31
|
+
// Re-export op normalization for consumers that operate on raw patches
|
|
32
|
+
export { normalizeRawOp, lowerCanonicalOp } from './OpNormalizer.js';
|
|
33
|
+
|
|
29
34
|
/**
|
|
30
35
|
* @typedef {Object} WarpStateV5
|
|
31
36
|
* @property {import('../crdt/ORSet.js').ORSet} nodeAlive - ORSet of alive nodes
|
|
32
37
|
* @property {import('../crdt/ORSet.js').ORSet} edgeAlive - ORSet of alive edges
|
|
33
38
|
* @property {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} prop - Properties with LWW
|
|
34
39
|
* @property {import('../crdt/VersionVector.js').VersionVector} observedFrontier - Observed version vector
|
|
35
|
-
* @property {Map<string, import('../utils/EventId.js').EventId>} edgeBirthEvent - EdgeKey → EventId of most recent EdgeAdd (for clean-slate prop visibility)
|
|
40
|
+
* @property {Map<string, import('../utils/EventId.js').EventId>} edgeBirthEvent - EdgeKey → EventId of most recent EdgeAdd (for clean-slate prop visibility).
|
|
41
|
+
* Always present at runtime (initialized to empty Map by createEmptyStateV5 and
|
|
42
|
+
* deserializeFullStateV5). Edge birth events were introduced in a later schema
|
|
43
|
+
* version; older checkpoints serialize without this field, but the deserializer
|
|
44
|
+
* always produces an empty Map for them.
|
|
36
45
|
*/
|
|
37
46
|
|
|
38
47
|
/**
|
|
@@ -87,19 +96,173 @@ export function createEmptyStateV5() {
|
|
|
87
96
|
* @returns {void}
|
|
88
97
|
*/
|
|
89
98
|
/**
|
|
90
|
-
* Known V2 operation types.
|
|
99
|
+
* Known raw (wire-format) V2 operation types. These are the 6 types that
|
|
100
|
+
* appear in persisted patches and on the sync wire.
|
|
91
101
|
* @type {ReadonlySet<string>}
|
|
92
102
|
*/
|
|
93
|
-
const
|
|
103
|
+
export const RAW_KNOWN_OPS = new Set([
|
|
104
|
+
'NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove',
|
|
105
|
+
'PropSet', 'BlobValue',
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Known canonical (internal) V2 operation types. Includes the 6 raw types
|
|
110
|
+
* plus the ADR 1 canonical split types `NodePropSet` and `EdgePropSet`.
|
|
111
|
+
* @type {ReadonlySet<string>}
|
|
112
|
+
*/
|
|
113
|
+
export const CANONICAL_KNOWN_OPS = new Set([
|
|
114
|
+
'NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove',
|
|
115
|
+
'PropSet', 'NodePropSet', 'EdgePropSet', 'BlobValue',
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validates that an operation has a known raw (wire-format) type.
|
|
120
|
+
* Use this at sync/wire boundaries to fail-close on unknown or
|
|
121
|
+
* canonical-only types arriving over the wire.
|
|
122
|
+
*
|
|
123
|
+
* @param {{ type: string }} op
|
|
124
|
+
* @returns {boolean} True if the op type is in RAW_KNOWN_OPS
|
|
125
|
+
*/
|
|
126
|
+
export function isKnownRawOp(op) {
|
|
127
|
+
return Boolean(op && typeof op.type === 'string' && RAW_KNOWN_OPS.has(op.type));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Validates that an operation has a known canonical (internal) type.
|
|
132
|
+
* Use this for internal guards after normalization.
|
|
133
|
+
*
|
|
134
|
+
* @param {{ type: string }} op
|
|
135
|
+
* @returns {boolean} True if the op type is in CANONICAL_KNOWN_OPS
|
|
136
|
+
*/
|
|
137
|
+
export function isKnownCanonicalOp(op) {
|
|
138
|
+
return Boolean(op && typeof op.type === 'string' && CANONICAL_KNOWN_OPS.has(op.type));
|
|
139
|
+
}
|
|
94
140
|
|
|
95
141
|
/**
|
|
96
142
|
* Validates that an operation has a known type.
|
|
97
143
|
*
|
|
144
|
+
* @deprecated Use {@link isKnownRawOp} for wire validation or
|
|
145
|
+
* {@link isKnownCanonicalOp} for internal guards.
|
|
98
146
|
* @param {{ type: string }} op
|
|
99
|
-
* @returns {boolean} True if the op type is
|
|
147
|
+
* @returns {boolean} True if the op type is a known raw wire type
|
|
100
148
|
*/
|
|
101
149
|
export function isKnownOp(op) {
|
|
102
|
-
return
|
|
150
|
+
return isKnownRawOp(op);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Asserts that `op[field]` is a string. Throws PatchError if not.
|
|
155
|
+
* @param {Record<string, unknown>} op
|
|
156
|
+
* @param {string} field
|
|
157
|
+
*/
|
|
158
|
+
function requireString(op, field) {
|
|
159
|
+
if (typeof op[field] !== 'string') {
|
|
160
|
+
throw new PatchError(
|
|
161
|
+
`${op.type} op requires '${field}' to be a string, got ${typeof op[field]}`,
|
|
162
|
+
{ context: { opType: op.type, field, actual: typeof op[field] } },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Asserts that `op[field]` is iterable (Array, Set, or any Symbol.iterator).
|
|
169
|
+
* @param {Record<string, unknown>} op
|
|
170
|
+
* @param {string} field
|
|
171
|
+
*/
|
|
172
|
+
function requireIterable(op, field) {
|
|
173
|
+
const val = op[field];
|
|
174
|
+
if (
|
|
175
|
+
val === null ||
|
|
176
|
+
val === undefined ||
|
|
177
|
+
typeof val !== 'object' ||
|
|
178
|
+
typeof /** @type {Iterable<unknown>} */ (val)[Symbol.iterator] !== 'function'
|
|
179
|
+
) {
|
|
180
|
+
throw new PatchError(
|
|
181
|
+
`${op.type} op requires '${field}' to be iterable, got ${typeof val}`,
|
|
182
|
+
{ context: { opType: op.type, field, actual: typeof val } },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Asserts that `op.dot` is an object with writerId (string) and counter (number).
|
|
189
|
+
* @param {Record<string, unknown>} op
|
|
190
|
+
*/
|
|
191
|
+
function requireDot(op) {
|
|
192
|
+
const { dot } = op;
|
|
193
|
+
if (!dot || typeof dot !== 'object') {
|
|
194
|
+
throw new PatchError(
|
|
195
|
+
`${op.type} op requires 'dot' to be an object, got ${typeof dot}`,
|
|
196
|
+
{ context: { opType: op.type, field: 'dot', actual: typeof dot } },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const d = /** @type {Record<string, unknown>} */ (dot);
|
|
200
|
+
if (typeof d.writerId !== 'string') {
|
|
201
|
+
throw new PatchError(
|
|
202
|
+
`${op.type} op requires 'dot.writerId' to be a string, got ${typeof d.writerId}`,
|
|
203
|
+
{ context: { opType: op.type, field: 'dot.writerId', actual: typeof d.writerId } },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (typeof d.counter !== 'number') {
|
|
207
|
+
throw new PatchError(
|
|
208
|
+
`${op.type} op requires 'dot.counter' to be a number, got ${typeof d.counter}`,
|
|
209
|
+
{ context: { opType: op.type, field: 'dot.counter', actual: typeof d.counter } },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Validates that an operation has the required fields for its type.
|
|
216
|
+
* Throws PatchError for malformed ops. Unknown/BlobValue types pass through
|
|
217
|
+
* for forward compatibility.
|
|
218
|
+
*
|
|
219
|
+
* @param {Record<string, unknown>} op
|
|
220
|
+
*/
|
|
221
|
+
function validateOp(op) {
|
|
222
|
+
if (!op || typeof op.type !== 'string') {
|
|
223
|
+
throw new PatchError(
|
|
224
|
+
`Invalid op: expected object with string 'type', got ${op === null || op === undefined ? String(op) : typeof op.type}`,
|
|
225
|
+
{ context: { actual: op === null || op === undefined ? String(op) : typeof op.type } },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
switch (op.type) {
|
|
230
|
+
case 'NodeAdd':
|
|
231
|
+
requireString(op, 'node');
|
|
232
|
+
requireDot(op);
|
|
233
|
+
break;
|
|
234
|
+
case 'NodeRemove':
|
|
235
|
+
// node is optional (informational for receipts); observedDots is required for mutation
|
|
236
|
+
requireIterable(op, 'observedDots');
|
|
237
|
+
break;
|
|
238
|
+
case 'EdgeAdd':
|
|
239
|
+
requireString(op, 'from');
|
|
240
|
+
requireString(op, 'to');
|
|
241
|
+
requireString(op, 'label');
|
|
242
|
+
requireDot(op);
|
|
243
|
+
break;
|
|
244
|
+
case 'EdgeRemove':
|
|
245
|
+
// from/to/label are optional (informational for receipts); observedDots is required for mutation
|
|
246
|
+
requireIterable(op, 'observedDots');
|
|
247
|
+
break;
|
|
248
|
+
case 'PropSet':
|
|
249
|
+
requireString(op, 'node');
|
|
250
|
+
requireString(op, 'key');
|
|
251
|
+
break;
|
|
252
|
+
case 'NodePropSet':
|
|
253
|
+
requireString(op, 'node');
|
|
254
|
+
requireString(op, 'key');
|
|
255
|
+
break;
|
|
256
|
+
case 'EdgePropSet':
|
|
257
|
+
requireString(op, 'from');
|
|
258
|
+
requireString(op, 'to');
|
|
259
|
+
requireString(op, 'label');
|
|
260
|
+
requireString(op, 'key');
|
|
261
|
+
break;
|
|
262
|
+
default:
|
|
263
|
+
// BlobValue and unknown types: no validation (forward-compat)
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
103
266
|
}
|
|
104
267
|
|
|
105
268
|
/**
|
|
@@ -110,6 +273,7 @@ export function isKnownOp(op) {
|
|
|
110
273
|
* @param {import('../utils/EventId.js').EventId} eventId - The event ID for LWW ordering
|
|
111
274
|
*/
|
|
112
275
|
export function applyOpV2(state, op, eventId) {
|
|
276
|
+
validateOp(/** @type {Record<string, unknown>} */ (op));
|
|
113
277
|
switch (op.type) {
|
|
114
278
|
case 'NodeAdd':
|
|
115
279
|
orsetAdd(state.nodeAlive, /** @type {string} */ (op.node), /** @type {import('../crdt/Dot.js').Dot} */ (op.dot));
|
|
@@ -134,8 +298,34 @@ export function applyOpV2(state, op, eventId) {
|
|
|
134
298
|
case 'EdgeRemove':
|
|
135
299
|
orsetRemove(state.edgeAlive, /** @type {Set<string>} */ (/** @type {unknown} */ (op.observedDots)));
|
|
136
300
|
break;
|
|
301
|
+
case 'NodePropSet': {
|
|
302
|
+
const key = encodePropKey(/** @type {string} */ (op.node), /** @type {string} */ (op.key));
|
|
303
|
+
const current = state.prop.get(key);
|
|
304
|
+
state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case 'EdgePropSet': {
|
|
308
|
+
const key = encodeEdgePropKey(
|
|
309
|
+
/** @type {string} */ (op.from),
|
|
310
|
+
/** @type {string} */ (op.to),
|
|
311
|
+
/** @type {string} */ (op.label),
|
|
312
|
+
/** @type {string} */ (op.key),
|
|
313
|
+
);
|
|
314
|
+
const current = state.prop.get(key);
|
|
315
|
+
state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
137
318
|
case 'PropSet': {
|
|
138
|
-
//
|
|
319
|
+
// Legacy raw PropSet — must NOT carry edge-property encoding at this point.
|
|
320
|
+
// If it does, normalization was skipped.
|
|
321
|
+
if (typeof op.node === 'string' && op.node[0] === EDGE_PROP_PREFIX) {
|
|
322
|
+
throw new PatchError(
|
|
323
|
+
'Unnormalized legacy edge-property PropSet reached canonical apply path. ' +
|
|
324
|
+
'Call normalizeRawOp() at the decode boundary.',
|
|
325
|
+
{ context: { opType: 'PropSet', node: op.node } },
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
// Plain node property (backward-compat for callers that bypass normalization)
|
|
139
329
|
const key = encodePropKey(/** @type {string} */ (op.node), /** @type {string} */ (op.key));
|
|
140
330
|
const current = state.prop.get(key);
|
|
141
331
|
state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
|
|
@@ -167,6 +357,8 @@ const RECEIPT_OP_TYPE = {
|
|
|
167
357
|
EdgeAdd: 'EdgeAdd',
|
|
168
358
|
EdgeRemove: 'EdgeTombstone',
|
|
169
359
|
PropSet: 'PropSet',
|
|
360
|
+
NodePropSet: 'NodePropSet',
|
|
361
|
+
EdgePropSet: 'EdgePropSet',
|
|
170
362
|
BlobValue: 'BlobValue',
|
|
171
363
|
};
|
|
172
364
|
|
|
@@ -213,21 +405,18 @@ function nodeAddOutcome(orset, op) {
|
|
|
213
405
|
* @returns {{target: string, result: 'applied'|'redundant'}} Outcome with node ID (or '*') as target
|
|
214
406
|
*/
|
|
215
407
|
function nodeRemoveOutcome(orset, op) {
|
|
216
|
-
//
|
|
408
|
+
// Build a reverse index (dot → elementId) for the observed dots to avoid
|
|
409
|
+
// O(|observedDots| × |entries|) scanning. Same pattern as buildDotToElement.
|
|
410
|
+
const targetDots = op.observedDots instanceof Set
|
|
411
|
+
? op.observedDots
|
|
412
|
+
: new Set(op.observedDots);
|
|
413
|
+
const dotToElement = buildDotToElement(orset, targetDots);
|
|
414
|
+
|
|
217
415
|
let effective = false;
|
|
218
|
-
for (const encodedDot of
|
|
219
|
-
if (!orset.tombstones.has(encodedDot)) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
for (const dots of orset.entries.values()) {
|
|
223
|
-
if (dots.has(encodedDot)) {
|
|
224
|
-
effective = true;
|
|
225
|
-
break;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
if (effective) {
|
|
229
|
-
break;
|
|
230
|
-
}
|
|
416
|
+
for (const encodedDot of targetDots) {
|
|
417
|
+
if (!orset.tombstones.has(encodedDot) && dotToElement.has(encodedDot)) {
|
|
418
|
+
effective = true;
|
|
419
|
+
break;
|
|
231
420
|
}
|
|
232
421
|
}
|
|
233
422
|
const target = op.node || '*';
|
|
@@ -279,18 +468,18 @@ function edgeAddOutcome(orset, op, edgeKey) {
|
|
|
279
468
|
* @returns {{target: string, result: 'applied'|'redundant'}} Outcome with encoded edge key (or '*') as target
|
|
280
469
|
*/
|
|
281
470
|
function edgeRemoveOutcome(orset, op) {
|
|
471
|
+
// Build a reverse index (dot → elementId) for the observed dots to avoid
|
|
472
|
+
// O(|observedDots| × |entries|) scanning. Same pattern as buildDotToElement.
|
|
473
|
+
const targetDots = op.observedDots instanceof Set
|
|
474
|
+
? op.observedDots
|
|
475
|
+
: new Set(op.observedDots);
|
|
476
|
+
const dotToElement = buildDotToElement(orset, targetDots);
|
|
477
|
+
|
|
282
478
|
let effective = false;
|
|
283
|
-
for (const encodedDot of
|
|
284
|
-
if (!orset.tombstones.has(encodedDot)) {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
effective = true;
|
|
288
|
-
break;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
if (effective) {
|
|
292
|
-
break;
|
|
293
|
-
}
|
|
479
|
+
for (const encodedDot of targetDots) {
|
|
480
|
+
if (!orset.tombstones.has(encodedDot) && dotToElement.has(encodedDot)) {
|
|
481
|
+
effective = true;
|
|
482
|
+
break;
|
|
294
483
|
}
|
|
295
484
|
}
|
|
296
485
|
// Construct target from op fields if available
|
|
@@ -301,7 +490,7 @@ function edgeRemoveOutcome(orset, op) {
|
|
|
301
490
|
}
|
|
302
491
|
|
|
303
492
|
/**
|
|
304
|
-
* Determines the receipt outcome for a
|
|
493
|
+
* Determines the receipt outcome for a property operation given a pre-computed key.
|
|
305
494
|
*
|
|
306
495
|
* Uses LWW (Last-Write-Wins) semantics to determine whether the incoming property
|
|
307
496
|
* value wins over any existing value. The comparison is based on EventId ordering:
|
|
@@ -315,41 +504,61 @@ function edgeRemoveOutcome(orset, op) {
|
|
|
315
504
|
* - `redundant`: Exact same write (identical EventId)
|
|
316
505
|
*
|
|
317
506
|
* @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap - The properties map keyed by encoded prop keys
|
|
318
|
-
* @param {
|
|
319
|
-
* @param {string} op.node - Node ID owning the property
|
|
320
|
-
* @param {string} op.key - Property key/name
|
|
321
|
-
* @param {unknown} op.value - Property value to set
|
|
507
|
+
* @param {string} key - Pre-encoded property key (node or edge)
|
|
322
508
|
* @param {import('../utils/EventId.js').EventId} eventId - The event ID for this operation, used for LWW comparison
|
|
323
509
|
* @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
|
|
324
510
|
* Outcome with encoded prop key as target; includes reason when superseded
|
|
325
511
|
*/
|
|
326
|
-
function
|
|
327
|
-
const key = encodePropKey(op.node, op.key);
|
|
512
|
+
function propOutcomeForKey(propMap, key, eventId) {
|
|
328
513
|
const current = propMap.get(key);
|
|
329
|
-
const target = key;
|
|
330
514
|
|
|
331
515
|
if (!current) {
|
|
332
|
-
|
|
333
|
-
return { target, result: 'applied' };
|
|
516
|
+
return { target: key, result: 'applied' };
|
|
334
517
|
}
|
|
335
518
|
|
|
336
|
-
// Compare the incoming EventId with the existing register's EventId
|
|
337
519
|
const cmp = compareEventIds(eventId, current.eventId);
|
|
338
520
|
if (cmp > 0) {
|
|
339
|
-
|
|
340
|
-
return { target, result: 'applied' };
|
|
521
|
+
return { target: key, result: 'applied' };
|
|
341
522
|
}
|
|
342
523
|
if (cmp < 0) {
|
|
343
|
-
// Existing write wins
|
|
344
524
|
const winner = current.eventId;
|
|
345
525
|
return {
|
|
346
|
-
target,
|
|
526
|
+
target: key,
|
|
347
527
|
result: 'superseded',
|
|
348
528
|
reason: `LWW: writer ${winner.writerId} at lamport ${winner.lamport} wins`,
|
|
349
529
|
};
|
|
350
530
|
}
|
|
351
|
-
|
|
352
|
-
|
|
531
|
+
return { target: key, result: 'redundant' };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Determines the receipt outcome for a PropSet/NodePropSet operation.
|
|
536
|
+
*
|
|
537
|
+
* @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap
|
|
538
|
+
* @param {Object} op
|
|
539
|
+
* @param {string} op.node - Node ID owning the property
|
|
540
|
+
* @param {string} op.key - Property key/name
|
|
541
|
+
* @param {import('../utils/EventId.js').EventId} eventId
|
|
542
|
+
* @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
|
|
543
|
+
*/
|
|
544
|
+
function propSetOutcome(propMap, op, eventId) {
|
|
545
|
+
return propOutcomeForKey(propMap, encodePropKey(op.node, op.key), eventId);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Determines the receipt outcome for an EdgePropSet operation.
|
|
550
|
+
*
|
|
551
|
+
* @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap
|
|
552
|
+
* @param {Object} op
|
|
553
|
+
* @param {string} op.from
|
|
554
|
+
* @param {string} op.to
|
|
555
|
+
* @param {string} op.label
|
|
556
|
+
* @param {string} op.key
|
|
557
|
+
* @param {import('../utils/EventId.js').EventId} eventId
|
|
558
|
+
* @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
|
|
559
|
+
*/
|
|
560
|
+
function edgePropSetOutcome(propMap, op, eventId) {
|
|
561
|
+
return propOutcomeForKey(propMap, encodeEdgePropKey(op.from, op.to, op.label, op.key), eventId);
|
|
353
562
|
}
|
|
354
563
|
|
|
355
564
|
/**
|
|
@@ -396,7 +605,7 @@ function updateFrontierFromPatch(state, patch) {
|
|
|
396
605
|
export function applyFast(state, patch, patchSha) {
|
|
397
606
|
for (let i = 0; i < patch.ops.length; i++) {
|
|
398
607
|
const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
|
|
399
|
-
applyOpV2(state, patch.ops[i], eventId);
|
|
608
|
+
applyOpV2(state, normalizeRawOp(patch.ops[i]), eventId);
|
|
400
609
|
}
|
|
401
610
|
updateFrontierFromPatch(state, patch);
|
|
402
611
|
return state;
|
|
@@ -491,11 +700,17 @@ function snapshotBeforeOp(state, op) {
|
|
|
491
700
|
const aliveBeforeEdges = aliveElementsForDots(state.edgeAlive, edgeDots);
|
|
492
701
|
return { aliveBeforeEdges };
|
|
493
702
|
}
|
|
494
|
-
case 'PropSet':
|
|
703
|
+
case 'PropSet':
|
|
704
|
+
case 'NodePropSet': {
|
|
495
705
|
const pk = encodePropKey(op.node, op.key);
|
|
496
706
|
const reg = state.prop.get(pk);
|
|
497
707
|
return { prevPropValue: reg ? reg.value : undefined, propKey: pk };
|
|
498
708
|
}
|
|
709
|
+
case 'EdgePropSet': {
|
|
710
|
+
const epk = encodeEdgePropKey(op.from, op.to, op.label, op.key);
|
|
711
|
+
const ereg = state.prop.get(epk);
|
|
712
|
+
return { prevPropValue: ereg ? ereg.value : undefined, propKey: epk };
|
|
713
|
+
}
|
|
499
714
|
default:
|
|
500
715
|
return {};
|
|
501
716
|
}
|
|
@@ -532,7 +747,8 @@ function accumulateOpDiff(diff, state, op, before) {
|
|
|
532
747
|
collectEdgeRemovals(diff, state, before);
|
|
533
748
|
break;
|
|
534
749
|
}
|
|
535
|
-
case 'PropSet':
|
|
750
|
+
case 'PropSet':
|
|
751
|
+
case 'NodePropSet': {
|
|
536
752
|
const reg = state.prop.get(/** @type {string} */ (before.propKey));
|
|
537
753
|
const newVal = reg ? reg.value : undefined;
|
|
538
754
|
if (newVal !== before.prevPropValue) {
|
|
@@ -545,6 +761,19 @@ function accumulateOpDiff(diff, state, op, before) {
|
|
|
545
761
|
}
|
|
546
762
|
break;
|
|
547
763
|
}
|
|
764
|
+
case 'EdgePropSet': {
|
|
765
|
+
const ereg = state.prop.get(/** @type {string} */ (before.propKey));
|
|
766
|
+
const eNewVal = ereg ? ereg.value : undefined;
|
|
767
|
+
if (eNewVal !== before.prevPropValue) {
|
|
768
|
+
diff.propsChanged.push({
|
|
769
|
+
nodeId: encodeEdgeKey(op.from, op.to, op.label),
|
|
770
|
+
key: op.key,
|
|
771
|
+
value: eNewVal,
|
|
772
|
+
prevValue: before.prevPropValue,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
548
777
|
default:
|
|
549
778
|
break;
|
|
550
779
|
}
|
|
@@ -593,7 +822,7 @@ function collectEdgeRemovals(diff, state, before) {
|
|
|
593
822
|
* @param {Object} patch - The patch to apply
|
|
594
823
|
* @param {string} patch.writer
|
|
595
824
|
* @param {number} patch.lamport
|
|
596
|
-
* @param {Array<
|
|
825
|
+
* @param {Array<import('../types/WarpTypesV2.js').OpV2 | {type: string}>} patch.ops
|
|
597
826
|
* @param {Map<string, number>|{[x: string]: number}} patch.context
|
|
598
827
|
* @param {string} patchSha - Git SHA of the patch commit
|
|
599
828
|
* @returns {{state: WarpStateV5, diff: import('../types/PatchDiff.js').PatchDiff}}
|
|
@@ -602,12 +831,12 @@ export function applyWithDiff(state, patch, patchSha) {
|
|
|
602
831
|
const diff = createEmptyDiff();
|
|
603
832
|
|
|
604
833
|
for (let i = 0; i < patch.ops.length; i++) {
|
|
605
|
-
const
|
|
834
|
+
const canonOp = /** @type {import('../types/WarpTypesV2.js').CanonicalOpV2} */ (normalizeRawOp(patch.ops[i]));
|
|
835
|
+
validateOp(/** @type {Record<string, unknown>} */ (canonOp));
|
|
606
836
|
const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
accumulateOpDiff(diff, state, typedOp, before);
|
|
837
|
+
const before = snapshotBeforeOp(state, canonOp);
|
|
838
|
+
applyOpV2(state, canonOp, eventId);
|
|
839
|
+
accumulateOpDiff(diff, state, canonOp, before);
|
|
611
840
|
}
|
|
612
841
|
|
|
613
842
|
updateFrontierFromPatch(state, patch);
|
|
@@ -630,40 +859,47 @@ export function applyWithReceipt(state, patch, patchSha) {
|
|
|
630
859
|
/** @type {import('../types/TickReceipt.js').OpOutcome[]} */
|
|
631
860
|
const opResults = [];
|
|
632
861
|
for (let i = 0; i < patch.ops.length; i++) {
|
|
633
|
-
const
|
|
862
|
+
const canonOp = /** @type {import('../types/WarpTypesV2.js').OpV2} */ (normalizeRawOp(patch.ops[i]));
|
|
863
|
+
validateOp(/** @type {Record<string, unknown>} */ (canonOp));
|
|
634
864
|
const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
|
|
635
865
|
|
|
636
866
|
// Determine outcome BEFORE applying the op (state is pre-op)
|
|
637
867
|
/** @type {{target: string, result: string, reason?: string}} */
|
|
638
868
|
let outcome;
|
|
639
|
-
switch (
|
|
869
|
+
switch (canonOp.type) {
|
|
640
870
|
case 'NodeAdd':
|
|
641
|
-
outcome = nodeAddOutcome(state.nodeAlive, /** @type {{node: string, dot: import('../crdt/Dot.js').Dot}} */ (
|
|
871
|
+
outcome = nodeAddOutcome(state.nodeAlive, /** @type {{node: string, dot: import('../crdt/Dot.js').Dot}} */ (canonOp));
|
|
642
872
|
break;
|
|
643
873
|
case 'NodeRemove':
|
|
644
|
-
outcome = nodeRemoveOutcome(state.nodeAlive, /** @type {{node?: string, observedDots: string[]}} */ (
|
|
874
|
+
outcome = nodeRemoveOutcome(state.nodeAlive, /** @type {{node?: string, observedDots: string[]}} */ (canonOp));
|
|
645
875
|
break;
|
|
646
876
|
case 'EdgeAdd': {
|
|
647
|
-
const edgeKey = encodeEdgeKey(
|
|
648
|
-
outcome = edgeAddOutcome(state.edgeAlive, /** @type {{from: string, to: string, label: string, dot: import('../crdt/Dot.js').Dot}} */ (
|
|
877
|
+
const edgeKey = encodeEdgeKey(canonOp.from, canonOp.to, canonOp.label);
|
|
878
|
+
outcome = edgeAddOutcome(state.edgeAlive, /** @type {{from: string, to: string, label: string, dot: import('../crdt/Dot.js').Dot}} */ (canonOp), edgeKey);
|
|
649
879
|
break;
|
|
650
880
|
}
|
|
651
881
|
case 'EdgeRemove':
|
|
652
|
-
outcome = edgeRemoveOutcome(state.edgeAlive, /** @type {{from?: string, to?: string, label?: string, observedDots: string[]}} */ (
|
|
882
|
+
outcome = edgeRemoveOutcome(state.edgeAlive, /** @type {{from?: string, to?: string, label?: string, observedDots: string[]}} */ (canonOp));
|
|
653
883
|
break;
|
|
654
884
|
case 'PropSet':
|
|
655
|
-
|
|
885
|
+
case 'NodePropSet':
|
|
886
|
+
outcome = propSetOutcome(state.prop, /** @type {{node: string, key: string, value: *}} */ (canonOp), eventId);
|
|
887
|
+
break;
|
|
888
|
+
case 'EdgePropSet':
|
|
889
|
+
outcome = edgePropSetOutcome(state.prop, /** @type {{from: string, to: string, label: string, key: string, value: *}} */ (canonOp), eventId);
|
|
656
890
|
break;
|
|
657
|
-
default:
|
|
891
|
+
default: {
|
|
658
892
|
// Unknown or BlobValue — always applied
|
|
659
|
-
|
|
893
|
+
const anyOp = /** @type {Record<string, string>} */ (canonOp);
|
|
894
|
+
outcome = { target: anyOp.node || anyOp.oid || '*', result: 'applied' };
|
|
660
895
|
break;
|
|
896
|
+
}
|
|
661
897
|
}
|
|
662
898
|
|
|
663
899
|
// Apply the op (mutates state)
|
|
664
|
-
applyOpV2(state,
|
|
900
|
+
applyOpV2(state, canonOp, eventId);
|
|
665
901
|
|
|
666
|
-
const receiptOp = /** @type {Record<string, string>} */ (RECEIPT_OP_TYPE)[
|
|
902
|
+
const receiptOp = /** @type {Record<string, string>} */ (RECEIPT_OP_TYPE)[canonOp.type] || canonOp.type;
|
|
667
903
|
// Skip unknown/forward-compatible op types that aren't valid receipt ops
|
|
668
904
|
if (!VALID_RECEIPT_OPS.has(receiptOp)) {
|
|
669
905
|
continue;
|
|
@@ -868,16 +1104,16 @@ export function reduceV5(patches, initialState, options) {
|
|
|
868
1104
|
* - Creating a branch point for speculative execution
|
|
869
1105
|
* - Ensuring immutability when passing state across API boundaries
|
|
870
1106
|
*
|
|
871
|
-
* **Implementation Note**: OR-Sets are cloned
|
|
872
|
-
*
|
|
1107
|
+
* **Implementation Note**: OR-Sets are cloned via `orsetClone()` which
|
|
1108
|
+
* directly copies entries and tombstones without merge logic overhead.
|
|
873
1109
|
*
|
|
874
1110
|
* @param {WarpStateV5} state - The state to clone
|
|
875
1111
|
* @returns {WarpStateV5} A new state with identical contents but independent data structures
|
|
876
1112
|
*/
|
|
877
1113
|
export function cloneStateV5(state) {
|
|
878
1114
|
return {
|
|
879
|
-
nodeAlive:
|
|
880
|
-
edgeAlive:
|
|
1115
|
+
nodeAlive: orsetClone(state.nodeAlive),
|
|
1116
|
+
edgeAlive: orsetClone(state.edgeAlive),
|
|
881
1117
|
prop: new Map(state.prop),
|
|
882
1118
|
observedFrontier: vvClone(state.observedFrontier),
|
|
883
1119
|
edgeBirthEvent: new Map(state.edgeBirthEvent || []),
|
|
@@ -91,6 +91,54 @@ export function encodeEdgePropKey(from, to, label, propKey) {
|
|
|
91
91
|
return `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}\0${propKey}`;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// -------------------------------------------------------------------------
|
|
95
|
+
// Legacy edge-property node encoding (raw PropSet ↔ canonical EdgePropSet)
|
|
96
|
+
// -------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Encodes edge identity as the legacy `node` field value for raw PropSet ops.
|
|
100
|
+
*
|
|
101
|
+
* Format: `\x01from\0to\0label`
|
|
102
|
+
*
|
|
103
|
+
* @param {string} from - Source node ID
|
|
104
|
+
* @param {string} to - Target node ID
|
|
105
|
+
* @param {string} label - Edge label
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function encodeLegacyEdgePropNode(from, to, label) {
|
|
109
|
+
return `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Returns true if a raw PropSet `node` field encodes an edge identity.
|
|
114
|
+
* @param {string} node - The `node` field from a raw PropSet op
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
export function isLegacyEdgePropNode(node) {
|
|
118
|
+
return typeof node === 'string' && node.length > 0 && node[0] === EDGE_PROP_PREFIX;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Decodes a legacy edge-property `node` field back to its components.
|
|
123
|
+
* @param {string} node - The `node` field (must start with \x01)
|
|
124
|
+
* @returns {{from: string, to: string, label: string}}
|
|
125
|
+
* @throws {Error} If the node field is not a valid legacy edge-property encoding
|
|
126
|
+
*/
|
|
127
|
+
export function decodeLegacyEdgePropNode(node) {
|
|
128
|
+
if (!isLegacyEdgePropNode(node)) {
|
|
129
|
+
throw new Error('Invalid legacy edge-property node: missing \\x01 prefix');
|
|
130
|
+
}
|
|
131
|
+
const parts = node.slice(1).split('\0');
|
|
132
|
+
if (parts.length !== 3) {
|
|
133
|
+
throw new Error(`Invalid legacy edge-property node: expected 3 segments, got ${parts.length}`);
|
|
134
|
+
}
|
|
135
|
+
const [from, to, label] = parts;
|
|
136
|
+
if (!from || !to || !label) {
|
|
137
|
+
throw new Error('Invalid legacy edge-property node: empty segment in decoded parts');
|
|
138
|
+
}
|
|
139
|
+
return { from, to, label };
|
|
140
|
+
}
|
|
141
|
+
|
|
94
142
|
/**
|
|
95
143
|
* Returns true if the encoded key is an edge property key.
|
|
96
144
|
* @param {string} key - Encoded property key
|