@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
|
@@ -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';
|
|
@@ -46,9 +48,12 @@ function findAttachedData(state, nodeId) {
|
|
|
46
48
|
const edges = [];
|
|
47
49
|
const props = [];
|
|
48
50
|
|
|
51
|
+
// Edge keys are encoded as "from\0to\0label". Check prefix for source
|
|
52
|
+
// and interior substring for target — avoids split() on every key.
|
|
53
|
+
const srcPrefix = `${nodeId}\0`;
|
|
54
|
+
const tgtInfix = `\0${nodeId}\0`;
|
|
49
55
|
for (const key of orsetElements(state.edgeAlive)) {
|
|
50
|
-
|
|
51
|
-
if (parts[0] === nodeId || parts[1] === nodeId) {
|
|
56
|
+
if (key.startsWith(srcPrefix) || key.includes(tgtInfix)) {
|
|
52
57
|
edges.push(key);
|
|
53
58
|
}
|
|
54
59
|
}
|
|
@@ -63,6 +68,30 @@ function findAttachedData(state, nodeId) {
|
|
|
63
68
|
return { edges, props, hasData: edges.length > 0 || props.length > 0 };
|
|
64
69
|
}
|
|
65
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
|
+
|
|
66
95
|
/**
|
|
67
96
|
* Fluent builder for creating WARP v5 patches with dots and observed-remove semantics.
|
|
68
97
|
*/
|
|
@@ -82,7 +111,7 @@ export class PatchBuilderV2 {
|
|
|
82
111
|
* @param {Function|null} [options.onCommitSuccess] - Callback invoked after successful commit
|
|
83
112
|
* @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
|
|
84
113
|
* @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization
|
|
85
|
-
* @param {
|
|
114
|
+
* @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for non-fatal warnings
|
|
86
115
|
*/
|
|
87
116
|
constructor({ persistence, graphName, writerId, lamport, versionVector, getCurrentState, expectedParentSha = null, onCommitSuccess = null, onDeleteWithData = 'warn', codec, logger }) {
|
|
88
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} */
|
|
@@ -130,7 +159,7 @@ export class PatchBuilderV2 {
|
|
|
130
159
|
/** @type {import('../../ports/CodecPort.js').default} */
|
|
131
160
|
this._codec = codec || defaultCodec;
|
|
132
161
|
|
|
133
|
-
/** @type {
|
|
162
|
+
/** @type {import('../../ports/LoggerPort.js').default} */
|
|
134
163
|
this._logger = logger || nullLogger;
|
|
135
164
|
|
|
136
165
|
/**
|
|
@@ -141,17 +170,23 @@ export class PatchBuilderV2 {
|
|
|
141
170
|
this._contentBlobs = [];
|
|
142
171
|
|
|
143
172
|
/**
|
|
144
|
-
*
|
|
173
|
+
* Observed operands — entities whose current state was consulted to build
|
|
174
|
+
* this patch.
|
|
175
|
+
*
|
|
176
|
+
* Semantic model per operation type:
|
|
177
|
+
* - removeNode(id): observes node `id` (reads its OR-Set dots for tombstoning)
|
|
178
|
+
* - removeEdge(from, to, label): observes the edge key
|
|
179
|
+
* - addEdge(from, to, label): observes both endpoint nodes `from` and `to`
|
|
180
|
+
* - setProperty(nodeId, key, value): observes node `nodeId`
|
|
181
|
+
* - setEdgeProperty(from, to, label, key, value): observes the edge key
|
|
182
|
+
* - cascade-generated EdgeRemove: observes the edge key
|
|
145
183
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* entity to observe its dots (OR-Set semantics). "Writes" track new data
|
|
149
|
-
* creation (adds). This distinction enables finer-grained provenance
|
|
150
|
-
* queries like "which patches wrote to X?" vs "which patches depended on X?"
|
|
184
|
+
* The public getter `.reads` and the serialized patch field `reads` retain
|
|
185
|
+
* the historical name for backward compatibility.
|
|
151
186
|
*
|
|
152
187
|
* @type {Set<string>}
|
|
153
188
|
*/
|
|
154
|
-
this.
|
|
189
|
+
this._observedOperands = new Set();
|
|
155
190
|
|
|
156
191
|
/**
|
|
157
192
|
* Nodes/edges written by this patch (for provenance tracking).
|
|
@@ -163,6 +198,15 @@ export class PatchBuilderV2 {
|
|
|
163
198
|
* @type {Set<string>}
|
|
164
199
|
*/
|
|
165
200
|
this._writes = new Set();
|
|
201
|
+
|
|
202
|
+
/** @type {boolean} Whether any edge-property ops have been added (schema 3 flag cache). */
|
|
203
|
+
this._hasEdgeProps = false;
|
|
204
|
+
|
|
205
|
+
/** @type {boolean} */
|
|
206
|
+
this._committed = false;
|
|
207
|
+
|
|
208
|
+
/** @type {boolean} */
|
|
209
|
+
this._committing = false;
|
|
166
210
|
}
|
|
167
211
|
|
|
168
212
|
/**
|
|
@@ -182,6 +226,16 @@ export class PatchBuilderV2 {
|
|
|
182
226
|
return this._snapshotState;
|
|
183
227
|
}
|
|
184
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Throws if this builder is no longer open for mutation.
|
|
231
|
+
* @private
|
|
232
|
+
*/
|
|
233
|
+
_assertNotCommitted() {
|
|
234
|
+
if (this._committed || this._committing) {
|
|
235
|
+
throw new Error('PatchBuilder already committed — create a new builder');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
185
239
|
/**
|
|
186
240
|
* Adds a node to the graph.
|
|
187
241
|
*
|
|
@@ -204,6 +258,8 @@ export class PatchBuilderV2 {
|
|
|
204
258
|
* .addEdge('user:alice', 'user:bob', 'follows');
|
|
205
259
|
*/
|
|
206
260
|
addNode(nodeId) {
|
|
261
|
+
this._assertNotCommitted();
|
|
262
|
+
_assertNoReservedBytes(nodeId, 'nodeId');
|
|
207
263
|
const dot = vvIncrement(this._vv, this._writerId);
|
|
208
264
|
this._ops.push(createNodeAddV2(nodeId, dot));
|
|
209
265
|
// Provenance: NodeAdd writes the node
|
|
@@ -238,6 +294,7 @@ export class PatchBuilderV2 {
|
|
|
238
294
|
* builder.removeNode('user:alice'); // Also removes all connected edges
|
|
239
295
|
*/
|
|
240
296
|
removeNode(nodeId) {
|
|
297
|
+
this._assertNotCommitted();
|
|
241
298
|
// Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
|
|
242
299
|
const state = this._getSnapshotState();
|
|
243
300
|
|
|
@@ -250,7 +307,7 @@ export class PatchBuilderV2 {
|
|
|
250
307
|
const edgeDots = [...orsetGetDots(state.edgeAlive, edgeKey)];
|
|
251
308
|
this._ops.push(createEdgeRemoveV2(from, to, label, edgeDots));
|
|
252
309
|
// Provenance: cascade-generated EdgeRemove reads the edge key (to observe its dots)
|
|
253
|
-
this.
|
|
310
|
+
this._observedOperands.add(edgeKey);
|
|
254
311
|
}
|
|
255
312
|
}
|
|
256
313
|
|
|
@@ -275,8 +332,7 @@ export class PatchBuilderV2 {
|
|
|
275
332
|
}
|
|
276
333
|
|
|
277
334
|
if (this._onDeleteWithData === 'warn') {
|
|
278
|
-
|
|
279
|
-
console.warn(
|
|
335
|
+
this._logger.warn(
|
|
280
336
|
`[warp] Deleting node '${nodeId}' which has attached data (${summary}). ` +
|
|
281
337
|
`Orphaned data will remain in state.`
|
|
282
338
|
);
|
|
@@ -287,7 +343,7 @@ export class PatchBuilderV2 {
|
|
|
287
343
|
const observedDots = state ? [...orsetGetDots(state.nodeAlive, nodeId)] : [];
|
|
288
344
|
this._ops.push(createNodeRemoveV2(nodeId, observedDots));
|
|
289
345
|
// Provenance: NodeRemove reads the node (to observe its dots)
|
|
290
|
-
this.
|
|
346
|
+
this._observedOperands.add(nodeId);
|
|
291
347
|
return this;
|
|
292
348
|
}
|
|
293
349
|
|
|
@@ -318,13 +374,17 @@ export class PatchBuilderV2 {
|
|
|
318
374
|
* .addEdge('user:alice', 'user:bob', 'collaborates_with');
|
|
319
375
|
*/
|
|
320
376
|
addEdge(from, to, label) {
|
|
377
|
+
this._assertNotCommitted();
|
|
378
|
+
_assertNoReservedBytes(from, 'from node ID');
|
|
379
|
+
_assertNoReservedBytes(to, 'to node ID');
|
|
380
|
+
_assertNoReservedBytes(label, 'edge label');
|
|
321
381
|
const dot = vvIncrement(this._vv, this._writerId);
|
|
322
382
|
this._ops.push(createEdgeAddV2(from, to, label, dot));
|
|
323
383
|
const edgeKey = encodeEdgeKey(from, to, label);
|
|
324
384
|
this._edgesAdded.add(edgeKey);
|
|
325
385
|
// Provenance: EdgeAdd reads both endpoint nodes, writes the edge key
|
|
326
|
-
this.
|
|
327
|
-
this.
|
|
386
|
+
this._observedOperands.add(from);
|
|
387
|
+
this._observedOperands.add(to);
|
|
328
388
|
this._writes.add(edgeKey);
|
|
329
389
|
return this;
|
|
330
390
|
}
|
|
@@ -355,13 +415,14 @@ export class PatchBuilderV2 {
|
|
|
355
415
|
* .removeNode('user:alice');
|
|
356
416
|
*/
|
|
357
417
|
removeEdge(from, to, label) {
|
|
418
|
+
this._assertNotCommitted();
|
|
358
419
|
// Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
|
|
359
420
|
const state = this._getSnapshotState();
|
|
360
421
|
const edgeKey = encodeEdgeKey(from, to, label);
|
|
361
422
|
const observedDots = state ? [...orsetGetDots(state.edgeAlive, edgeKey)] : [];
|
|
362
423
|
this._ops.push(createEdgeRemoveV2(from, to, label, observedDots));
|
|
363
424
|
// Provenance: EdgeRemove reads the edge key (to observe its dots)
|
|
364
|
-
this.
|
|
425
|
+
this._observedOperands.add(edgeKey);
|
|
365
426
|
return this;
|
|
366
427
|
}
|
|
367
428
|
|
|
@@ -395,10 +456,13 @@ export class PatchBuilderV2 {
|
|
|
395
456
|
* .setProperty('user:alice', 'age', 30);
|
|
396
457
|
*/
|
|
397
458
|
setProperty(nodeId, key, value) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
459
|
+
this._assertNotCommitted();
|
|
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
|
|
465
|
+
this._observedOperands.add(nodeId);
|
|
402
466
|
this._writes.add(nodeId);
|
|
403
467
|
return this;
|
|
404
468
|
}
|
|
@@ -441,6 +505,11 @@ export class PatchBuilderV2 {
|
|
|
441
505
|
* .setEdgeProperty('user:alice', 'user:bob', 'follows', 'public', true);
|
|
442
506
|
*/
|
|
443
507
|
setEdgeProperty(from, to, label, key, value) {
|
|
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');
|
|
444
513
|
// Validate edge exists in this patch or in current state
|
|
445
514
|
const ek = encodeEdgeKey(from, to, label);
|
|
446
515
|
if (!this._edgesAdded.has(ek)) {
|
|
@@ -450,15 +519,11 @@ export class PatchBuilderV2 {
|
|
|
450
519
|
}
|
|
451
520
|
}
|
|
452
521
|
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
|
|
459
|
-
this._ops.push(createPropSetV2(edgeNode, key, value));
|
|
460
|
-
// Provenance: setEdgeProperty reads the edge (implicit existence check) and writes the edge
|
|
461
|
-
this._reads.add(ek);
|
|
522
|
+
// Canonical EdgePropSet — lowered to legacy raw PropSet at commit time
|
|
523
|
+
this._ops.push(createEdgePropSetV2(from, to, label, key, value));
|
|
524
|
+
this._hasEdgeProps = true;
|
|
525
|
+
// Provenance: EdgePropSet reads the edge (implicit existence check) and writes the edge
|
|
526
|
+
this._observedOperands.add(ek);
|
|
462
527
|
this._writes.add(ek);
|
|
463
528
|
return this;
|
|
464
529
|
}
|
|
@@ -479,6 +544,10 @@ export class PatchBuilderV2 {
|
|
|
479
544
|
* @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
|
|
480
545
|
*/
|
|
481
546
|
async attachContent(nodeId, content) {
|
|
547
|
+
this._assertNotCommitted();
|
|
548
|
+
// Validate identifiers before writing blob to avoid orphaned blobs
|
|
549
|
+
_assertNoReservedBytes(nodeId, 'nodeId');
|
|
550
|
+
_assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
|
|
482
551
|
const oid = await this._persistence.writeBlob(content);
|
|
483
552
|
this.setProperty(nodeId, CONTENT_PROPERTY_KEY, oid);
|
|
484
553
|
this._contentBlobs.push(oid);
|
|
@@ -496,6 +565,12 @@ export class PatchBuilderV2 {
|
|
|
496
565
|
* @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
|
|
497
566
|
*/
|
|
498
567
|
async attachEdgeContent(from, to, label, content) {
|
|
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');
|
|
499
574
|
const oid = await this._persistence.writeBlob(content);
|
|
500
575
|
this.setEdgeProperty(from, to, label, CONTENT_PROPERTY_KEY, oid);
|
|
501
576
|
this._contentBlobs.push(oid);
|
|
@@ -521,14 +596,16 @@ export class PatchBuilderV2 {
|
|
|
521
596
|
* - `ops`: Array of operations (NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, PropSet)
|
|
522
597
|
*/
|
|
523
598
|
build() {
|
|
524
|
-
const schema = this.
|
|
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));
|
|
525
602
|
return createPatchV2({
|
|
526
603
|
schema,
|
|
527
604
|
writer: this._writerId,
|
|
528
605
|
lamport: this._lamport,
|
|
529
606
|
context: vvSerialize(this._vv),
|
|
530
|
-
ops:
|
|
531
|
-
reads: [...this.
|
|
607
|
+
ops: rawOps,
|
|
608
|
+
reads: [...this._observedOperands].sort(),
|
|
532
609
|
writes: [...this._writes].sort(),
|
|
533
610
|
});
|
|
534
611
|
}
|
|
@@ -537,20 +614,24 @@ export class PatchBuilderV2 {
|
|
|
537
614
|
* Commits the patch to the graph.
|
|
538
615
|
*
|
|
539
616
|
* This method performs the following steps atomically:
|
|
540
|
-
* 1.
|
|
541
|
-
* 2.
|
|
542
|
-
* 3.
|
|
543
|
-
* 4.
|
|
544
|
-
* 5.
|
|
545
|
-
* 6.
|
|
546
|
-
* 7. Creates a
|
|
547
|
-
* 8.
|
|
548
|
-
* 9.
|
|
617
|
+
* 1. Verifies this builder is still open (not committed and not in-flight)
|
|
618
|
+
* 2. Validates the patch is non-empty
|
|
619
|
+
* 3. Checks for concurrent modifications (compare-and-swap on writer ref)
|
|
620
|
+
* 4. Calculates the next lamport timestamp from the parent commit
|
|
621
|
+
* 5. Builds the PatchV2 structure with the resolved lamport
|
|
622
|
+
* 6. Encodes the patch as CBOR and writes it as a Git blob
|
|
623
|
+
* 7. Creates a Git tree containing the patch blob
|
|
624
|
+
* 8. Creates a commit with proper trailers linking to the parent
|
|
625
|
+
* 9. Updates the writer ref to point to the new commit
|
|
626
|
+
* 10. Invokes the success callback if provided (for eager re-materialization)
|
|
549
627
|
*
|
|
550
628
|
* The commit is written to the writer's patch chain at:
|
|
551
629
|
* `refs/warp/<graphName>/writers/<writerId>`
|
|
552
630
|
*
|
|
553
631
|
* @returns {Promise<string>} The commit SHA of the new patch commit
|
|
632
|
+
* @throws {Error} If this builder has already been committed, or a commit
|
|
633
|
+
* is currently in-flight on this builder.
|
|
634
|
+
* Message: `"PatchBuilder already committed — create a new builder"`
|
|
554
635
|
* @throws {Error} If the patch is empty (no operations were added).
|
|
555
636
|
* Message: `"Cannot commit empty patch: no operations added"`
|
|
556
637
|
* @throws {WriterError} If a concurrent commit was detected (another process
|
|
@@ -578,115 +659,123 @@ export class PatchBuilderV2 {
|
|
|
578
659
|
* }
|
|
579
660
|
*/
|
|
580
661
|
async commit() {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
662
|
+
this._assertNotCommitted();
|
|
663
|
+
this._committing = true;
|
|
664
|
+
try {
|
|
665
|
+
// 2. Reject empty patches
|
|
666
|
+
if (this._ops.length === 0) {
|
|
667
|
+
throw new Error('Cannot commit empty patch: no operations added');
|
|
668
|
+
}
|
|
585
669
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
670
|
+
// 3. Race detection: check if writer ref has advanced since builder creation
|
|
671
|
+
const writerRef = buildWriterRef(this._graphName, this._writerId);
|
|
672
|
+
const currentRefSha = await this._persistence.readRef(writerRef);
|
|
673
|
+
|
|
674
|
+
if (currentRefSha !== this._expectedParentSha) {
|
|
675
|
+
const err = /** @type {WriterError & { expectedSha: string|null, actualSha: string|null }} */ (new WriterError(
|
|
676
|
+
'WRITER_CAS_CONFLICT',
|
|
677
|
+
'Commit failed: writer ref was updated by another process. Re-materialize and retry.'
|
|
678
|
+
));
|
|
679
|
+
err.expectedSha = this._expectedParentSha;
|
|
680
|
+
err.actualSha = currentRefSha;
|
|
681
|
+
throw err;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// 4. Calculate lamport and parent from current ref state.
|
|
685
|
+
// Start from this._lamport (set by _nextLamport() in createPatch()), which already
|
|
686
|
+
// incorporates the globally-observed max Lamport tick via _maxObservedLamport.
|
|
687
|
+
// This ensures a first-time writer whose own chain is empty still commits at a tick
|
|
688
|
+
// above any previously-observed writer, winning LWW tiebreakers correctly.
|
|
689
|
+
let lamport = this._lamport;
|
|
690
|
+
let parentCommit = null;
|
|
691
|
+
|
|
692
|
+
if (currentRefSha) {
|
|
693
|
+
parentCommit = currentRefSha;
|
|
694
|
+
// Read the current patch commit to get its lamport timestamp and take the max,
|
|
695
|
+
// so the chain stays monotonic even if the ref advanced since createPatch().
|
|
696
|
+
const commitMessage = await this._persistence.showNode(currentRefSha);
|
|
697
|
+
const kind = detectMessageKind(commitMessage);
|
|
698
|
+
|
|
699
|
+
if (kind === 'patch') {
|
|
700
|
+
let patchInfo;
|
|
701
|
+
try {
|
|
702
|
+
patchInfo = decodePatchMessage(commitMessage);
|
|
703
|
+
} catch (err) {
|
|
704
|
+
throw new Error(
|
|
705
|
+
`Failed to parse lamport from writer ref ${writerRef}: ` +
|
|
706
|
+
`commit ${currentRefSha} has invalid patch message format`,
|
|
707
|
+
{ cause: err }
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
lamport = Math.max(this._lamport, patchInfo.lamport + 1);
|
|
711
|
+
}
|
|
712
|
+
// Non-patch ref (checkpoint, etc.): keep lamport from this._lamport
|
|
713
|
+
// (already incorporates _maxObservedLamport), matching _nextLamport() behavior.
|
|
714
|
+
}
|
|
599
715
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
716
|
+
// 5. Build PatchV2 structure with correct lamport
|
|
717
|
+
// Note: Dots were assigned using constructor lamport, but commit lamport may differ.
|
|
718
|
+
// For now, we use the calculated lamport for the patch metadata.
|
|
719
|
+
// The dots themselves are independent of patch lamport (they use VV counters).
|
|
720
|
+
const schema = this._hasEdgeProps ? 3 : 2;
|
|
721
|
+
// Lower canonical ops to raw form for the persisted patch
|
|
722
|
+
const rawOps = /** @type {import('../types/WarpTypesV2.js').RawOpV2[]} */ (this._ops.map(lowerCanonicalOp));
|
|
723
|
+
const patch = createPatchV2({
|
|
724
|
+
schema,
|
|
725
|
+
writer: this._writerId,
|
|
726
|
+
lamport,
|
|
727
|
+
context: vvSerialize(this._vv),
|
|
728
|
+
ops: rawOps,
|
|
729
|
+
reads: [...this._observedOperands].sort(),
|
|
730
|
+
writes: [...this._writes].sort(),
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// 6. Encode patch as CBOR and write as a Git blob
|
|
734
|
+
const patchCbor = this._codec.encode(patch);
|
|
735
|
+
const patchBlobOid = await this._persistence.writeBlob(/** @type {Buffer} */ (patchCbor));
|
|
736
|
+
|
|
737
|
+
// 7. Create tree with the patch blob + any content blobs (deduplicated)
|
|
738
|
+
// Format for mktree: "mode type oid\tpath"
|
|
739
|
+
const treeEntries = [`100644 blob ${patchBlobOid}\tpatch.cbor`];
|
|
740
|
+
const uniqueBlobs = [...new Set(this._contentBlobs)];
|
|
741
|
+
for (const blobOid of uniqueBlobs) {
|
|
742
|
+
treeEntries.push(`100644 blob ${blobOid}\t_content_${blobOid}`);
|
|
743
|
+
}
|
|
744
|
+
const treeOid = await this._persistence.writeTree(treeEntries);
|
|
745
|
+
|
|
746
|
+
// 8. Create commit with proper trailers linking to the parent
|
|
747
|
+
const commitMessage = encodePatchMessage({
|
|
748
|
+
graph: this._graphName,
|
|
749
|
+
writer: this._writerId,
|
|
750
|
+
lamport,
|
|
751
|
+
patchOid: patchBlobOid,
|
|
752
|
+
schema,
|
|
753
|
+
});
|
|
754
|
+
const parents = parentCommit ? [parentCommit] : [];
|
|
755
|
+
const newCommitSha = await this._persistence.commitNodeWithTree({
|
|
756
|
+
treeOid,
|
|
757
|
+
parents,
|
|
758
|
+
message: commitMessage,
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// 9. Update writer ref to point to new commit
|
|
762
|
+
await this._persistence.updateRef(writerRef, newCommitSha);
|
|
763
|
+
|
|
764
|
+
// 10. Notify success callback (updates graph's version vector + eager re-materialize)
|
|
765
|
+
if (this._onCommitSuccess) {
|
|
617
766
|
try {
|
|
618
|
-
|
|
767
|
+
await this._onCommitSuccess({ patch, sha: newCommitSha });
|
|
619
768
|
} catch (err) {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
`commit ${currentRefSha} has invalid patch message format`,
|
|
623
|
-
{ cause: err }
|
|
624
|
-
);
|
|
769
|
+
// Commit is already persisted — log but don't fail the caller.
|
|
770
|
+
this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, { error: err });
|
|
625
771
|
}
|
|
626
|
-
lamport = Math.max(this._lamport, patchInfo.lamport + 1);
|
|
627
772
|
}
|
|
628
|
-
// Non-patch ref (checkpoint, etc.): keep lamport from this._lamport
|
|
629
|
-
// (already incorporates _maxObservedLamport), matching _nextLamport() behavior.
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// 4. Build PatchV2 structure with correct lamport
|
|
633
|
-
// Note: Dots were assigned using constructor lamport, but commit lamport may differ.
|
|
634
|
-
// For now, we use the calculated lamport for the patch metadata.
|
|
635
|
-
// The dots themselves are independent of patch lamport (they use VV counters).
|
|
636
|
-
const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2;
|
|
637
|
-
// Use createPatchV2 for consistent patch construction (DRY with build())
|
|
638
|
-
const patch = createPatchV2({
|
|
639
|
-
schema,
|
|
640
|
-
writer: this._writerId,
|
|
641
|
-
lamport,
|
|
642
|
-
context: vvSerialize(this._vv),
|
|
643
|
-
ops: this._ops,
|
|
644
|
-
reads: [...this._reads].sort(),
|
|
645
|
-
writes: [...this._writes].sort(),
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
// 5. Encode patch as CBOR and write as a Git blob
|
|
649
|
-
const patchCbor = this._codec.encode(patch);
|
|
650
|
-
const patchBlobOid = await this._persistence.writeBlob(/** @type {Buffer} */ (patchCbor));
|
|
651
|
-
|
|
652
|
-
// 6. Create tree with the patch blob + any content blobs (deduplicated)
|
|
653
|
-
// Format for mktree: "mode type oid\tpath"
|
|
654
|
-
const treeEntries = [`100644 blob ${patchBlobOid}\tpatch.cbor`];
|
|
655
|
-
const uniqueBlobs = [...new Set(this._contentBlobs)];
|
|
656
|
-
for (const blobOid of uniqueBlobs) {
|
|
657
|
-
treeEntries.push(`100644 blob ${blobOid}\t_content_${blobOid}`);
|
|
658
|
-
}
|
|
659
|
-
const treeOid = await this._persistence.writeTree(treeEntries);
|
|
660
773
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
lamport,
|
|
666
|
-
patchOid: patchBlobOid,
|
|
667
|
-
schema,
|
|
668
|
-
});
|
|
669
|
-
const parents = parentCommit ? [parentCommit] : [];
|
|
670
|
-
const newCommitSha = await this._persistence.commitNodeWithTree({
|
|
671
|
-
treeOid,
|
|
672
|
-
parents,
|
|
673
|
-
message: commitMessage,
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
// 8. Update writer ref to point to new commit
|
|
677
|
-
await this._persistence.updateRef(writerRef, newCommitSha);
|
|
678
|
-
|
|
679
|
-
// 9. Notify success callback (updates graph's version vector + eager re-materialize)
|
|
680
|
-
if (this._onCommitSuccess) {
|
|
681
|
-
try {
|
|
682
|
-
await this._onCommitSuccess({ patch, sha: newCommitSha });
|
|
683
|
-
} catch (err) {
|
|
684
|
-
// Commit is already persisted — log but don't fail the caller.
|
|
685
|
-
this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, err);
|
|
686
|
-
}
|
|
774
|
+
this._committed = true;
|
|
775
|
+
return newCommitSha;
|
|
776
|
+
} finally {
|
|
777
|
+
this._committing = false;
|
|
687
778
|
}
|
|
688
|
-
|
|
689
|
-
return newCommitSha;
|
|
690
779
|
}
|
|
691
780
|
|
|
692
781
|
/**
|
|
@@ -723,22 +812,13 @@ export class PatchBuilderV2 {
|
|
|
723
812
|
}
|
|
724
813
|
|
|
725
814
|
/**
|
|
726
|
-
* Gets the set of
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
* - Endpoint nodes read via `addEdge` (implicit existence reference)
|
|
731
|
-
* - Edge keys read via `removeEdge` (to observe dots)
|
|
732
|
-
* - Nodes read via `setProperty` (implicit existence reference)
|
|
733
|
-
* - Edge keys read via `setEdgeProperty` (implicit existence reference)
|
|
734
|
-
*
|
|
735
|
-
* Note: Returns a defensive copy to prevent external mutation of internal state.
|
|
736
|
-
* The returned Set is a copy, so mutations to it do not affect the builder.
|
|
737
|
-
*
|
|
738
|
-
* @returns {ReadonlySet<string>} Copy of node IDs and encoded edge keys that were read
|
|
815
|
+
* Gets the set of observed operands (entities whose state was consulted).
|
|
816
|
+
* Retains the `reads` name for API/serialization compatibility.
|
|
817
|
+
* Internal field is `_observedOperands`.
|
|
818
|
+
* @returns {ReadonlySet<string>}
|
|
739
819
|
*/
|
|
740
820
|
get reads() {
|
|
741
|
-
return new Set(this.
|
|
821
|
+
return new Set(this._observedOperands);
|
|
742
822
|
}
|
|
743
823
|
|
|
744
824
|
/**
|
|
@@ -221,6 +221,10 @@ function deepFreeze(obj) {
|
|
|
221
221
|
/**
|
|
222
222
|
* Creates a deep clone of a value.
|
|
223
223
|
*
|
|
224
|
+
* Results are deep-frozen to prevent accidental mutation of cached state.
|
|
225
|
+
* structuredClone is preferred; JSON round-trip is the fallback for
|
|
226
|
+
* environments without structuredClone support.
|
|
227
|
+
*
|
|
224
228
|
* Attempts structuredClone first (Node 17+ / modern browsers), falls back
|
|
225
229
|
* to JSON round-trip, and returns the original value if both fail (e.g.,
|
|
226
230
|
* for values containing functions or circular references).
|
|
@@ -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);
|