@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.
Files changed (54) hide show
  1. package/README.md +8 -4
  2. package/bin/cli/commands/trust.js +37 -1
  3. package/bin/cli/infrastructure.js +14 -1
  4. package/bin/cli/schemas.js +4 -4
  5. package/bin/warp-graph.js +9 -2
  6. package/index.d.ts +18 -2
  7. package/package.json +1 -1
  8. package/src/domain/WarpGraph.js +4 -1
  9. package/src/domain/crdt/Dot.js +5 -0
  10. package/src/domain/crdt/LWW.js +3 -1
  11. package/src/domain/crdt/ORSet.js +63 -27
  12. package/src/domain/crdt/VersionVector.js +12 -0
  13. package/src/domain/errors/PatchError.js +27 -0
  14. package/src/domain/errors/StorageError.js +8 -0
  15. package/src/domain/errors/SyncError.js +1 -0
  16. package/src/domain/errors/TrustError.js +2 -0
  17. package/src/domain/errors/WriterError.js +5 -0
  18. package/src/domain/errors/index.js +1 -0
  19. package/src/domain/services/AuditVerifierService.js +32 -2
  20. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  21. package/src/domain/services/CheckpointService.js +12 -8
  22. package/src/domain/services/Frontier.js +18 -0
  23. package/src/domain/services/GCPolicy.js +25 -4
  24. package/src/domain/services/GraphTraversal.js +11 -50
  25. package/src/domain/services/HttpSyncServer.js +18 -29
  26. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  27. package/src/domain/services/JoinReducer.js +164 -31
  28. package/src/domain/services/MaterializedViewService.js +13 -2
  29. package/src/domain/services/PatchBuilderV2.js +210 -145
  30. package/src/domain/services/QueryBuilder.js +67 -30
  31. package/src/domain/services/SyncController.js +62 -18
  32. package/src/domain/services/SyncPayloadSchema.js +236 -0
  33. package/src/domain/services/SyncProtocol.js +102 -40
  34. package/src/domain/services/SyncTrustGate.js +146 -0
  35. package/src/domain/services/TranslationCost.js +2 -2
  36. package/src/domain/trust/TrustRecordService.js +161 -34
  37. package/src/domain/utils/CachedValue.js +34 -5
  38. package/src/domain/utils/EventId.js +4 -1
  39. package/src/domain/utils/LRUCache.js +3 -1
  40. package/src/domain/utils/RefLayout.js +4 -0
  41. package/src/domain/utils/canonicalStringify.js +48 -18
  42. package/src/domain/utils/matchGlob.js +7 -0
  43. package/src/domain/warp/PatchSession.js +30 -24
  44. package/src/domain/warp/Writer.js +12 -5
  45. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  46. package/src/domain/warp/checkpoint.methods.js +102 -16
  47. package/src/domain/warp/materialize.methods.js +47 -5
  48. package/src/domain/warp/materializeAdvanced.methods.js +52 -10
  49. package/src/domain/warp/patch.methods.js +24 -8
  50. package/src/domain/warp/query.methods.js +4 -4
  51. package/src/domain/warp/subscribe.methods.js +11 -19
  52. package/src/infrastructure/adapters/GitGraphAdapter.js +57 -54
  53. package/src/infrastructure/codecs/CborCodec.js +2 -0
  54. 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
- // Check if any of the observed dots are currently non-tombstoned
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 op.observedDots) {
196
- if (!orset.tombstones.has(encodedDot)) {
197
- // This dot exists and is not yet tombstoned, so the remove is effective
198
- // Check if any entry actually has this dot
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 op.observedDots) {
261
- if (!orset.tombstones.has(encodedDot)) {
262
- for (const dots of orset.entries.values()) {
263
- if (dots.has(encodedDot)) {
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 by joining with an empty set,
849
- * which creates new data structures with identical contents.
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: orsetJoin(state.nodeAlive, createORSet()),
857
- edgeAlive: orsetJoin(state.edgeAlive, createORSet()),
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('props_')) {
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('props_')) {
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
  }