@git-stunts/git-warp 12.1.0 → 12.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/bin/cli/commands/trust.js +37 -1
- package/bin/cli/infrastructure.js +14 -1
- package/bin/cli/schemas.js +4 -4
- package/bin/warp-graph.js +9 -2
- package/index.d.ts +18 -2
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +4 -1
- package/src/domain/crdt/Dot.js +5 -0
- package/src/domain/crdt/LWW.js +3 -1
- package/src/domain/crdt/ORSet.js +63 -27
- 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/SyncError.js +1 -0
- package/src/domain/errors/TrustError.js +2 -0
- package/src/domain/errors/WriterError.js +5 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditVerifierService.js +32 -2
- package/src/domain/services/BitmapIndexBuilder.js +14 -9
- package/src/domain/services/CheckpointService.js +12 -8
- package/src/domain/services/Frontier.js +18 -0
- package/src/domain/services/GCPolicy.js +25 -4
- package/src/domain/services/GraphTraversal.js +11 -50
- package/src/domain/services/HttpSyncServer.js +18 -29
- package/src/domain/services/IncrementalIndexUpdater.js +179 -36
- package/src/domain/services/JoinReducer.js +164 -31
- package/src/domain/services/MaterializedViewService.js +13 -2
- package/src/domain/services/PatchBuilderV2.js +210 -145
- package/src/domain/services/QueryBuilder.js +67 -30
- package/src/domain/services/SyncController.js +62 -18
- package/src/domain/services/SyncPayloadSchema.js +236 -0
- package/src/domain/services/SyncProtocol.js +102 -40
- package/src/domain/services/SyncTrustGate.js +146 -0
- package/src/domain/services/TranslationCost.js +2 -2
- package/src/domain/trust/TrustRecordService.js +161 -34
- 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/matchGlob.js +7 -0
- package/src/domain/warp/PatchSession.js +30 -24
- package/src/domain/warp/Writer.js +12 -5
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/checkpoint.methods.js +102 -16
- package/src/domain/warp/materialize.methods.js +47 -5
- package/src/domain/warp/materializeAdvanced.methods.js +52 -10
- package/src/domain/warp/patch.methods.js +24 -8
- package/src/domain/warp/query.methods.js +4 -4
- package/src/domain/warp/subscribe.methods.js +11 -19
- package/src/infrastructure/adapters/GitGraphAdapter.js +57 -54
- package/src/infrastructure/codecs/CborCodec.js +2 -0
- package/src/domain/utils/fnv1a.js +0 -20
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* }
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { createORSet, orsetAdd, orsetRemove, orsetJoin, orsetContains } from '../crdt/ORSet.js';
|
|
12
|
+
import { createORSet, orsetAdd, orsetRemove, orsetJoin, orsetContains, orsetClone } from '../crdt/ORSet.js';
|
|
13
13
|
import { createVersionVector, vvMerge, vvClone, vvDeserialize } from '../crdt/VersionVector.js';
|
|
14
14
|
import { lwwSet, lwwMax } from '../crdt/LWW.js';
|
|
15
15
|
import { createEventId, compareEventIds } from '../utils/EventId.js';
|
|
@@ -17,6 +17,7 @@ import { createTickReceipt, OP_TYPES } from '../types/TickReceipt.js';
|
|
|
17
17
|
import { encodeDot } from '../crdt/Dot.js';
|
|
18
18
|
import { encodeEdgeKey, decodeEdgeKey, encodePropKey } from './KeyCodec.js';
|
|
19
19
|
import { createEmptyDiff, mergeDiffs } from '../types/PatchDiff.js';
|
|
20
|
+
import PatchError from '../errors/PatchError.js';
|
|
20
21
|
|
|
21
22
|
// Re-export key codec functions for backward compatibility
|
|
22
23
|
export {
|
|
@@ -32,7 +33,11 @@ export {
|
|
|
32
33
|
* @property {import('../crdt/ORSet.js').ORSet} edgeAlive - ORSet of alive edges
|
|
33
34
|
* @property {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} prop - Properties with LWW
|
|
34
35
|
* @property {import('../crdt/VersionVector.js').VersionVector} observedFrontier - Observed version vector
|
|
35
|
-
* @property {Map<string, import('../utils/EventId.js').EventId>} edgeBirthEvent - EdgeKey → EventId of most recent EdgeAdd (for clean-slate prop visibility)
|
|
36
|
+
* @property {Map<string, import('../utils/EventId.js').EventId>} edgeBirthEvent - EdgeKey → EventId of most recent EdgeAdd (for clean-slate prop visibility).
|
|
37
|
+
* Always present at runtime (initialized to empty Map by createEmptyStateV5 and
|
|
38
|
+
* deserializeFullStateV5). Edge birth events were introduced in a later schema
|
|
39
|
+
* version; older checkpoints serialize without this field, but the deserializer
|
|
40
|
+
* always produces an empty Map for them.
|
|
36
41
|
*/
|
|
37
42
|
|
|
38
43
|
/**
|
|
@@ -86,7 +91,136 @@ export function createEmptyStateV5() {
|
|
|
86
91
|
* @param {import('../utils/EventId.js').EventId} eventId - Event ID for causality tracking
|
|
87
92
|
* @returns {void}
|
|
88
93
|
*/
|
|
94
|
+
/**
|
|
95
|
+
* Known V2 operation types. Used for forward-compatibility validation.
|
|
96
|
+
* @type {ReadonlySet<string>}
|
|
97
|
+
*/
|
|
98
|
+
const KNOWN_OPS = new Set(['NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove', 'PropSet', 'BlobValue']);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validates that an operation has a known type.
|
|
102
|
+
*
|
|
103
|
+
* @param {{ type: string }} op
|
|
104
|
+
* @returns {boolean} True if the op type is in KNOWN_OPS
|
|
105
|
+
*/
|
|
106
|
+
export function isKnownOp(op) {
|
|
107
|
+
return op && typeof op.type === 'string' && KNOWN_OPS.has(op.type);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Asserts that `op[field]` is a string. Throws PatchError if not.
|
|
112
|
+
* @param {Record<string, unknown>} op
|
|
113
|
+
* @param {string} field
|
|
114
|
+
*/
|
|
115
|
+
function requireString(op, field) {
|
|
116
|
+
if (typeof op[field] !== 'string') {
|
|
117
|
+
throw new PatchError(
|
|
118
|
+
`${op.type} op requires '${field}' to be a string, got ${typeof op[field]}`,
|
|
119
|
+
{ context: { opType: op.type, field, actual: typeof op[field] } },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Asserts that `op[field]` is iterable (Array, Set, or any Symbol.iterator).
|
|
126
|
+
* @param {Record<string, unknown>} op
|
|
127
|
+
* @param {string} field
|
|
128
|
+
*/
|
|
129
|
+
function requireIterable(op, field) {
|
|
130
|
+
const val = op[field];
|
|
131
|
+
if (
|
|
132
|
+
val === null ||
|
|
133
|
+
val === undefined ||
|
|
134
|
+
typeof val !== 'object' ||
|
|
135
|
+
typeof /** @type {Iterable<unknown>} */ (val)[Symbol.iterator] !== 'function'
|
|
136
|
+
) {
|
|
137
|
+
throw new PatchError(
|
|
138
|
+
`${op.type} op requires '${field}' to be iterable, got ${typeof val}`,
|
|
139
|
+
{ context: { opType: op.type, field, actual: typeof val } },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Asserts that `op.dot` is an object with writerId (string) and counter (number).
|
|
146
|
+
* @param {Record<string, unknown>} op
|
|
147
|
+
*/
|
|
148
|
+
function requireDot(op) {
|
|
149
|
+
const { dot } = op;
|
|
150
|
+
if (!dot || typeof dot !== 'object') {
|
|
151
|
+
throw new PatchError(
|
|
152
|
+
`${op.type} op requires 'dot' to be an object, got ${typeof dot}`,
|
|
153
|
+
{ context: { opType: op.type, field: 'dot', actual: typeof dot } },
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const d = /** @type {Record<string, unknown>} */ (dot);
|
|
157
|
+
if (typeof d.writerId !== 'string') {
|
|
158
|
+
throw new PatchError(
|
|
159
|
+
`${op.type} op requires 'dot.writerId' to be a string, got ${typeof d.writerId}`,
|
|
160
|
+
{ context: { opType: op.type, field: 'dot.writerId', actual: typeof d.writerId } },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (typeof d.counter !== 'number') {
|
|
164
|
+
throw new PatchError(
|
|
165
|
+
`${op.type} op requires 'dot.counter' to be a number, got ${typeof d.counter}`,
|
|
166
|
+
{ context: { opType: op.type, field: 'dot.counter', actual: typeof d.counter } },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Validates that an operation has the required fields for its type.
|
|
173
|
+
* Throws PatchError for malformed ops. Unknown/BlobValue types pass through
|
|
174
|
+
* for forward compatibility.
|
|
175
|
+
*
|
|
176
|
+
* @param {Record<string, unknown>} op
|
|
177
|
+
*/
|
|
178
|
+
function validateOp(op) {
|
|
179
|
+
if (!op || typeof op.type !== 'string') {
|
|
180
|
+
throw new PatchError(
|
|
181
|
+
`Invalid op: expected object with string 'type', got ${op === null || op === undefined ? String(op) : typeof op.type}`,
|
|
182
|
+
{ context: { actual: op === null || op === undefined ? String(op) : typeof op.type } },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
switch (op.type) {
|
|
187
|
+
case 'NodeAdd':
|
|
188
|
+
requireString(op, 'node');
|
|
189
|
+
requireDot(op);
|
|
190
|
+
break;
|
|
191
|
+
case 'NodeRemove':
|
|
192
|
+
// node is optional (informational for receipts); observedDots is required for mutation
|
|
193
|
+
requireIterable(op, 'observedDots');
|
|
194
|
+
break;
|
|
195
|
+
case 'EdgeAdd':
|
|
196
|
+
requireString(op, 'from');
|
|
197
|
+
requireString(op, 'to');
|
|
198
|
+
requireString(op, 'label');
|
|
199
|
+
requireDot(op);
|
|
200
|
+
break;
|
|
201
|
+
case 'EdgeRemove':
|
|
202
|
+
// from/to/label are optional (informational for receipts); observedDots is required for mutation
|
|
203
|
+
requireIterable(op, 'observedDots');
|
|
204
|
+
break;
|
|
205
|
+
case 'PropSet':
|
|
206
|
+
requireString(op, 'node');
|
|
207
|
+
requireString(op, 'key');
|
|
208
|
+
break;
|
|
209
|
+
default:
|
|
210
|
+
// BlobValue and unknown types: no validation (forward-compat)
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Applies a single V2 operation to the given CRDT state.
|
|
217
|
+
*
|
|
218
|
+
* @param {WarpStateV5} state - The mutable CRDT state to update
|
|
219
|
+
* @param {{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}} op - The operation to apply
|
|
220
|
+
* @param {import('../utils/EventId.js').EventId} eventId - The event ID for LWW ordering
|
|
221
|
+
*/
|
|
89
222
|
export function applyOpV2(state, op, eventId) {
|
|
223
|
+
validateOp(/** @type {Record<string, unknown>} */ (op));
|
|
90
224
|
switch (op.type) {
|
|
91
225
|
case 'NodeAdd':
|
|
92
226
|
orsetAdd(state.nodeAlive, /** @type {string} */ (op.node), /** @type {import('../crdt/Dot.js').Dot} */ (op.dot));
|
|
@@ -190,21 +324,18 @@ function nodeAddOutcome(orset, op) {
|
|
|
190
324
|
* @returns {{target: string, result: 'applied'|'redundant'}} Outcome with node ID (or '*') as target
|
|
191
325
|
*/
|
|
192
326
|
function nodeRemoveOutcome(orset, op) {
|
|
193
|
-
//
|
|
327
|
+
// Build a reverse index (dot → elementId) for the observed dots to avoid
|
|
328
|
+
// O(|observedDots| × |entries|) scanning. Same pattern as buildDotToElement.
|
|
329
|
+
const targetDots = op.observedDots instanceof Set
|
|
330
|
+
? op.observedDots
|
|
331
|
+
: new Set(op.observedDots);
|
|
332
|
+
const dotToElement = buildDotToElement(orset, targetDots);
|
|
333
|
+
|
|
194
334
|
let effective = false;
|
|
195
|
-
for (const encodedDot of
|
|
196
|
-
if (!orset.tombstones.has(encodedDot)) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
for (const dots of orset.entries.values()) {
|
|
200
|
-
if (dots.has(encodedDot)) {
|
|
201
|
-
effective = true;
|
|
202
|
-
break;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (effective) {
|
|
206
|
-
break;
|
|
207
|
-
}
|
|
335
|
+
for (const encodedDot of targetDots) {
|
|
336
|
+
if (!orset.tombstones.has(encodedDot) && dotToElement.has(encodedDot)) {
|
|
337
|
+
effective = true;
|
|
338
|
+
break;
|
|
208
339
|
}
|
|
209
340
|
}
|
|
210
341
|
const target = op.node || '*';
|
|
@@ -256,18 +387,18 @@ function edgeAddOutcome(orset, op, edgeKey) {
|
|
|
256
387
|
* @returns {{target: string, result: 'applied'|'redundant'}} Outcome with encoded edge key (or '*') as target
|
|
257
388
|
*/
|
|
258
389
|
function edgeRemoveOutcome(orset, op) {
|
|
390
|
+
// Build a reverse index (dot → elementId) for the observed dots to avoid
|
|
391
|
+
// O(|observedDots| × |entries|) scanning. Same pattern as buildDotToElement.
|
|
392
|
+
const targetDots = op.observedDots instanceof Set
|
|
393
|
+
? op.observedDots
|
|
394
|
+
: new Set(op.observedDots);
|
|
395
|
+
const dotToElement = buildDotToElement(orset, targetDots);
|
|
396
|
+
|
|
259
397
|
let effective = false;
|
|
260
|
-
for (const encodedDot of
|
|
261
|
-
if (!orset.tombstones.has(encodedDot)) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
effective = true;
|
|
265
|
-
break;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
if (effective) {
|
|
269
|
-
break;
|
|
270
|
-
}
|
|
398
|
+
for (const encodedDot of targetDots) {
|
|
399
|
+
if (!orset.tombstones.has(encodedDot) && dotToElement.has(encodedDot)) {
|
|
400
|
+
effective = true;
|
|
401
|
+
break;
|
|
271
402
|
}
|
|
272
403
|
}
|
|
273
404
|
// Construct target from op fields if available
|
|
@@ -580,6 +711,7 @@ export function applyWithDiff(state, patch, patchSha) {
|
|
|
580
711
|
|
|
581
712
|
for (let i = 0; i < patch.ops.length; i++) {
|
|
582
713
|
const op = patch.ops[i];
|
|
714
|
+
validateOp(/** @type {Record<string, unknown>} */ (op));
|
|
583
715
|
const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
|
|
584
716
|
const typedOp = /** @type {import('../types/WarpTypesV2.js').OpV2} */ (op);
|
|
585
717
|
const before = snapshotBeforeOp(state, typedOp);
|
|
@@ -608,6 +740,7 @@ export function applyWithReceipt(state, patch, patchSha) {
|
|
|
608
740
|
const opResults = [];
|
|
609
741
|
for (let i = 0; i < patch.ops.length; i++) {
|
|
610
742
|
const op = patch.ops[i];
|
|
743
|
+
validateOp(/** @type {Record<string, unknown>} */ (op));
|
|
611
744
|
const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
|
|
612
745
|
|
|
613
746
|
// Determine outcome BEFORE applying the op (state is pre-op)
|
|
@@ -845,16 +978,16 @@ export function reduceV5(patches, initialState, options) {
|
|
|
845
978
|
* - Creating a branch point for speculative execution
|
|
846
979
|
* - Ensuring immutability when passing state across API boundaries
|
|
847
980
|
*
|
|
848
|
-
* **Implementation Note**: OR-Sets are cloned
|
|
849
|
-
*
|
|
981
|
+
* **Implementation Note**: OR-Sets are cloned via `orsetClone()` which
|
|
982
|
+
* directly copies entries and tombstones without merge logic overhead.
|
|
850
983
|
*
|
|
851
984
|
* @param {WarpStateV5} state - The state to clone
|
|
852
985
|
* @returns {WarpStateV5} A new state with identical contents but independent data structures
|
|
853
986
|
*/
|
|
854
987
|
export function cloneStateV5(state) {
|
|
855
988
|
return {
|
|
856
|
-
nodeAlive:
|
|
857
|
-
edgeAlive:
|
|
989
|
+
nodeAlive: orsetClone(state.nodeAlive),
|
|
990
|
+
edgeAlive: orsetClone(state.edgeAlive),
|
|
858
991
|
prop: new Map(state.prop),
|
|
859
992
|
observedFrontier: vvClone(state.observedFrontier),
|
|
860
993
|
edgeBirthEvent: new Map(state.edgeBirthEvent || []),
|
|
@@ -21,6 +21,9 @@ import IncrementalIndexUpdater from './IncrementalIndexUpdater.js';
|
|
|
21
21
|
import { orsetElements, orsetContains } from '../crdt/ORSet.js';
|
|
22
22
|
import { decodeEdgeKey } from './KeyCodec.js';
|
|
23
23
|
|
|
24
|
+
/** Prefix for property shard paths in the index tree. */
|
|
25
|
+
const PROPS_PREFIX = 'props_';
|
|
26
|
+
|
|
24
27
|
/**
|
|
25
28
|
* @typedef {import('./BitmapNeighborProvider.js').LogicalIndex} LogicalIndex
|
|
26
29
|
*/
|
|
@@ -66,7 +69,7 @@ function buildInMemoryPropertyReader(tree, codec) {
|
|
|
66
69
|
/** @type {Record<string, string>} */
|
|
67
70
|
const propShardOids = {};
|
|
68
71
|
for (const path of Object.keys(tree)) {
|
|
69
|
-
if (path.startsWith(
|
|
72
|
+
if (path.startsWith(PROPS_PREFIX)) {
|
|
70
73
|
propShardOids[path] = path;
|
|
71
74
|
}
|
|
72
75
|
}
|
|
@@ -93,7 +96,7 @@ function partitionShardOids(shardOids) {
|
|
|
93
96
|
const propOids = {};
|
|
94
97
|
|
|
95
98
|
for (const [path, oid] of Object.entries(shardOids)) {
|
|
96
|
-
if (path.startsWith(
|
|
99
|
+
if (path.startsWith(PROPS_PREFIX)) {
|
|
97
100
|
propOids[path] = oid;
|
|
98
101
|
} else {
|
|
99
102
|
indexOids[path] = oid;
|
|
@@ -105,6 +108,10 @@ function partitionShardOids(shardOids) {
|
|
|
105
108
|
/**
|
|
106
109
|
* Mulberry32 PRNG — deterministic 32-bit generator from a seed.
|
|
107
110
|
*
|
|
111
|
+
* mulberry32 is a fast 32-bit PRNG by Tommy Ettinger. The magic constants
|
|
112
|
+
* (0x6D2B79F5, shifts 15/13/16) are part of the published algorithm.
|
|
113
|
+
* See: https://gist.github.com/tommyettinger/46a874533244883189143505d203312c
|
|
114
|
+
*
|
|
108
115
|
* @param {number} seed
|
|
109
116
|
* @returns {() => number} Returns values in [0, 1)
|
|
110
117
|
*/
|
|
@@ -134,6 +141,10 @@ function sampleNodes(allNodes, sampleRate, seed) {
|
|
|
134
141
|
}
|
|
135
142
|
const rng = mulberry32(seed);
|
|
136
143
|
const sampled = allNodes.filter(() => rng() < sampleRate);
|
|
144
|
+
// When the initial sample is empty (e.g., graph has fewer nodes than
|
|
145
|
+
// sample size), we fall back to using all available nodes. This changes
|
|
146
|
+
// the distribution but is acceptable since the sample is only used for
|
|
147
|
+
// layout heuristics.
|
|
137
148
|
if (sampled.length === 0) {
|
|
138
149
|
sampled.push(allNodes[Math.floor(rng() * allNodes.length)]);
|
|
139
150
|
}
|