@git-stunts/git-warp 12.2.1 → 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 +6 -5
- package/bin/presenters/text.js +10 -3
- package/package.json +1 -1
- package/src/domain/crdt/ORSet.js +3 -0
- package/src/domain/services/AuditReceiptService.js +2 -1
- package/src/domain/services/AuditVerifierService.js +1 -0
- package/src/domain/services/BoundaryTransitionRecord.js +1 -0
- package/src/domain/services/CheckpointMessageCodec.js +5 -0
- package/src/domain/services/CheckpointService.js +19 -1
- package/src/domain/services/JoinReducer.js +172 -46
- package/src/domain/services/KeyCodec.js +48 -0
- package/src/domain/services/MaterializedViewService.js +1 -1
- package/src/domain/services/MessageSchemaDetector.js +35 -5
- package/src/domain/services/OpNormalizer.js +79 -0
- package/src/domain/services/PatchBuilderV2.js +62 -21
- package/src/domain/services/SyncAuthService.js +3 -0
- package/src/domain/services/SyncProtocol.js +6 -5
- package/src/domain/services/WarpMessageCodec.js +2 -0
- package/src/domain/trust/TrustCrypto.js +8 -5
- package/src/domain/types/TickReceipt.js +6 -4
- package/src/domain/types/WarpTypesV2.js +77 -5
- package/src/domain/utils/defaultClock.js +1 -0
- package/src/domain/warp/Writer.js +7 -1
- package/src/domain/warp/fork.methods.js +1 -1
- package/src/domain/warp/patch.methods.js +2 -0
package/README.md
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
<img src="docs/images/hero.gif" alt="git-warp CLI demo" width="600">
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
-
## What's New in v12.
|
|
11
|
+
## What's New in v12.3.0
|
|
12
12
|
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
13
|
+
- **M13 ADR 1 — canonical edge property ops** — internal model now uses honest `NodePropSet`/`EdgePropSet` semantics. Legacy raw `PropSet` is normalized at reducer entry points and lowered back at write time. No wire-format change — persisted patches remain backward-compatible.
|
|
14
|
+
- **Wire gate hardened** — sync boundary now explicitly rejects canonical-only op types (`NodePropSet`, `EdgePropSet`) arriving over the wire, preventing premature schema migration before ADR 2 capability cutover.
|
|
15
|
+
- **Reserved-byte validation** — new writes reject node IDs containing `\0` or starting with `\x01`, preventing ambiguous legacy edge-property encoding.
|
|
16
|
+
- **Version namespace separation** — patch schema and checkpoint schema constants are now distinct (`PATCH_SCHEMA_V2`/`V3` vs `CHECKPOINT_SCHEMA_STANDARD`/`INDEX_TREE`).
|
|
17
|
+
- **ADR governance** — ADR 3 readiness gates formalize when the persisted wire-format migration may proceed, with GitHub issue template and go/no-go checklist.
|
|
17
18
|
|
|
18
19
|
See the [full changelog](CHANGELOG.md) for details.
|
|
19
20
|
|
package/bin/presenters/text.js
CHANGED
|
@@ -291,7 +291,8 @@ function formatOpSummaryPlain(summary) {
|
|
|
291
291
|
const order = [
|
|
292
292
|
['NodeAdd', '+', 'node'],
|
|
293
293
|
['EdgeAdd', '+', 'edge'],
|
|
294
|
-
['
|
|
294
|
+
['prop', '~', 'prop'], // coalesced PropSet + NodePropSet
|
|
295
|
+
['EdgePropSet', '~', 'eprop'],
|
|
295
296
|
['NodeTombstone', '-', 'node'],
|
|
296
297
|
['EdgeTombstone', '-', 'edge'],
|
|
297
298
|
['BlobValue', '+', 'blob'],
|
|
@@ -299,7 +300,10 @@ function formatOpSummaryPlain(summary) {
|
|
|
299
300
|
|
|
300
301
|
const parts = [];
|
|
301
302
|
for (const [opType, symbol, label] of order) {
|
|
302
|
-
|
|
303
|
+
// Coalesce PropSet + NodePropSet into one bucket
|
|
304
|
+
const n = opType === 'prop'
|
|
305
|
+
? (summary?.PropSet || 0) + (summary?.NodePropSet || 0) || undefined
|
|
306
|
+
: summary?.[opType];
|
|
303
307
|
if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
|
|
304
308
|
parts.push(`${symbol}${n}${label}`);
|
|
305
309
|
}
|
|
@@ -612,9 +616,12 @@ function formatPatchOp(op) {
|
|
|
612
616
|
if (op.type === 'EdgeTombstone') {
|
|
613
617
|
return ` - edge ${op.from} -[${op.label}]-> ${op.to}`;
|
|
614
618
|
}
|
|
615
|
-
if (op.type === 'PropSet') {
|
|
619
|
+
if (op.type === 'PropSet' || op.type === 'NodePropSet') {
|
|
616
620
|
return ` ~ ${op.node}.${op.key} = ${JSON.stringify(op.value)}`;
|
|
617
621
|
}
|
|
622
|
+
if (op.type === 'EdgePropSet') {
|
|
623
|
+
return ` ~ edge(${op.from} -[${op.label}]-> ${op.to}).${op.key} = ${JSON.stringify(op.value)}`;
|
|
624
|
+
}
|
|
618
625
|
if (op.type === 'BlobValue') {
|
|
619
626
|
return ` + blob ${op.node}`;
|
|
620
627
|
}
|
package/package.json
CHANGED
package/src/domain/crdt/ORSet.js
CHANGED
|
@@ -116,6 +116,9 @@ export function createORSet() {
|
|
|
116
116
|
* @param {import('./Dot.js').Dot} dot - The dot representing this add operation
|
|
117
117
|
*/
|
|
118
118
|
export function orsetAdd(set, element, dot) {
|
|
119
|
+
if (!dot || typeof dot.writerId !== 'string' || !Number.isInteger(dot.counter)) {
|
|
120
|
+
throw new Error(`orsetAdd: invalid dot -- expected {writerId: string, counter: integer}, got ${JSON.stringify(dot)}`);
|
|
121
|
+
}
|
|
119
122
|
const encoded = encodeDot(dot);
|
|
120
123
|
|
|
121
124
|
let dots = set.entries.get(element);
|
|
@@ -339,7 +339,8 @@ export class AuditReceiptService {
|
|
|
339
339
|
// Compute opsDigest
|
|
340
340
|
const opsDigest = await computeOpsDigest(ops, this._crypto);
|
|
341
341
|
|
|
342
|
-
//
|
|
342
|
+
// Wall-clock timestamp for audit receipt (not a perf timer)
|
|
343
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
343
344
|
const timestamp = Date.now();
|
|
344
345
|
|
|
345
346
|
// Determine prevAuditCommit
|
|
@@ -257,6 +257,7 @@ export class AuditVerifierService {
|
|
|
257
257
|
|
|
258
258
|
return {
|
|
259
259
|
graph: graphName,
|
|
260
|
+
// eslint-disable-next-line no-restricted-syntax -- wall-clock timestamp for audit report
|
|
260
261
|
verifiedAt: new Date().toISOString(),
|
|
261
262
|
summary: { total: chains.length, valid, partial, invalid },
|
|
262
263
|
chains,
|
|
@@ -164,6 +164,7 @@ export async function createBTR(initialState, payload, options) {
|
|
|
164
164
|
throw new TypeError('payload must be a ProvenancePayload');
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// eslint-disable-next-line no-restricted-syntax -- wall-clock default for BTR timestamp
|
|
167
168
|
const { key, timestamp = new Date().toISOString(), crypto, codec } = options;
|
|
168
169
|
|
|
169
170
|
// Validate HMAC key is not empty/falsy
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* materialized graph state. See {@link module:domain/services/WarpMessageCodec}
|
|
6
6
|
* for the facade that re-exports all codec functions.
|
|
7
7
|
*
|
|
8
|
+
* **Schema namespace note:** Checkpoint schema versions (2, 3, 4) are
|
|
9
|
+
* distinct from patch schema versions (PATCH_SCHEMA_V2, PATCH_SCHEMA_V3).
|
|
10
|
+
* See {@link module:domain/services/CheckpointService} for named constants
|
|
11
|
+
* `CHECKPOINT_SCHEMA_STANDARD` and `CHECKPOINT_SCHEMA_INDEX_TREE`.
|
|
12
|
+
*
|
|
8
13
|
* @module domain/services/CheckpointMessageCodec
|
|
9
14
|
*/
|
|
10
15
|
|
|
@@ -28,6 +28,24 @@ import { cloneStateV5, reduceV5 } from './JoinReducer.js';
|
|
|
28
28
|
import { encodeEdgeKey, encodePropKey, CONTENT_PROPERTY_KEY, decodePropKey, isEdgePropKey, decodeEdgePropKey } from './KeyCodec.js';
|
|
29
29
|
import { ProvenanceIndex } from './ProvenanceIndex.js';
|
|
30
30
|
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Checkpoint Schema Constants
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Standard checkpoint schema — full V5 state without index tree.
|
|
37
|
+
* Distinct from the patch schema namespace (PATCH_SCHEMA_V2/V3).
|
|
38
|
+
* @type {number}
|
|
39
|
+
*/
|
|
40
|
+
export const CHECKPOINT_SCHEMA_STANDARD = 2;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Index-tree checkpoint schema — full V5 state with bitmap index tree.
|
|
44
|
+
* Distinct from the patch schema namespace (PATCH_SCHEMA_V2/V3).
|
|
45
|
+
* @type {number}
|
|
46
|
+
*/
|
|
47
|
+
export const CHECKPOINT_SCHEMA_INDEX_TREE = 4;
|
|
48
|
+
|
|
31
49
|
// ============================================================================
|
|
32
50
|
// Internal Helpers
|
|
33
51
|
// ============================================================================
|
|
@@ -244,7 +262,7 @@ export async function createV5({
|
|
|
244
262
|
indexOid: treeOid,
|
|
245
263
|
// Schema 3 was used for edge-property-aware patches but is never emitted
|
|
246
264
|
// by checkpoint creation. Schema 4 indicates an index tree is present.
|
|
247
|
-
schema: indexTree ?
|
|
265
|
+
schema: indexTree ? CHECKPOINT_SCHEMA_INDEX_TREE : CHECKPOINT_SCHEMA_STANDARD,
|
|
248
266
|
});
|
|
249
267
|
|
|
250
268
|
// 9. Create the checkpoint commit
|
|
@@ -15,7 +15,8 @@ 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';
|
|
20
21
|
import PatchError from '../errors/PatchError.js';
|
|
21
22
|
|
|
@@ -27,6 +28,9 @@ export {
|
|
|
27
28
|
encodeEdgePropKey, isEdgePropKey, decodeEdgePropKey,
|
|
28
29
|
} from './KeyCodec.js';
|
|
29
30
|
|
|
31
|
+
// Re-export op normalization for consumers that operate on raw patches
|
|
32
|
+
export { normalizeRawOp, lowerCanonicalOp } from './OpNormalizer.js';
|
|
33
|
+
|
|
30
34
|
/**
|
|
31
35
|
* @typedef {Object} WarpStateV5
|
|
32
36
|
* @property {import('../crdt/ORSet.js').ORSet} nodeAlive - ORSet of alive nodes
|
|
@@ -92,19 +96,58 @@ export function createEmptyStateV5() {
|
|
|
92
96
|
* @returns {void}
|
|
93
97
|
*/
|
|
94
98
|
/**
|
|
95
|
-
* 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.
|
|
101
|
+
* @type {ReadonlySet<string>}
|
|
102
|
+
*/
|
|
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`.
|
|
96
111
|
* @type {ReadonlySet<string>}
|
|
97
112
|
*/
|
|
98
|
-
const
|
|
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
|
+
}
|
|
99
140
|
|
|
100
141
|
/**
|
|
101
142
|
* Validates that an operation has a known type.
|
|
102
143
|
*
|
|
144
|
+
* @deprecated Use {@link isKnownRawOp} for wire validation or
|
|
145
|
+
* {@link isKnownCanonicalOp} for internal guards.
|
|
103
146
|
* @param {{ type: string }} op
|
|
104
|
-
* @returns {boolean} True if the op type is
|
|
147
|
+
* @returns {boolean} True if the op type is a known raw wire type
|
|
105
148
|
*/
|
|
106
149
|
export function isKnownOp(op) {
|
|
107
|
-
return
|
|
150
|
+
return isKnownRawOp(op);
|
|
108
151
|
}
|
|
109
152
|
|
|
110
153
|
/**
|
|
@@ -206,6 +249,16 @@ function validateOp(op) {
|
|
|
206
249
|
requireString(op, 'node');
|
|
207
250
|
requireString(op, 'key');
|
|
208
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;
|
|
209
262
|
default:
|
|
210
263
|
// BlobValue and unknown types: no validation (forward-compat)
|
|
211
264
|
break;
|
|
@@ -245,8 +298,34 @@ export function applyOpV2(state, op, eventId) {
|
|
|
245
298
|
case 'EdgeRemove':
|
|
246
299
|
orsetRemove(state.edgeAlive, /** @type {Set<string>} */ (/** @type {unknown} */ (op.observedDots)));
|
|
247
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
|
+
}
|
|
248
318
|
case 'PropSet': {
|
|
249
|
-
//
|
|
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)
|
|
250
329
|
const key = encodePropKey(/** @type {string} */ (op.node), /** @type {string} */ (op.key));
|
|
251
330
|
const current = state.prop.get(key);
|
|
252
331
|
state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
|
|
@@ -278,6 +357,8 @@ const RECEIPT_OP_TYPE = {
|
|
|
278
357
|
EdgeAdd: 'EdgeAdd',
|
|
279
358
|
EdgeRemove: 'EdgeTombstone',
|
|
280
359
|
PropSet: 'PropSet',
|
|
360
|
+
NodePropSet: 'NodePropSet',
|
|
361
|
+
EdgePropSet: 'EdgePropSet',
|
|
281
362
|
BlobValue: 'BlobValue',
|
|
282
363
|
};
|
|
283
364
|
|
|
@@ -409,7 +490,7 @@ function edgeRemoveOutcome(orset, op) {
|
|
|
409
490
|
}
|
|
410
491
|
|
|
411
492
|
/**
|
|
412
|
-
* Determines the receipt outcome for a
|
|
493
|
+
* Determines the receipt outcome for a property operation given a pre-computed key.
|
|
413
494
|
*
|
|
414
495
|
* Uses LWW (Last-Write-Wins) semantics to determine whether the incoming property
|
|
415
496
|
* value wins over any existing value. The comparison is based on EventId ordering:
|
|
@@ -423,41 +504,61 @@ function edgeRemoveOutcome(orset, op) {
|
|
|
423
504
|
* - `redundant`: Exact same write (identical EventId)
|
|
424
505
|
*
|
|
425
506
|
* @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap - The properties map keyed by encoded prop keys
|
|
426
|
-
* @param {
|
|
427
|
-
* @param {string} op.node - Node ID owning the property
|
|
428
|
-
* @param {string} op.key - Property key/name
|
|
429
|
-
* @param {unknown} op.value - Property value to set
|
|
507
|
+
* @param {string} key - Pre-encoded property key (node or edge)
|
|
430
508
|
* @param {import('../utils/EventId.js').EventId} eventId - The event ID for this operation, used for LWW comparison
|
|
431
509
|
* @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
|
|
432
510
|
* Outcome with encoded prop key as target; includes reason when superseded
|
|
433
511
|
*/
|
|
434
|
-
function
|
|
435
|
-
const key = encodePropKey(op.node, op.key);
|
|
512
|
+
function propOutcomeForKey(propMap, key, eventId) {
|
|
436
513
|
const current = propMap.get(key);
|
|
437
|
-
const target = key;
|
|
438
514
|
|
|
439
515
|
if (!current) {
|
|
440
|
-
|
|
441
|
-
return { target, result: 'applied' };
|
|
516
|
+
return { target: key, result: 'applied' };
|
|
442
517
|
}
|
|
443
518
|
|
|
444
|
-
// Compare the incoming EventId with the existing register's EventId
|
|
445
519
|
const cmp = compareEventIds(eventId, current.eventId);
|
|
446
520
|
if (cmp > 0) {
|
|
447
|
-
|
|
448
|
-
return { target, result: 'applied' };
|
|
521
|
+
return { target: key, result: 'applied' };
|
|
449
522
|
}
|
|
450
523
|
if (cmp < 0) {
|
|
451
|
-
// Existing write wins
|
|
452
524
|
const winner = current.eventId;
|
|
453
525
|
return {
|
|
454
|
-
target,
|
|
526
|
+
target: key,
|
|
455
527
|
result: 'superseded',
|
|
456
528
|
reason: `LWW: writer ${winner.writerId} at lamport ${winner.lamport} wins`,
|
|
457
529
|
};
|
|
458
530
|
}
|
|
459
|
-
|
|
460
|
-
|
|
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);
|
|
461
562
|
}
|
|
462
563
|
|
|
463
564
|
/**
|
|
@@ -504,7 +605,7 @@ function updateFrontierFromPatch(state, patch) {
|
|
|
504
605
|
export function applyFast(state, patch, patchSha) {
|
|
505
606
|
for (let i = 0; i < patch.ops.length; i++) {
|
|
506
607
|
const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
|
|
507
|
-
applyOpV2(state, patch.ops[i], eventId);
|
|
608
|
+
applyOpV2(state, normalizeRawOp(patch.ops[i]), eventId);
|
|
508
609
|
}
|
|
509
610
|
updateFrontierFromPatch(state, patch);
|
|
510
611
|
return state;
|
|
@@ -599,11 +700,17 @@ function snapshotBeforeOp(state, op) {
|
|
|
599
700
|
const aliveBeforeEdges = aliveElementsForDots(state.edgeAlive, edgeDots);
|
|
600
701
|
return { aliveBeforeEdges };
|
|
601
702
|
}
|
|
602
|
-
case 'PropSet':
|
|
703
|
+
case 'PropSet':
|
|
704
|
+
case 'NodePropSet': {
|
|
603
705
|
const pk = encodePropKey(op.node, op.key);
|
|
604
706
|
const reg = state.prop.get(pk);
|
|
605
707
|
return { prevPropValue: reg ? reg.value : undefined, propKey: pk };
|
|
606
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
|
+
}
|
|
607
714
|
default:
|
|
608
715
|
return {};
|
|
609
716
|
}
|
|
@@ -640,7 +747,8 @@ function accumulateOpDiff(diff, state, op, before) {
|
|
|
640
747
|
collectEdgeRemovals(diff, state, before);
|
|
641
748
|
break;
|
|
642
749
|
}
|
|
643
|
-
case 'PropSet':
|
|
750
|
+
case 'PropSet':
|
|
751
|
+
case 'NodePropSet': {
|
|
644
752
|
const reg = state.prop.get(/** @type {string} */ (before.propKey));
|
|
645
753
|
const newVal = reg ? reg.value : undefined;
|
|
646
754
|
if (newVal !== before.prevPropValue) {
|
|
@@ -653,6 +761,19 @@ function accumulateOpDiff(diff, state, op, before) {
|
|
|
653
761
|
}
|
|
654
762
|
break;
|
|
655
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
|
+
}
|
|
656
777
|
default:
|
|
657
778
|
break;
|
|
658
779
|
}
|
|
@@ -701,7 +822,7 @@ function collectEdgeRemovals(diff, state, before) {
|
|
|
701
822
|
* @param {Object} patch - The patch to apply
|
|
702
823
|
* @param {string} patch.writer
|
|
703
824
|
* @param {number} patch.lamport
|
|
704
|
-
* @param {Array<
|
|
825
|
+
* @param {Array<import('../types/WarpTypesV2.js').OpV2 | {type: string}>} patch.ops
|
|
705
826
|
* @param {Map<string, number>|{[x: string]: number}} patch.context
|
|
706
827
|
* @param {string} patchSha - Git SHA of the patch commit
|
|
707
828
|
* @returns {{state: WarpStateV5, diff: import('../types/PatchDiff.js').PatchDiff}}
|
|
@@ -710,13 +831,12 @@ export function applyWithDiff(state, patch, patchSha) {
|
|
|
710
831
|
const diff = createEmptyDiff();
|
|
711
832
|
|
|
712
833
|
for (let i = 0; i < patch.ops.length; i++) {
|
|
713
|
-
const
|
|
714
|
-
validateOp(/** @type {Record<string, unknown>} */ (
|
|
834
|
+
const canonOp = /** @type {import('../types/WarpTypesV2.js').CanonicalOpV2} */ (normalizeRawOp(patch.ops[i]));
|
|
835
|
+
validateOp(/** @type {Record<string, unknown>} */ (canonOp));
|
|
715
836
|
const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
accumulateOpDiff(diff, state, typedOp, before);
|
|
837
|
+
const before = snapshotBeforeOp(state, canonOp);
|
|
838
|
+
applyOpV2(state, canonOp, eventId);
|
|
839
|
+
accumulateOpDiff(diff, state, canonOp, before);
|
|
720
840
|
}
|
|
721
841
|
|
|
722
842
|
updateFrontierFromPatch(state, patch);
|
|
@@ -739,41 +859,47 @@ export function applyWithReceipt(state, patch, patchSha) {
|
|
|
739
859
|
/** @type {import('../types/TickReceipt.js').OpOutcome[]} */
|
|
740
860
|
const opResults = [];
|
|
741
861
|
for (let i = 0; i < patch.ops.length; i++) {
|
|
742
|
-
const
|
|
743
|
-
validateOp(/** @type {Record<string, unknown>} */ (
|
|
862
|
+
const canonOp = /** @type {import('../types/WarpTypesV2.js').OpV2} */ (normalizeRawOp(patch.ops[i]));
|
|
863
|
+
validateOp(/** @type {Record<string, unknown>} */ (canonOp));
|
|
744
864
|
const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
|
|
745
865
|
|
|
746
866
|
// Determine outcome BEFORE applying the op (state is pre-op)
|
|
747
867
|
/** @type {{target: string, result: string, reason?: string}} */
|
|
748
868
|
let outcome;
|
|
749
|
-
switch (
|
|
869
|
+
switch (canonOp.type) {
|
|
750
870
|
case 'NodeAdd':
|
|
751
|
-
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));
|
|
752
872
|
break;
|
|
753
873
|
case 'NodeRemove':
|
|
754
|
-
outcome = nodeRemoveOutcome(state.nodeAlive, /** @type {{node?: string, observedDots: string[]}} */ (
|
|
874
|
+
outcome = nodeRemoveOutcome(state.nodeAlive, /** @type {{node?: string, observedDots: string[]}} */ (canonOp));
|
|
755
875
|
break;
|
|
756
876
|
case 'EdgeAdd': {
|
|
757
|
-
const edgeKey = encodeEdgeKey(
|
|
758
|
-
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);
|
|
759
879
|
break;
|
|
760
880
|
}
|
|
761
881
|
case 'EdgeRemove':
|
|
762
|
-
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));
|
|
763
883
|
break;
|
|
764
884
|
case 'PropSet':
|
|
765
|
-
|
|
885
|
+
case 'NodePropSet':
|
|
886
|
+
outcome = propSetOutcome(state.prop, /** @type {{node: string, key: string, value: *}} */ (canonOp), eventId);
|
|
766
887
|
break;
|
|
767
|
-
|
|
888
|
+
case 'EdgePropSet':
|
|
889
|
+
outcome = edgePropSetOutcome(state.prop, /** @type {{from: string, to: string, label: string, key: string, value: *}} */ (canonOp), eventId);
|
|
890
|
+
break;
|
|
891
|
+
default: {
|
|
768
892
|
// Unknown or BlobValue — always applied
|
|
769
|
-
|
|
893
|
+
const anyOp = /** @type {Record<string, string>} */ (canonOp);
|
|
894
|
+
outcome = { target: anyOp.node || anyOp.oid || '*', result: 'applied' };
|
|
770
895
|
break;
|
|
896
|
+
}
|
|
771
897
|
}
|
|
772
898
|
|
|
773
899
|
// Apply the op (mutates state)
|
|
774
|
-
applyOpV2(state,
|
|
900
|
+
applyOpV2(state, canonOp, eventId);
|
|
775
901
|
|
|
776
|
-
const receiptOp = /** @type {Record<string, string>} */ (RECEIPT_OP_TYPE)[
|
|
902
|
+
const receiptOp = /** @type {Record<string, string>} */ (RECEIPT_OP_TYPE)[canonOp.type] || canonOp.type;
|
|
777
903
|
// Skip unknown/forward-compatible op types that aren't valid receipt ops
|
|
778
904
|
if (!VALID_RECEIPT_OPS.has(receiptOp)) {
|
|
779
905
|
continue;
|
|
@@ -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
|
|
@@ -354,7 +354,7 @@ export default class MaterializedViewService {
|
|
|
354
354
|
* @returns {VerifyResult}
|
|
355
355
|
*/
|
|
356
356
|
verifyIndex({ state, logicalIndex, options = {} }) {
|
|
357
|
-
const seed = options.seed ?? (
|
|
357
|
+
const seed = options.seed ?? (Math.random() * 0x7FFFFFFF >>> 0);
|
|
358
358
|
const sampleRate = options.sampleRate ?? 0.1;
|
|
359
359
|
const allNodes = [...orsetElements(state.nodeAlive)].sort();
|
|
360
360
|
const sampled = sampleNodes(allNodes, sampleRate, seed);
|
|
@@ -20,17 +20,33 @@ import { getCodec, TRAILER_KEYS } from './MessageCodecInternal.js';
|
|
|
20
20
|
// -----------------------------------------------------------------------------
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
23
|
+
* Patch schema version for classic node-only patches (V5 format).
|
|
24
24
|
* @type {number}
|
|
25
25
|
*/
|
|
26
26
|
export const SCHEMA_V2 = 2;
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* Patch schema version for patches that may contain edge property PropSet ops.
|
|
30
30
|
* @type {number}
|
|
31
31
|
*/
|
|
32
32
|
export const SCHEMA_V3 = 3;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Alias: patch schema v2 (classic node-only patches).
|
|
36
|
+
* Use this when you need to be explicit that you mean *patch* schema,
|
|
37
|
+
* not checkpoint schema.
|
|
38
|
+
* @type {number}
|
|
39
|
+
*/
|
|
40
|
+
export const PATCH_SCHEMA_V2 = SCHEMA_V2;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Alias: patch schema v3 (edge-property-aware patches).
|
|
44
|
+
* Use this when you need to be explicit that you mean *patch* schema,
|
|
45
|
+
* not checkpoint schema.
|
|
46
|
+
* @type {number}
|
|
47
|
+
*/
|
|
48
|
+
export const PATCH_SCHEMA_V3 = SCHEMA_V3;
|
|
49
|
+
|
|
34
50
|
// -----------------------------------------------------------------------------
|
|
35
51
|
// Schema Version Detection
|
|
36
52
|
// -----------------------------------------------------------------------------
|
|
@@ -50,6 +66,14 @@ export function detectSchemaVersion(ops) {
|
|
|
50
66
|
return SCHEMA_V2;
|
|
51
67
|
}
|
|
52
68
|
for (const op of ops) {
|
|
69
|
+
if (!op || typeof op !== 'object') {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Canonical EdgePropSet always implies schema 3
|
|
73
|
+
if (op.type === 'EdgePropSet') {
|
|
74
|
+
return SCHEMA_V3;
|
|
75
|
+
}
|
|
76
|
+
// Legacy raw PropSet with edge-property encoding
|
|
53
77
|
if (op.type === 'PropSet' && typeof op.node === 'string' && op.node.startsWith(EDGE_PROP_PREFIX)) {
|
|
54
78
|
return SCHEMA_V3;
|
|
55
79
|
}
|
|
@@ -90,10 +114,16 @@ export function assertOpsCompatible(ops, maxSchema) {
|
|
|
90
114
|
return;
|
|
91
115
|
}
|
|
92
116
|
for (const op of ops) {
|
|
117
|
+
if (!op || typeof op !== 'object') {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
93
120
|
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
op.
|
|
121
|
+
// Canonical EdgePropSet (ADR 1) — should never appear on wire pre-ADR 2,
|
|
122
|
+
// but reject defensively for v2 readers
|
|
123
|
+
op.type === 'EdgePropSet' ||
|
|
124
|
+
(op.type === 'PropSet' &&
|
|
125
|
+
typeof op.node === 'string' &&
|
|
126
|
+
op.node.startsWith(EDGE_PROP_PREFIX))
|
|
97
127
|
) {
|
|
98
128
|
throw new SchemaUnsupportedError(
|
|
99
129
|
'Upgrade to >=7.3.0 (WEIGHTED) to sync edge properties.',
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpNormalizer — raw ↔ canonical operation conversion.
|
|
3
|
+
*
|
|
4
|
+
* ADR 1 (Canonicalize Edge Property Operations Internally) requires that
|
|
5
|
+
* reducers, provenance, receipts, and queries operate on canonical ops:
|
|
6
|
+
*
|
|
7
|
+
* Raw (persisted): NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, PropSet, BlobValue
|
|
8
|
+
* Canonical (internal): NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, NodePropSet, EdgePropSet, BlobValue
|
|
9
|
+
*
|
|
10
|
+
* **Current normalization location:** Normalization is performed at the
|
|
11
|
+
* reducer entry points (`applyFast`, `applyWithReceipt`, `applyWithDiff`
|
|
12
|
+
* in JoinReducer.js), not at the CBOR decode boundary as originally
|
|
13
|
+
* planned in ADR 1. This is a pragmatic deviation — the reducer calls
|
|
14
|
+
* `normalizeRawOp()` on each op before dispatch. Lowering happens in
|
|
15
|
+
* `PatchBuilderV2.build()`/`commit()` via `lowerCanonicalOp()`.
|
|
16
|
+
*
|
|
17
|
+
* @module domain/services/OpNormalizer
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createNodePropSetV2, createEdgePropSetV2, createPropSetV2 } from '../types/WarpTypesV2.js';
|
|
21
|
+
import { isLegacyEdgePropNode, decodeLegacyEdgePropNode, encodeLegacyEdgePropNode } from './KeyCodec.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Normalizes a single raw (persisted) op into its canonical form.
|
|
25
|
+
*
|
|
26
|
+
* - Raw `PropSet` with \x01-prefixed node → canonical `EdgePropSet`
|
|
27
|
+
* - Raw `PropSet` without prefix → canonical `NodePropSet`
|
|
28
|
+
* - All other op types pass through unchanged.
|
|
29
|
+
*
|
|
30
|
+
* @param {import('../types/WarpTypesV2.js').RawOpV2 | {type: string}} rawOp
|
|
31
|
+
* @returns {import('../types/WarpTypesV2.js').CanonicalOpV2 | {type: string}}
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeRawOp(rawOp) {
|
|
34
|
+
if (!rawOp || typeof rawOp !== 'object' || typeof rawOp.type !== 'string') {
|
|
35
|
+
return rawOp;
|
|
36
|
+
}
|
|
37
|
+
if (rawOp.type !== 'PropSet') {
|
|
38
|
+
return rawOp;
|
|
39
|
+
}
|
|
40
|
+
const op = /** @type {import('../types/WarpTypesV2.js').OpV2PropSet} */ (rawOp);
|
|
41
|
+
if (isLegacyEdgePropNode(op.node)) {
|
|
42
|
+
const { from, to, label } = decodeLegacyEdgePropNode(op.node);
|
|
43
|
+
return createEdgePropSetV2(from, to, label, op.key, op.value);
|
|
44
|
+
}
|
|
45
|
+
return createNodePropSetV2(op.node, op.key, op.value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Lowers a single canonical op back to raw (persisted) form.
|
|
50
|
+
*
|
|
51
|
+
* - Canonical `NodePropSet` → raw `PropSet`
|
|
52
|
+
* - Canonical `EdgePropSet` → raw `PropSet` with legacy \x01-prefixed node
|
|
53
|
+
* - All other op types pass through unchanged.
|
|
54
|
+
*
|
|
55
|
+
* In M13, this always produces legacy raw PropSet for property ops.
|
|
56
|
+
* A future graph capability cutover (ADR 2) may allow emitting raw
|
|
57
|
+
* `EdgePropSet` directly.
|
|
58
|
+
*
|
|
59
|
+
* @param {import('../types/WarpTypesV2.js').CanonicalOpV2 | {type: string}} canonicalOp
|
|
60
|
+
* @returns {import('../types/WarpTypesV2.js').RawOpV2 | {type: string}}
|
|
61
|
+
*/
|
|
62
|
+
export function lowerCanonicalOp(canonicalOp) {
|
|
63
|
+
switch (canonicalOp.type) {
|
|
64
|
+
case 'NodePropSet': {
|
|
65
|
+
const op = /** @type {import('../types/WarpTypesV2.js').OpV2NodePropSet} */ (canonicalOp);
|
|
66
|
+
return createPropSetV2(op.node, op.key, op.value);
|
|
67
|
+
}
|
|
68
|
+
case 'EdgePropSet': {
|
|
69
|
+
const op = /** @type {import('../types/WarpTypesV2.js').OpV2EdgePropSet} */ (canonicalOp);
|
|
70
|
+
return createPropSetV2(
|
|
71
|
+
encodeLegacyEdgePropNode(op.from, op.to, op.label),
|
|
72
|
+
op.key,
|
|
73
|
+
op.value,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
default:
|
|
77
|
+
return canonicalOp;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -20,10 +20,12 @@ import {
|
|
|
20
20
|
createNodeRemoveV2,
|
|
21
21
|
createEdgeAddV2,
|
|
22
22
|
createEdgeRemoveV2,
|
|
23
|
-
|
|
23
|
+
createNodePropSetV2,
|
|
24
|
+
createEdgePropSetV2,
|
|
24
25
|
createPatchV2,
|
|
25
26
|
} from '../types/WarpTypesV2.js';
|
|
26
|
-
import { encodeEdgeKey, EDGE_PROP_PREFIX, CONTENT_PROPERTY_KEY } from './KeyCodec.js';
|
|
27
|
+
import { encodeEdgeKey, FIELD_SEPARATOR, EDGE_PROP_PREFIX, CONTENT_PROPERTY_KEY } from './KeyCodec.js';
|
|
28
|
+
import { lowerCanonicalOp } from './OpNormalizer.js';
|
|
27
29
|
import { encodePatchMessage, decodePatchMessage, detectMessageKind } from './WarpMessageCodec.js';
|
|
28
30
|
import { buildWriterRef } from '../utils/RefLayout.js';
|
|
29
31
|
import WriterError from '../errors/WriterError.js';
|
|
@@ -66,6 +68,30 @@ function findAttachedData(state, nodeId) {
|
|
|
66
68
|
return { edges, props, hasData: edges.length > 0 || props.length > 0 };
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Validates that an identifier does not contain reserved bytes that would
|
|
73
|
+
* make the legacy edge-property encoding ambiguous.
|
|
74
|
+
*
|
|
75
|
+
* Rejects:
|
|
76
|
+
* - Identifiers containing \0 (field separator)
|
|
77
|
+
* - Identifiers starting with \x01 (edge property prefix)
|
|
78
|
+
*
|
|
79
|
+
* @param {string} value - Identifier to validate
|
|
80
|
+
* @param {string} label - Human-readable label for error messages
|
|
81
|
+
* @throws {Error} If the identifier contains reserved bytes
|
|
82
|
+
*/
|
|
83
|
+
function _assertNoReservedBytes(value, label) {
|
|
84
|
+
if (typeof value !== 'string') {
|
|
85
|
+
throw new Error(`${label} must be a string, got ${typeof value}`);
|
|
86
|
+
}
|
|
87
|
+
if (value.includes(FIELD_SEPARATOR)) {
|
|
88
|
+
throw new Error(`${label} must not contain null bytes (\\0): ${JSON.stringify(value)}`);
|
|
89
|
+
}
|
|
90
|
+
if (value.length > 0 && value[0] === EDGE_PROP_PREFIX) {
|
|
91
|
+
throw new Error(`${label} must not start with reserved prefix \\x01: ${JSON.stringify(value)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
69
95
|
/**
|
|
70
96
|
* Fluent builder for creating WARP v5 patches with dots and observed-remove semantics.
|
|
71
97
|
*/
|
|
@@ -85,7 +111,7 @@ export class PatchBuilderV2 {
|
|
|
85
111
|
* @param {Function|null} [options.onCommitSuccess] - Callback invoked after successful commit
|
|
86
112
|
* @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
|
|
87
113
|
* @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization
|
|
88
|
-
* @param {
|
|
114
|
+
* @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for non-fatal warnings
|
|
89
115
|
*/
|
|
90
116
|
constructor({ persistence, graphName, writerId, lamport, versionVector, getCurrentState, expectedParentSha = null, onCommitSuccess = null, onDeleteWithData = 'warn', codec, logger }) {
|
|
91
117
|
/** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default} */
|
|
@@ -133,7 +159,7 @@ export class PatchBuilderV2 {
|
|
|
133
159
|
/** @type {import('../../ports/CodecPort.js').default} */
|
|
134
160
|
this._codec = codec || defaultCodec;
|
|
135
161
|
|
|
136
|
-
/** @type {
|
|
162
|
+
/** @type {import('../../ports/LoggerPort.js').default} */
|
|
137
163
|
this._logger = logger || nullLogger;
|
|
138
164
|
|
|
139
165
|
/**
|
|
@@ -233,6 +259,7 @@ export class PatchBuilderV2 {
|
|
|
233
259
|
*/
|
|
234
260
|
addNode(nodeId) {
|
|
235
261
|
this._assertNotCommitted();
|
|
262
|
+
_assertNoReservedBytes(nodeId, 'nodeId');
|
|
236
263
|
const dot = vvIncrement(this._vv, this._writerId);
|
|
237
264
|
this._ops.push(createNodeAddV2(nodeId, dot));
|
|
238
265
|
// Provenance: NodeAdd writes the node
|
|
@@ -305,8 +332,7 @@ export class PatchBuilderV2 {
|
|
|
305
332
|
}
|
|
306
333
|
|
|
307
334
|
if (this._onDeleteWithData === 'warn') {
|
|
308
|
-
|
|
309
|
-
console.warn(
|
|
335
|
+
this._logger.warn(
|
|
310
336
|
`[warp] Deleting node '${nodeId}' which has attached data (${summary}). ` +
|
|
311
337
|
`Orphaned data will remain in state.`
|
|
312
338
|
);
|
|
@@ -349,6 +375,9 @@ export class PatchBuilderV2 {
|
|
|
349
375
|
*/
|
|
350
376
|
addEdge(from, to, label) {
|
|
351
377
|
this._assertNotCommitted();
|
|
378
|
+
_assertNoReservedBytes(from, 'from node ID');
|
|
379
|
+
_assertNoReservedBytes(to, 'to node ID');
|
|
380
|
+
_assertNoReservedBytes(label, 'edge label');
|
|
352
381
|
const dot = vvIncrement(this._vv, this._writerId);
|
|
353
382
|
this._ops.push(createEdgeAddV2(from, to, label, dot));
|
|
354
383
|
const edgeKey = encodeEdgeKey(from, to, label);
|
|
@@ -428,9 +457,11 @@ export class PatchBuilderV2 {
|
|
|
428
457
|
*/
|
|
429
458
|
setProperty(nodeId, key, value) {
|
|
430
459
|
this._assertNotCommitted();
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
//
|
|
460
|
+
_assertNoReservedBytes(nodeId, 'nodeId');
|
|
461
|
+
_assertNoReservedBytes(key, 'property key');
|
|
462
|
+
// Canonical NodePropSet — lowered to raw PropSet at commit time
|
|
463
|
+
this._ops.push(createNodePropSetV2(nodeId, key, value));
|
|
464
|
+
// Provenance: NodePropSet reads the node (implicit existence check) and writes the node
|
|
434
465
|
this._observedOperands.add(nodeId);
|
|
435
466
|
this._writes.add(nodeId);
|
|
436
467
|
return this;
|
|
@@ -475,6 +506,10 @@ export class PatchBuilderV2 {
|
|
|
475
506
|
*/
|
|
476
507
|
setEdgeProperty(from, to, label, key, value) {
|
|
477
508
|
this._assertNotCommitted();
|
|
509
|
+
_assertNoReservedBytes(from, 'from node ID');
|
|
510
|
+
_assertNoReservedBytes(to, 'to node ID');
|
|
511
|
+
_assertNoReservedBytes(label, 'edge label');
|
|
512
|
+
_assertNoReservedBytes(key, 'property key');
|
|
478
513
|
// Validate edge exists in this patch or in current state
|
|
479
514
|
const ek = encodeEdgeKey(from, to, label);
|
|
480
515
|
if (!this._edgesAdded.has(ek)) {
|
|
@@ -484,15 +519,10 @@ export class PatchBuilderV2 {
|
|
|
484
519
|
}
|
|
485
520
|
}
|
|
486
521
|
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
// = `\x01from\0to\0label` + `\0` + key
|
|
490
|
-
// = `\x01from\0to\0label\0key`
|
|
491
|
-
// = encodeEdgePropKey(from, to, label, key)
|
|
492
|
-
const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
|
|
493
|
-
this._ops.push(createPropSetV2(edgeNode, key, value));
|
|
522
|
+
// Canonical EdgePropSet — lowered to legacy raw PropSet at commit time
|
|
523
|
+
this._ops.push(createEdgePropSetV2(from, to, label, key, value));
|
|
494
524
|
this._hasEdgeProps = true;
|
|
495
|
-
// Provenance:
|
|
525
|
+
// Provenance: EdgePropSet reads the edge (implicit existence check) and writes the edge
|
|
496
526
|
this._observedOperands.add(ek);
|
|
497
527
|
this._writes.add(ek);
|
|
498
528
|
return this;
|
|
@@ -515,6 +545,9 @@ export class PatchBuilderV2 {
|
|
|
515
545
|
*/
|
|
516
546
|
async attachContent(nodeId, content) {
|
|
517
547
|
this._assertNotCommitted();
|
|
548
|
+
// Validate identifiers before writing blob to avoid orphaned blobs
|
|
549
|
+
_assertNoReservedBytes(nodeId, 'nodeId');
|
|
550
|
+
_assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
|
|
518
551
|
const oid = await this._persistence.writeBlob(content);
|
|
519
552
|
this.setProperty(nodeId, CONTENT_PROPERTY_KEY, oid);
|
|
520
553
|
this._contentBlobs.push(oid);
|
|
@@ -533,6 +566,11 @@ export class PatchBuilderV2 {
|
|
|
533
566
|
*/
|
|
534
567
|
async attachEdgeContent(from, to, label, content) {
|
|
535
568
|
this._assertNotCommitted();
|
|
569
|
+
// Validate identifiers before writing blob to avoid orphaned blobs
|
|
570
|
+
_assertNoReservedBytes(from, 'from');
|
|
571
|
+
_assertNoReservedBytes(to, 'to');
|
|
572
|
+
_assertNoReservedBytes(label, 'label');
|
|
573
|
+
_assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
|
|
536
574
|
const oid = await this._persistence.writeBlob(content);
|
|
537
575
|
this.setEdgeProperty(from, to, label, CONTENT_PROPERTY_KEY, oid);
|
|
538
576
|
this._contentBlobs.push(oid);
|
|
@@ -559,12 +597,14 @@ export class PatchBuilderV2 {
|
|
|
559
597
|
*/
|
|
560
598
|
build() {
|
|
561
599
|
const schema = this._hasEdgeProps ? 3 : 2;
|
|
600
|
+
// Lower canonical ops to raw form for the persisted patch
|
|
601
|
+
const rawOps = /** @type {import('../types/WarpTypesV2.js').RawOpV2[]} */ (this._ops.map(lowerCanonicalOp));
|
|
562
602
|
return createPatchV2({
|
|
563
603
|
schema,
|
|
564
604
|
writer: this._writerId,
|
|
565
605
|
lamport: this._lamport,
|
|
566
606
|
context: vvSerialize(this._vv),
|
|
567
|
-
ops:
|
|
607
|
+
ops: rawOps,
|
|
568
608
|
reads: [...this._observedOperands].sort(),
|
|
569
609
|
writes: [...this._writes].sort(),
|
|
570
610
|
});
|
|
@@ -678,13 +718,14 @@ export class PatchBuilderV2 {
|
|
|
678
718
|
// For now, we use the calculated lamport for the patch metadata.
|
|
679
719
|
// The dots themselves are independent of patch lamport (they use VV counters).
|
|
680
720
|
const schema = this._hasEdgeProps ? 3 : 2;
|
|
681
|
-
//
|
|
721
|
+
// Lower canonical ops to raw form for the persisted patch
|
|
722
|
+
const rawOps = /** @type {import('../types/WarpTypesV2.js').RawOpV2[]} */ (this._ops.map(lowerCanonicalOp));
|
|
682
723
|
const patch = createPatchV2({
|
|
683
724
|
schema,
|
|
684
725
|
writer: this._writerId,
|
|
685
726
|
lamport,
|
|
686
727
|
context: vvSerialize(this._vv),
|
|
687
|
-
ops:
|
|
728
|
+
ops: rawOps,
|
|
688
729
|
reads: [...this._observedOperands].sort(),
|
|
689
730
|
writes: [...this._writes].sort(),
|
|
690
731
|
});
|
|
@@ -726,7 +767,7 @@ export class PatchBuilderV2 {
|
|
|
726
767
|
await this._onCommitSuccess({ patch, sha: newCommitSha });
|
|
727
768
|
} catch (err) {
|
|
728
769
|
// Commit is already persisted — log but don't fail the caller.
|
|
729
|
-
this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, err);
|
|
770
|
+
this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, { error: err });
|
|
730
771
|
}
|
|
731
772
|
}
|
|
732
773
|
|
|
@@ -68,6 +68,8 @@ export function buildCanonicalPayload({ keyId, method, path, timestamp, nonce, c
|
|
|
68
68
|
*/
|
|
69
69
|
export async function signSyncRequest({ method, path, contentType, body, secret, keyId }, { crypto } = {}) {
|
|
70
70
|
const c = crypto || defaultCrypto;
|
|
71
|
+
// Wall-clock timestamp required for HMAC replay protection (not a perf timer)
|
|
72
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
71
73
|
const timestamp = String(Date.now());
|
|
72
74
|
const nonce = globalThis.crypto.randomUUID();
|
|
73
75
|
|
|
@@ -187,6 +189,7 @@ export default class SyncAuthService {
|
|
|
187
189
|
this._mode = mode;
|
|
188
190
|
this._crypto = crypto || defaultCrypto;
|
|
189
191
|
this._logger = logger || nullLogger;
|
|
192
|
+
// eslint-disable-next-line no-restricted-syntax -- wall-clock fallback for HMAC verification
|
|
190
193
|
this._wallClockMs = wallClockMs || (() => Date.now());
|
|
191
194
|
this._maxClockSkewMs = typeof maxClockSkewMs === 'number' ? maxClockSkewMs : MAX_CLOCK_SKEW_MS;
|
|
192
195
|
this._nonceCache = new LRUCache(nonceCapacity || DEFAULT_NONCE_CAPACITY);
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import defaultCodec from '../utils/defaultCodec.js';
|
|
40
40
|
import nullLogger from '../utils/nullLogger.js';
|
|
41
41
|
import { decodePatchMessage, assertOpsCompatible, SCHEMA_V3 } from './WarpMessageCodec.js';
|
|
42
|
-
import { join, cloneStateV5,
|
|
42
|
+
import { join, cloneStateV5, isKnownRawOp } from './JoinReducer.js';
|
|
43
43
|
import SchemaUnsupportedError from '../errors/SchemaUnsupportedError.js';
|
|
44
44
|
import { cloneFrontier, updateFrontier } from './Frontier.js';
|
|
45
45
|
import { vvDeserialize } from '../crdt/VersionVector.js';
|
|
@@ -559,17 +559,18 @@ export function applySyncResponse(response, state, frontier) {
|
|
|
559
559
|
// Normalize patch context (in case it came from network serialization)
|
|
560
560
|
const normalizedPatch = normalizePatch(patch);
|
|
561
561
|
// Guard: reject patches with genuinely unknown op types (B106 / C2 fix).
|
|
562
|
-
//
|
|
563
|
-
//
|
|
562
|
+
// Uses isKnownRawOp to accept only the 6 wire-format types. Canonical-only
|
|
563
|
+
// types (NodePropSet, EdgePropSet) must never appear on the wire before
|
|
564
|
+
// ADR 2 capability cutover — reject them here to fail closed.
|
|
564
565
|
for (const op of normalizedPatch.ops) {
|
|
565
|
-
if (!
|
|
566
|
+
if (!isKnownRawOp(op)) {
|
|
566
567
|
throw new SchemaUnsupportedError(
|
|
567
568
|
`Patch ${sha} contains unknown op type: ${op.type}`
|
|
568
569
|
);
|
|
569
570
|
}
|
|
570
571
|
}
|
|
571
572
|
// Guard: reject patches exceeding our maximum supported schema version.
|
|
572
|
-
//
|
|
573
|
+
// isKnownRawOp() above checks op-type recognition; this checks the schema
|
|
573
574
|
// version ceiling. Currently SCHEMA_V3 is the max.
|
|
574
575
|
assertOpsCompatible(normalizedPatch.ops, SCHEMA_V3);
|
|
575
576
|
// Apply patch to state
|
|
@@ -16,6 +16,13 @@ export const SUPPORTED_ALGORITHMS = new Set(['ed25519']);
|
|
|
16
16
|
|
|
17
17
|
const ED25519_PUBLIC_KEY_LENGTH = 32;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* DER-encoded SPKI prefix for Ed25519 public keys (RFC 8410, Section 4).
|
|
21
|
+
* Prepend to a 32-byte raw key to form a valid SPKI structure for `createPublicKey()`.
|
|
22
|
+
* @see https://www.rfc-editor.org/rfc/rfc8410#section-4
|
|
23
|
+
*/
|
|
24
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
25
|
+
|
|
19
26
|
/**
|
|
20
27
|
* Decodes a base64-encoded Ed25519 public key and validates its length.
|
|
21
28
|
*
|
|
@@ -81,11 +88,7 @@ export function verifySignature({
|
|
|
81
88
|
const raw = decodePublicKey(publicKeyBase64);
|
|
82
89
|
|
|
83
90
|
const keyObject = createPublicKey({
|
|
84
|
-
key: Buffer.concat([
|
|
85
|
-
// DER prefix for Ed25519 public key (RFC 8410)
|
|
86
|
-
Buffer.from('302a300506032b6570032100', 'hex'),
|
|
87
|
-
raw,
|
|
88
|
-
]),
|
|
91
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
|
|
89
92
|
format: 'der',
|
|
90
93
|
type: 'spki',
|
|
91
94
|
});
|
|
@@ -25,6 +25,8 @@ export const OP_TYPES = Object.freeze([
|
|
|
25
25
|
'EdgeAdd',
|
|
26
26
|
'EdgeTombstone',
|
|
27
27
|
'PropSet',
|
|
28
|
+
'NodePropSet',
|
|
29
|
+
'EdgePropSet',
|
|
28
30
|
'BlobValue',
|
|
29
31
|
]);
|
|
30
32
|
|
|
@@ -80,9 +82,9 @@ function validateOp(op, index) {
|
|
|
80
82
|
/**
|
|
81
83
|
* Validates that an operation type is one of the allowed OP_TYPES.
|
|
82
84
|
*
|
|
83
|
-
* Valid operation types correspond to the
|
|
84
|
-
*
|
|
85
|
-
*
|
|
85
|
+
* Valid operation types correspond to the eight receipt operation types:
|
|
86
|
+
* NodeAdd, NodeTombstone, EdgeAdd, EdgeTombstone, PropSet, NodePropSet,
|
|
87
|
+
* EdgePropSet, and BlobValue.
|
|
86
88
|
*
|
|
87
89
|
* @param {unknown} value - The operation type to validate
|
|
88
90
|
* @param {number} i - Index of the operation in the ops array (for error messages)
|
|
@@ -156,7 +158,7 @@ function validateOpResult(value, i) {
|
|
|
156
158
|
|
|
157
159
|
/**
|
|
158
160
|
* @typedef {Object} OpOutcome
|
|
159
|
-
* @property {string} op - Operation type ('NodeAdd' | 'NodeTombstone' | 'EdgeAdd' | 'EdgeTombstone' | 'PropSet' | 'BlobValue')
|
|
161
|
+
* @property {string} op - Operation type ('NodeAdd' | 'NodeTombstone' | 'EdgeAdd' | 'EdgeTombstone' | 'PropSet' | 'NodePropSet' | 'EdgePropSet' | 'BlobValue')
|
|
160
162
|
* @property {string} target - Node ID or edge key
|
|
161
163
|
* @property {'applied' | 'superseded' | 'redundant'} result - Outcome of the operation
|
|
162
164
|
* @property {string} [reason] - Human-readable explanation (e.g., "LWW: writer bob at lamport 43 wins")
|
|
@@ -74,18 +74,64 @@
|
|
|
74
74
|
*/
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* Property set operation - sets a property value on a node
|
|
78
|
-
* Uses EventId for identification (derived from patch context)
|
|
77
|
+
* Property set operation - sets a property value on a node (raw/persisted form).
|
|
78
|
+
* Uses EventId for identification (derived from patch context).
|
|
79
|
+
*
|
|
80
|
+
* In raw patches, edge properties are also encoded as PropSet with the node
|
|
81
|
+
* field carrying a \x01-prefixed edge identity. See {@link OpV2NodePropSet}
|
|
82
|
+
* and {@link OpV2EdgePropSet} for the canonical (internal) representations.
|
|
83
|
+
*
|
|
79
84
|
* @typedef {Object} OpV2PropSet
|
|
80
85
|
* @property {'PropSet'} type - Operation type discriminator
|
|
86
|
+
* @property {NodeId} node - Node ID to set property on (may contain \x01 prefix for edge props)
|
|
87
|
+
* @property {string} key - Property key
|
|
88
|
+
* @property {unknown} value - Property value (any JSON-serializable type)
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Canonical node property set operation (internal only — never persisted).
|
|
93
|
+
* @typedef {Object} OpV2NodePropSet
|
|
94
|
+
* @property {'NodePropSet'} type - Operation type discriminator
|
|
81
95
|
* @property {NodeId} node - Node ID to set property on
|
|
82
96
|
* @property {string} key - Property key
|
|
83
97
|
* @property {unknown} value - Property value (any JSON-serializable type)
|
|
84
98
|
*/
|
|
85
99
|
|
|
86
100
|
/**
|
|
87
|
-
*
|
|
88
|
-
* @typedef {
|
|
101
|
+
* Canonical edge property set operation (internal only — never persisted).
|
|
102
|
+
* @typedef {Object} OpV2EdgePropSet
|
|
103
|
+
* @property {'EdgePropSet'} type - Operation type discriminator
|
|
104
|
+
* @property {NodeId} from - Source node ID
|
|
105
|
+
* @property {NodeId} to - Target node ID
|
|
106
|
+
* @property {string} label - Edge label
|
|
107
|
+
* @property {string} key - Property key
|
|
108
|
+
* @property {unknown} value - Property value (any JSON-serializable type)
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Blob value reference operation.
|
|
113
|
+
* @typedef {Object} OpV2BlobValue
|
|
114
|
+
* @property {'BlobValue'} type - Operation type discriminator
|
|
115
|
+
* @property {string} node - Node ID the blob is attached to
|
|
116
|
+
* @property {string} oid - Blob object ID in the Git object store
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Union of all raw (persisted) v2 operation types.
|
|
121
|
+
* @typedef {OpV2NodeAdd | OpV2NodeRemove | OpV2EdgeAdd | OpV2EdgeRemove | OpV2PropSet | OpV2BlobValue} RawOpV2
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Union of all canonical (internal) v2 operation types.
|
|
126
|
+
* Reducers, provenance, receipts, and queries operate on canonical ops only.
|
|
127
|
+
* @typedef {OpV2NodeAdd | OpV2NodeRemove | OpV2EdgeAdd | OpV2EdgeRemove | OpV2NodePropSet | OpV2EdgePropSet | OpV2BlobValue} CanonicalOpV2
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Union of all v2 operation types (raw + canonical).
|
|
132
|
+
* Used in patch containers that may hold either raw ops (from disk)
|
|
133
|
+
* or canonical ops (after normalization).
|
|
134
|
+
* @typedef {RawOpV2 | CanonicalOpV2} OpV2
|
|
89
135
|
*/
|
|
90
136
|
|
|
91
137
|
// ============================================================================
|
|
@@ -153,7 +199,9 @@ export function createEdgeRemoveV2(from, to, label, observedDots) {
|
|
|
153
199
|
}
|
|
154
200
|
|
|
155
201
|
/**
|
|
156
|
-
* Creates a PropSet operation (no dot - uses EventId)
|
|
202
|
+
* Creates a raw PropSet operation (no dot - uses EventId).
|
|
203
|
+
* This is the persisted form. For internal use, prefer
|
|
204
|
+
* {@link createNodePropSetV2} or {@link createEdgePropSetV2}.
|
|
157
205
|
* @param {NodeId} node - Node ID to set property on
|
|
158
206
|
* @param {string} key - Property key
|
|
159
207
|
* @param {unknown} value - Property value (any JSON-serializable type)
|
|
@@ -163,6 +211,30 @@ export function createPropSetV2(node, key, value) {
|
|
|
163
211
|
return { type: 'PropSet', node, key, value };
|
|
164
212
|
}
|
|
165
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Creates a canonical NodePropSet operation (internal only).
|
|
216
|
+
* @param {NodeId} node - Node ID to set property on
|
|
217
|
+
* @param {string} key - Property key
|
|
218
|
+
* @param {unknown} value - Property value (any JSON-serializable type)
|
|
219
|
+
* @returns {OpV2NodePropSet} NodePropSet operation
|
|
220
|
+
*/
|
|
221
|
+
export function createNodePropSetV2(node, key, value) {
|
|
222
|
+
return { type: 'NodePropSet', node, key, value };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Creates a canonical EdgePropSet operation (internal only).
|
|
227
|
+
* @param {NodeId} from - Source node ID
|
|
228
|
+
* @param {NodeId} to - Target node ID
|
|
229
|
+
* @param {string} label - Edge label
|
|
230
|
+
* @param {string} key - Property key
|
|
231
|
+
* @param {unknown} value - Property value (any JSON-serializable type)
|
|
232
|
+
* @returns {OpV2EdgePropSet} EdgePropSet operation
|
|
233
|
+
*/
|
|
234
|
+
export function createEdgePropSetV2(from, to, label, key, value) {
|
|
235
|
+
return { type: 'EdgePropSet', from, to, label, key, value };
|
|
236
|
+
}
|
|
237
|
+
|
|
166
238
|
// ============================================================================
|
|
167
239
|
// Factory Functions - Patch
|
|
168
240
|
// ============================================================================
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import defaultCodec from '../utils/defaultCodec.js';
|
|
18
|
+
import nullLogger from '../utils/nullLogger.js';
|
|
18
19
|
import { validateWriterId, buildWriterRef } from '../utils/RefLayout.js';
|
|
19
20
|
import { PatchSession } from './PatchSession.js';
|
|
20
21
|
import { PatchBuilderV2 } from '../services/PatchBuilderV2.js';
|
|
@@ -44,8 +45,9 @@ export class Writer {
|
|
|
44
45
|
* @param {(result: {patch: Object, sha: string}) => void | Promise<void>} [options.onCommitSuccess] - Callback invoked after successful commit with { patch, sha }
|
|
45
46
|
* @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
|
|
46
47
|
* @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for CBOR serialization (defaults to domain-local codec)
|
|
48
|
+
* @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger port
|
|
47
49
|
*/
|
|
48
|
-
constructor({ persistence, graphName, writerId, versionVector, getCurrentState, onCommitSuccess, onDeleteWithData = 'warn', codec }) {
|
|
50
|
+
constructor({ persistence, graphName, writerId, versionVector, getCurrentState, onCommitSuccess, onDeleteWithData = 'warn', codec, logger }) {
|
|
49
51
|
validateWriterId(writerId);
|
|
50
52
|
|
|
51
53
|
/** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default} */
|
|
@@ -72,6 +74,9 @@ export class Writer {
|
|
|
72
74
|
/** @type {import('../../ports/CodecPort.js').default|undefined} */
|
|
73
75
|
this._codec = codec || defaultCodec;
|
|
74
76
|
|
|
77
|
+
/** @type {import('../../ports/LoggerPort.js').default} */
|
|
78
|
+
this._logger = logger || nullLogger;
|
|
79
|
+
|
|
75
80
|
/** @type {boolean} */
|
|
76
81
|
this._commitInProgress = false;
|
|
77
82
|
}
|
|
@@ -151,6 +156,7 @@ export class Writer {
|
|
|
151
156
|
onCommitSuccess: this._onCommitSuccess,
|
|
152
157
|
onDeleteWithData: this._onDeleteWithData,
|
|
153
158
|
codec: this._codec,
|
|
159
|
+
logger: this._logger,
|
|
154
160
|
});
|
|
155
161
|
|
|
156
162
|
// Return PatchSession wrapping the builder
|
|
@@ -104,7 +104,7 @@ export async function fork({ from, at, forkName, forkWriterId }) {
|
|
|
104
104
|
|
|
105
105
|
// 4. Generate or validate fork name (add random suffix to prevent collisions)
|
|
106
106
|
const resolvedForkName =
|
|
107
|
-
forkName ?? `${this._graphName}-fork-${
|
|
107
|
+
forkName ?? `${this._graphName}-fork-${Math.random().toString(36).slice(2, 10).padEnd(8, '0')}`;
|
|
108
108
|
try {
|
|
109
109
|
validateGraphName(resolvedForkName);
|
|
110
110
|
} catch (err) {
|
|
@@ -291,6 +291,7 @@ export async function writer(writerId) {
|
|
|
291
291
|
onDeleteWithData: this._onDeleteWithData,
|
|
292
292
|
onCommitSuccess: /** @type {(result: {patch: Object, sha: string}) => void} */ (/** @type {unknown} */ ((/** @type {{patch?: import('../types/WarpTypesV2.js').PatchV2, sha?: string}} */ opts) => this._onPatchCommitted(resolvedWriterId, opts))),
|
|
293
293
|
codec: this._codec,
|
|
294
|
+
logger: this._logger || undefined,
|
|
294
295
|
});
|
|
295
296
|
}
|
|
296
297
|
|
|
@@ -349,6 +350,7 @@ export async function createWriter(opts = {}) {
|
|
|
349
350
|
onDeleteWithData: this._onDeleteWithData,
|
|
350
351
|
onCommitSuccess: /** @type {(result: {patch: Object, sha: string}) => void} */ (/** @type {unknown} */ ((/** @type {{patch?: import('../types/WarpTypesV2.js').PatchV2, sha?: string}} */ commitOpts) => this._onPatchCommitted(freshWriterId, commitOpts))),
|
|
351
352
|
codec: this._codec,
|
|
353
|
+
logger: this._logger || undefined,
|
|
352
354
|
});
|
|
353
355
|
}
|
|
354
356
|
|