@git-stunts/git-warp 11.5.1 → 12.1.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.
Files changed (49) hide show
  1. package/README.md +137 -10
  2. package/bin/cli/commands/registry.js +4 -0
  3. package/bin/cli/commands/reindex.js +41 -0
  4. package/bin/cli/commands/verify-index.js +59 -0
  5. package/bin/cli/infrastructure.js +7 -2
  6. package/bin/cli/schemas.js +19 -0
  7. package/bin/cli/types.js +2 -0
  8. package/index.d.ts +52 -15
  9. package/package.json +3 -2
  10. package/src/domain/WarpGraph.js +40 -0
  11. package/src/domain/errors/ShardIdOverflowError.js +28 -0
  12. package/src/domain/errors/index.js +1 -0
  13. package/src/domain/services/AdjacencyNeighborProvider.js +140 -0
  14. package/src/domain/services/BitmapNeighborProvider.js +178 -0
  15. package/src/domain/services/CheckpointMessageCodec.js +3 -3
  16. package/src/domain/services/CheckpointService.js +77 -12
  17. package/src/domain/services/GraphTraversal.js +1239 -0
  18. package/src/domain/services/IncrementalIndexUpdater.js +765 -0
  19. package/src/domain/services/JoinReducer.js +233 -5
  20. package/src/domain/services/LogicalBitmapIndexBuilder.js +323 -0
  21. package/src/domain/services/LogicalIndexBuildService.js +108 -0
  22. package/src/domain/services/LogicalIndexReader.js +315 -0
  23. package/src/domain/services/LogicalTraversal.js +321 -202
  24. package/src/domain/services/MaterializedViewService.js +379 -0
  25. package/src/domain/services/ObserverView.js +132 -69
  26. package/src/domain/services/PatchBuilderV2.js +3 -3
  27. package/src/domain/services/PropertyIndexBuilder.js +64 -0
  28. package/src/domain/services/PropertyIndexReader.js +111 -0
  29. package/src/domain/services/QueryBuilder.js +15 -44
  30. package/src/domain/services/TemporalQuery.js +128 -14
  31. package/src/domain/services/TranslationCost.js +8 -24
  32. package/src/domain/types/PatchDiff.js +90 -0
  33. package/src/domain/types/WarpTypesV2.js +4 -4
  34. package/src/domain/utils/MinHeap.js +45 -17
  35. package/src/domain/utils/canonicalCbor.js +36 -0
  36. package/src/domain/utils/fnv1a.js +20 -0
  37. package/src/domain/utils/matchGlob.js +51 -0
  38. package/src/domain/utils/roaring.js +14 -3
  39. package/src/domain/utils/shardKey.js +40 -0
  40. package/src/domain/utils/toBytes.js +17 -0
  41. package/src/domain/warp/_wiredMethods.d.ts +7 -1
  42. package/src/domain/warp/checkpoint.methods.js +21 -5
  43. package/src/domain/warp/materialize.methods.js +17 -5
  44. package/src/domain/warp/materializeAdvanced.methods.js +142 -3
  45. package/src/domain/warp/query.methods.js +83 -15
  46. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +26 -5
  47. package/src/ports/BlobPort.js +1 -1
  48. package/src/ports/NeighborProviderPort.js +59 -0
  49. package/src/ports/SeekCachePort.js +4 -3
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Builds property index shards from node properties.
3
+ *
4
+ * Produces `props_XX.cbor` shards keyed by shard key, where each
5
+ * shard maps nodeId → { key: value, ... }.
6
+ *
7
+ * @module domain/services/PropertyIndexBuilder
8
+ */
9
+
10
+ import defaultCodec from '../utils/defaultCodec.js';
11
+ import computeShardKey from '../utils/shardKey.js';
12
+
13
+ export default class PropertyIndexBuilder {
14
+ /**
15
+ * @param {Object} [options]
16
+ * @param {import('../../ports/CodecPort.js').default} [options.codec]
17
+ */
18
+ constructor({ codec } = {}) {
19
+ this._codec = codec || defaultCodec;
20
+ /** @type {Map<string, Map<string, Record<string, unknown>>>} shardKey → (nodeId → props) */
21
+ this._shards = new Map();
22
+ }
23
+
24
+ /**
25
+ * Adds a property for a node.
26
+ *
27
+ * @param {string} nodeId
28
+ * @param {string} key
29
+ * @param {unknown} value
30
+ */
31
+ addProperty(nodeId, key, value) {
32
+ const shardKey = computeShardKey(nodeId);
33
+ let shard = this._shards.get(shardKey);
34
+ if (!shard) {
35
+ shard = new Map();
36
+ this._shards.set(shardKey, shard);
37
+ }
38
+ let nodeProps = shard.get(nodeId);
39
+ if (!nodeProps) {
40
+ nodeProps = /** @type {Record<string, unknown>} */ (Object.create(null));
41
+ shard.set(nodeId, nodeProps);
42
+ }
43
+ /** @type {Record<string, unknown>} */ (nodeProps)[key] = value;
44
+ }
45
+
46
+ /**
47
+ * Serializes all property shards.
48
+ *
49
+ * @returns {Record<string, Uint8Array>}
50
+ */
51
+ serialize() {
52
+ /** @type {Record<string, Uint8Array>} */
53
+ const tree = {};
54
+ for (const [shardKey, shard] of this._shards) {
55
+ // Encode as array of [nodeId, props] pairs to avoid __proto__ key issues
56
+ // when CBOR decodes into plain objects. Sorted by nodeId for determinism.
57
+ const entries = [...shard.entries()]
58
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
59
+ .map(([nodeId, props]) => [nodeId, props]);
60
+ tree[`props_${shardKey}.cbor`] = this._codec.encode(entries).slice();
61
+ }
62
+ return tree;
63
+ }
64
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Reads property index shards lazily with LRU caching.
3
+ *
4
+ * Loads `props_XX.cbor` shards on demand via IndexStoragePort.readBlob.
5
+ *
6
+ * @module domain/services/PropertyIndexReader
7
+ */
8
+
9
+ import defaultCodec from '../utils/defaultCodec.js';
10
+ import computeShardKey from '../utils/shardKey.js';
11
+ import LRUCache from '../utils/LRUCache.js';
12
+
13
+ export default class PropertyIndexReader {
14
+ /**
15
+ * @param {Object} [options]
16
+ * @param {import('../../ports/IndexStoragePort.js').default} [options.storage]
17
+ * @param {import('../../ports/CodecPort.js').default} [options.codec]
18
+ * @param {number} [options.maxCachedShards=64]
19
+ */
20
+ constructor({ storage, codec, maxCachedShards = 64 } = /** @type {{ storage?: import('../../ports/IndexStoragePort.js').default, codec?: import('../../ports/CodecPort.js').default, maxCachedShards?: number }} */ ({})) {
21
+ this._storage = storage;
22
+ this._codec = codec || defaultCodec;
23
+ /** @type {Map<string, string>} path → oid */
24
+ this._shardOids = new Map();
25
+ /** @type {LRUCache<string, Record<string, Record<string, unknown>>>} */
26
+ this._cache = new LRUCache(maxCachedShards);
27
+ }
28
+
29
+ /**
30
+ * Configures OID mappings for lazy loading.
31
+ *
32
+ * @param {Record<string, string>} shardOids - path → blob OID
33
+ */
34
+ setup(shardOids) {
35
+ this._shardOids = new Map(Object.entries(shardOids));
36
+ this._cache.clear();
37
+ }
38
+
39
+ /**
40
+ * Returns all properties for a node, or null if not found.
41
+ *
42
+ * @param {string} nodeId
43
+ * @returns {Promise<Record<string, unknown>|null>}
44
+ */
45
+ async getNodeProps(nodeId) {
46
+ const shard = await this._loadShard(nodeId);
47
+ if (!shard) {
48
+ return null;
49
+ }
50
+ return shard[nodeId] ?? null;
51
+ }
52
+
53
+ /**
54
+ * Returns a single property value, or undefined.
55
+ *
56
+ * @param {string} nodeId
57
+ * @param {string} key
58
+ * @returns {Promise<unknown|undefined>}
59
+ */
60
+ async getProperty(nodeId, key) {
61
+ const props = await this.getNodeProps(nodeId);
62
+ if (!props) {
63
+ return undefined;
64
+ }
65
+ return props[key];
66
+ }
67
+
68
+ /**
69
+ * @param {string} nodeId
70
+ * @returns {Promise<Record<string, Record<string, unknown>>|null>}
71
+ * @private
72
+ */
73
+ async _loadShard(nodeId) {
74
+ const shardKey = computeShardKey(nodeId);
75
+ const path = `props_${shardKey}.cbor`;
76
+
77
+ const cached = this._cache.get(path);
78
+ if (cached !== undefined) {
79
+ return cached;
80
+ }
81
+
82
+ const oid = this._shardOids.get(path);
83
+ if (!oid) {
84
+ return null;
85
+ }
86
+
87
+ if (!this._storage) {
88
+ return null;
89
+ }
90
+
91
+ const buffer = await /** @type {{ readBlob(oid: string): Promise<Buffer|Uint8Array|undefined|null> }} */ (this._storage).readBlob(oid);
92
+ if (buffer === null || buffer === undefined) {
93
+ throw new Error(`PropertyIndexReader: missing blob for OID '${oid}' (${path})`);
94
+ }
95
+ const decoded = this._codec.decode(buffer);
96
+
97
+ // Shards are stored as array of [nodeId, props] pairs (proto-safe)
98
+ if (!Array.isArray(decoded)) {
99
+ const shape = decoded === null ? 'null' : typeof decoded;
100
+ throw new Error(`PropertyIndexReader: invalid shard format for '${path}' (expected array, got ${shape})`);
101
+ }
102
+
103
+ /** @type {Record<string, Record<string, unknown>>} */
104
+ const data = Object.create(null);
105
+ for (const [nid, props] of /** @type {Array<[string, Record<string, unknown>]>} */ (decoded)) {
106
+ data[nid] = props;
107
+ }
108
+ this._cache.set(path, data);
109
+ return data;
110
+ }
111
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import QueryError from '../errors/QueryError.js';
8
+ import { matchGlob } from '../utils/matchGlob.js';
8
9
 
9
10
  const DEFAULT_PATTERN = '*';
10
11
 
@@ -48,15 +49,18 @@ const DEFAULT_PATTERN = '*';
48
49
  */
49
50
 
50
51
  /**
51
- * Asserts that a match pattern is a string.
52
+ * Asserts that a match pattern is a string or array of strings.
52
53
  *
53
54
  * @param {unknown} pattern - The pattern to validate
54
- * @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
55
+ * @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
55
56
  * @private
56
57
  */
57
58
  function assertMatchPattern(pattern) {
58
- if (typeof pattern !== 'string') {
59
- throw new QueryError('match() expects a string pattern', {
59
+ const isString = typeof pattern === 'string';
60
+ const isStringArray = Array.isArray(pattern) && pattern.every((p) => typeof p === 'string');
61
+
62
+ if (!isString && !isStringArray) {
63
+ throw new QueryError('match() expects a string pattern or array of string patterns', {
60
64
  code: 'E_QUERY_MATCH_TYPE',
61
65
  context: { receivedType: typeof pattern },
62
66
  });
@@ -165,41 +169,6 @@ function sortIds(ids) {
165
169
  return [...ids].sort();
166
170
  }
167
171
 
168
- /**
169
- * Escapes special regex characters in a string so it can be used as a literal match.
170
- *
171
- * @param {string} value - The string to escape
172
- * @returns {string} The escaped string safe for use in a RegExp
173
- * @private
174
- */
175
- function escapeRegex(value) {
176
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
177
- }
178
-
179
- /**
180
- * Tests whether a node ID matches a glob-style pattern.
181
- *
182
- * Supports:
183
- * - `*` as the default pattern, matching all node IDs
184
- * - Wildcard `*` anywhere in the pattern, matching zero or more characters
185
- * - Literal match when pattern contains no wildcards
186
- *
187
- * @param {string} nodeId - The node ID to test
188
- * @param {string} pattern - The glob pattern (e.g., "user:*", "*:admin", "*")
189
- * @returns {boolean} True if the node ID matches the pattern
190
- * @private
191
- */
192
- function matchesPattern(nodeId, pattern) {
193
- if (pattern === DEFAULT_PATTERN) {
194
- return true;
195
- }
196
- if (pattern.includes('*')) {
197
- const regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
198
- return regex.test(nodeId);
199
- }
200
- return nodeId === pattern;
201
- }
202
-
203
172
  /**
204
173
  * Recursively freezes an object and all nested objects/arrays.
205
174
  *
@@ -494,7 +463,7 @@ export default class QueryBuilder {
494
463
  */
495
464
  constructor(graph) {
496
465
  this._graph = graph;
497
- /** @type {string|null} */
466
+ /** @type {string|string[]|null} */
498
467
  this._pattern = null;
499
468
  /** @type {Array<{type: string, fn?: (node: QueryNodeSnapshot) => boolean, label?: string, depth?: [number, number]}>} */
500
469
  this._operations = [];
@@ -505,19 +474,21 @@ export default class QueryBuilder {
505
474
  }
506
475
 
507
476
  /**
508
- * Sets the match pattern for filtering nodes by ID.
477
+ * Sets the match pattern(s) for filtering nodes by ID.
509
478
  *
510
479
  * Supports glob-style patterns:
511
480
  * - `*` matches all nodes
512
481
  * - `user:*` matches all nodes starting with "user:"
513
482
  * - `*:admin` matches all nodes ending with ":admin"
483
+ * - Array of patterns: `['campaign:*', 'milestone:*']` (OR semantics)
514
484
  *
515
- * @param {string} pattern - Glob pattern to match node IDs against
485
+ * @param {string|string[]} pattern - Glob pattern or array of patterns to match node IDs against
516
486
  * @returns {QueryBuilder} This builder for chaining
517
- * @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
487
+ * @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
518
488
  */
519
489
  match(pattern) {
520
490
  assertMatchPattern(pattern);
491
+ /** @type {string|string[]|null} */
521
492
  this._pattern = pattern;
522
493
  return this;
523
494
  }
@@ -682,7 +653,7 @@ export default class QueryBuilder {
682
653
  const pattern = this._pattern ?? DEFAULT_PATTERN;
683
654
 
684
655
  let workingSet;
685
- workingSet = allNodes.filter((nodeId) => matchesPattern(nodeId, pattern));
656
+ workingSet = allNodes.filter((nodeId) => matchGlob(pattern, nodeId));
686
657
 
687
658
  for (const op of this._operations) {
688
659
  if (op.type === 'where') {
@@ -26,7 +26,7 @@
26
26
  * @see Paper IV - Echo and the WARP Core (CTL* temporal logic on histories)
27
27
  */
28
28
 
29
- import { createEmptyStateV5, join as joinPatch } from './JoinReducer.js';
29
+ import { createEmptyStateV5, cloneStateV5, join as joinPatch } from './JoinReducer.js';
30
30
  import { decodePropKey } from './KeyCodec.js';
31
31
  import { orsetContains } from '../crdt/ORSet.js';
32
32
 
@@ -83,6 +83,64 @@ function extractNodeSnapshot(state, nodeId) {
83
83
  return { id: nodeId, exists, props };
84
84
  }
85
85
 
86
+ /**
87
+ * Evaluates checkpoint boundary semantics for `always()`.
88
+ *
89
+ * @param {Object} params
90
+ * @param {import('./JoinReducer.js').WarpStateV5} params.state
91
+ * @param {string} params.nodeId
92
+ * @param {Function} params.predicate
93
+ * @param {number|null} params.checkpointMaxLamport
94
+ * @param {number} params.since
95
+ * @returns {{ nodeEverExisted: boolean, shouldReturn: boolean, returnValue: boolean }}
96
+ * @private
97
+ */
98
+ function evaluateAlwaysCheckpointBoundary({
99
+ state,
100
+ nodeId,
101
+ predicate,
102
+ checkpointMaxLamport,
103
+ since,
104
+ }) {
105
+ if (checkpointMaxLamport !== since) {
106
+ return { nodeEverExisted: false, shouldReturn: false, returnValue: false };
107
+ }
108
+ const snapshot = extractNodeSnapshot(state, nodeId);
109
+ if (!snapshot.exists) {
110
+ return { nodeEverExisted: false, shouldReturn: false, returnValue: false };
111
+ }
112
+ if (!predicate(snapshot)) {
113
+ return { nodeEverExisted: true, shouldReturn: true, returnValue: false };
114
+ }
115
+ return { nodeEverExisted: true, shouldReturn: false, returnValue: false };
116
+ }
117
+
118
+ /**
119
+ * Evaluates checkpoint boundary semantics for `eventually()`.
120
+ *
121
+ * @param {Object} params
122
+ * @param {import('./JoinReducer.js').WarpStateV5} params.state
123
+ * @param {string} params.nodeId
124
+ * @param {Function} params.predicate
125
+ * @param {number|null} params.checkpointMaxLamport
126
+ * @param {number} params.since
127
+ * @returns {boolean}
128
+ * @private
129
+ */
130
+ function evaluateEventuallyCheckpointBoundary({
131
+ state,
132
+ nodeId,
133
+ predicate,
134
+ checkpointMaxLamport,
135
+ since,
136
+ }) {
137
+ if (checkpointMaxLamport !== since) {
138
+ return false;
139
+ }
140
+ const snapshot = extractNodeSnapshot(state, nodeId);
141
+ return snapshot.exists && predicate(snapshot);
142
+ }
143
+
86
144
  /**
87
145
  * TemporalQuery provides temporal logic operators over graph history.
88
146
  *
@@ -94,10 +152,14 @@ export class TemporalQuery {
94
152
  * @param {Object} options
95
153
  * @param {Function} options.loadAllPatches - Async function that returns
96
154
  * all patches as Array<{ patch, sha }> in causal order.
155
+ * @param {Function} [options.loadCheckpoint] - Async function returning
156
+ * { state: WarpStateV5, maxLamport: number } or null.
97
157
  */
98
- constructor({ loadAllPatches }) {
158
+ constructor({ loadAllPatches, loadCheckpoint }) {
99
159
  /** @type {Function} */
100
160
  this._loadAllPatches = loadAllPatches;
161
+ /** @type {Function|null} */
162
+ this._loadCheckpoint = loadCheckpoint || null;
101
163
  }
102
164
 
103
165
  /**
@@ -128,19 +190,27 @@ export class TemporalQuery {
128
190
  const since = options.since ?? 0;
129
191
  const allPatches = await this._loadAllPatches();
130
192
 
131
- const state = createEmptyStateV5();
132
- let nodeEverExisted = false;
193
+ const { state, startIdx, checkpointMaxLamport } = await this._resolveStart(allPatches, since);
194
+ const boundary = evaluateAlwaysCheckpointBoundary({
195
+ state,
196
+ nodeId,
197
+ predicate,
198
+ checkpointMaxLamport,
199
+ since,
200
+ });
201
+ if (boundary.shouldReturn) {
202
+ return boundary.returnValue;
203
+ }
204
+ let { nodeEverExisted } = boundary;
133
205
 
134
- for (const { patch, sha } of allPatches) {
135
- // Apply the patch to state
206
+ for (let i = startIdx; i < allPatches.length; i++) {
207
+ const { patch, sha } = allPatches[i];
136
208
  joinPatch(state, patch, sha);
137
209
 
138
- // Skip patches before the `since` threshold
139
210
  if (patch.lamport < since) {
140
211
  continue;
141
212
  }
142
213
 
143
- // Extract node snapshot at this tick
144
214
  const snapshot = extractNodeSnapshot(state, nodeId);
145
215
 
146
216
  if (snapshot.exists) {
@@ -151,7 +221,6 @@ export class TemporalQuery {
151
221
  }
152
222
  }
153
223
 
154
- // If the node never existed in the range, return false
155
224
  return nodeEverExisted;
156
225
  }
157
226
 
@@ -181,18 +250,26 @@ export class TemporalQuery {
181
250
  const since = options.since ?? 0;
182
251
  const allPatches = await this._loadAllPatches();
183
252
 
184
- const state = createEmptyStateV5();
253
+ const { state, startIdx, checkpointMaxLamport } = await this._resolveStart(allPatches, since);
185
254
 
186
- for (const { patch, sha } of allPatches) {
187
- // Apply the patch to state
255
+ if (evaluateEventuallyCheckpointBoundary({
256
+ state,
257
+ nodeId,
258
+ predicate,
259
+ checkpointMaxLamport,
260
+ since,
261
+ })) {
262
+ return true;
263
+ }
264
+
265
+ for (let i = startIdx; i < allPatches.length; i++) {
266
+ const { patch, sha } = allPatches[i];
188
267
  joinPatch(state, patch, sha);
189
268
 
190
- // Skip patches before the `since` threshold
191
269
  if (patch.lamport < since) {
192
270
  continue;
193
271
  }
194
272
 
195
- // Extract node snapshot at this tick
196
273
  const snapshot = extractNodeSnapshot(state, nodeId);
197
274
 
198
275
  if (snapshot.exists && predicate(snapshot)) {
@@ -202,4 +279,41 @@ export class TemporalQuery {
202
279
 
203
280
  return false;
204
281
  }
282
+
283
+ /**
284
+ * Resolves the initial state and start index for temporal replay.
285
+ *
286
+ * When `since > 0` and a checkpoint is available with
287
+ * `maxLamport <= since`, uses the checkpoint state and skips
288
+ * patches already covered by it. Otherwise falls back to an
289
+ * empty state starting from index 0.
290
+ *
291
+ * **Checkpoint `maxLamport` invariant**: The checkpoint's `maxLamport` value
292
+ * MUST represent a fully-closed Lamport tick — i.e. ALL patches with
293
+ * `lamport <= maxLamport` are included in the checkpoint state. The
294
+ * `findIndex` below uses strict `>` to locate the first patch *after* the
295
+ * checkpoint boundary. If a checkpoint were created mid-tick (some but not
296
+ * all patches at a given Lamport value included), this would silently skip
297
+ * the remaining same-tick patches. Checkpoint creators MUST guarantee the
298
+ * all-or-nothing inclusion property for any given Lamport tick.
299
+ *
300
+ * @param {Array<{patch: {lamport: number, [k: string]: unknown}, sha: string}>} allPatches
301
+ * @param {number} since - Minimum Lamport tick
302
+ * @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, startIdx: number, checkpointMaxLamport: number|null}>}
303
+ * @private
304
+ */
305
+ async _resolveStart(allPatches, since) {
306
+ if (since > 0 && this._loadCheckpoint) {
307
+ const ck = /** @type {{ state: import('./JoinReducer.js').WarpStateV5, maxLamport: number } | null} */ (await this._loadCheckpoint());
308
+ if (ck && ck.state && ck.maxLamport <= since) {
309
+ const idx = allPatches.findIndex(
310
+ ({ patch }) => patch.lamport > ck.maxLamport,
311
+ );
312
+ const startIdx = idx < 0 ? allPatches.length : idx;
313
+ // Replay mutates state in-place; isolate checkpoint provider caches from query runs.
314
+ return { state: cloneStateV5(ck.state), startIdx, checkpointMaxLamport: ck.maxLamport };
315
+ }
316
+ }
317
+ return { state: createEmptyStateV5(), startIdx: 0, checkpointMaxLamport: null };
318
+ }
205
319
  }
@@ -15,28 +15,10 @@
15
15
 
16
16
  import { orsetElements, orsetContains } from '../crdt/ORSet.js';
17
17
  import { decodeEdgeKey, decodePropKey, isEdgePropKey } from './KeyCodec.js';
18
+ import { matchGlob } from '../utils/matchGlob.js';
18
19
 
19
20
  /** @typedef {import('./JoinReducer.js').WarpStateV5} WarpStateV5 */
20
21
 
21
- /**
22
- * Tests whether a string matches a glob-style pattern.
23
- *
24
- * @param {string} pattern - Glob pattern (e.g. 'user:*', '*:admin', '*')
25
- * @param {string} str - The string to test
26
- * @returns {boolean} True if the string matches the pattern
27
- */
28
- function matchGlob(pattern, str) {
29
- if (pattern === '*') {
30
- return true;
31
- }
32
- if (!pattern.includes('*')) {
33
- return pattern === str;
34
- }
35
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
36
- const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
37
- return regex.test(str);
38
- }
39
-
40
22
  /**
41
23
  * Computes the set of property keys visible under an observer config.
42
24
  *
@@ -188,20 +170,22 @@ function computePropLoss(state, { nodesA, nodesBSet, configA, configB }) {
188
170
  * A's view to B's view. It is asymmetric: cost(A->B) != cost(B->A) in general.
189
171
  *
190
172
  * @param {Object} configA - Observer configuration for A
191
- * @param {string} configA.match - Glob pattern for visible nodes
173
+ * @param {string|string[]} configA.match - Glob pattern(s) for visible nodes
192
174
  * @param {string[]} [configA.expose] - Property keys to include
193
175
  * @param {string[]} [configA.redact] - Property keys to exclude
194
176
  * @param {Object} configB - Observer configuration for B
195
- * @param {string} configB.match - Glob pattern for visible nodes
177
+ * @param {string|string[]} configB.match - Glob pattern(s) for visible nodes
196
178
  * @param {string[]} [configB.expose] - Property keys to include
197
179
  * @param {string[]} [configB.redact] - Property keys to exclude
198
180
  * @param {WarpStateV5} state - WarpStateV5 materialized state
199
181
  * @returns {{ cost: number, breakdown: { nodeLoss: number, edgeLoss: number, propLoss: number } }}
200
182
  */
201
183
  export function computeTranslationCost(configA, configB, state) {
202
- if (!configA || typeof configA.match !== 'string' ||
203
- !configB || typeof configB.match !== 'string') {
204
- throw new Error('configA.match and configB.match must be strings');
184
+ /** @param {unknown} m */
185
+ const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
186
+ if (!configA || !isValidMatch(configA.match) ||
187
+ !configB || !isValidMatch(configB.match)) {
188
+ throw new Error('configA.match and configB.match must be strings or arrays of strings');
205
189
  }
206
190
  const allNodes = [...orsetElements(state.nodeAlive)];
207
191
  const nodesA = allNodes.filter((id) => matchGlob(configA.match, id));
@@ -0,0 +1,90 @@
1
+ /**
2
+ * PatchDiff — captures alive-ness transitions during patch application.
3
+ *
4
+ * A diff entry is produced only when the alive-ness state of a node or edge
5
+ * actually changes, or when an LWW property winner changes. Redundant ops
6
+ * (e.g. NodeAdd on an already-alive node) produce no diff entries.
7
+ *
8
+ * @module domain/types/PatchDiff
9
+ */
10
+
11
+ /**
12
+ * @typedef {Object} EdgeDiffEntry
13
+ * @property {string} from - Source node ID
14
+ * @property {string} to - Target node ID
15
+ * @property {string} label - Edge label
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} PropDiffEntry
20
+ * @property {string} nodeId - Node (or edge-prop owner) ID
21
+ * @property {string} key - Property key
22
+ * @property {unknown} value - New LWW winner value
23
+ * @property {unknown} prevValue - Previous LWW winner value (undefined if none)
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} PatchDiff
28
+ * @property {string[]} nodesAdded - Nodes that transitioned not-alive → alive
29
+ * @property {string[]} nodesRemoved - Nodes that transitioned alive → not-alive
30
+ * @property {EdgeDiffEntry[]} edgesAdded - Edges that transitioned not-alive → alive
31
+ * @property {EdgeDiffEntry[]} edgesRemoved - Edges that transitioned alive → not-alive
32
+ * @property {PropDiffEntry[]} propsChanged - Properties whose LWW winner actually changed
33
+ */
34
+
35
+ /**
36
+ * Creates an empty PatchDiff.
37
+ *
38
+ * @returns {PatchDiff}
39
+ */
40
+ export function createEmptyDiff() {
41
+ return {
42
+ nodesAdded: [],
43
+ nodesRemoved: [],
44
+ edgesAdded: [],
45
+ edgesRemoved: [],
46
+ propsChanged: [],
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Merges two PatchDiff objects into a net diff by cancelling out
52
+ * contradictory add/remove pairs.
53
+ *
54
+ * - A node that appears in `a.nodesAdded` and `b.nodesRemoved` (or vice-versa)
55
+ * is dropped from both lists (the transitions cancel out).
56
+ * - Same logic applies to edges (keyed by `from\0to\0label`).
57
+ * - For `propsChanged`, only the last entry per `(nodeId, key)` is kept.
58
+ *
59
+ * @param {PatchDiff} a
60
+ * @param {PatchDiff} b
61
+ * @returns {PatchDiff}
62
+ */
63
+ export function mergeDiffs(a, b) {
64
+ const allAdded = a.nodesAdded.concat(b.nodesAdded);
65
+ const allRemoved = a.nodesRemoved.concat(b.nodesRemoved);
66
+ const removedSet = new Set(allRemoved);
67
+ const addedSet = new Set(allAdded);
68
+ const nodesAdded = allAdded.filter((id) => !removedSet.has(id));
69
+ const nodesRemoved = allRemoved.filter((id) => !addedSet.has(id));
70
+
71
+ /** @param {EdgeDiffEntry} e */
72
+ const edgeKey = (e) => `${e.from}\0${e.to}\0${e.label}`;
73
+ const allEdgesAdded = a.edgesAdded.concat(b.edgesAdded);
74
+ const allEdgesRemoved = a.edgesRemoved.concat(b.edgesRemoved);
75
+ const edgeRemovedSet = new Set(allEdgesRemoved.map(edgeKey));
76
+ const edgeAddedSet = new Set(allEdgesAdded.map(edgeKey));
77
+ const edgesAdded = allEdgesAdded.filter((e) => !edgeRemovedSet.has(edgeKey(e)));
78
+ const edgesRemoved = allEdgesRemoved.filter((e) => !edgeAddedSet.has(edgeKey(e)));
79
+
80
+ // For props, deduplicate by keeping the last entry per (nodeId, key).
81
+ const allProps = a.propsChanged.concat(b.propsChanged);
82
+ /** @type {Map<string, PropDiffEntry>} */
83
+ const propMap = new Map();
84
+ for (const entry of allProps) {
85
+ propMap.set(`${entry.nodeId}\0${entry.key}`, entry);
86
+ }
87
+ const propsChanged = [...propMap.values()];
88
+
89
+ return { nodesAdded, nodesRemoved, edgesAdded, edgesRemoved, propsChanged };
90
+ }
@@ -50,7 +50,7 @@
50
50
  * @typedef {Object} OpV2NodeRemove
51
51
  * @property {'NodeRemove'} type - Operation type discriminator
52
52
  * @property {NodeId} node - Node ID to remove
53
- * @property {Dot[]} observedDots - Dots being removed (add events observed)
53
+ * @property {string[]} observedDots - Encoded dot strings being removed (add events observed)
54
54
  */
55
55
 
56
56
  /**
@@ -70,7 +70,7 @@
70
70
  * @property {NodeId} from - Source node ID
71
71
  * @property {NodeId} to - Target node ID
72
72
  * @property {string} label - Edge label/type
73
- * @property {Dot[]} observedDots - Dots being removed (add events observed)
73
+ * @property {string[]} observedDots - Encoded dot strings being removed (add events observed)
74
74
  */
75
75
 
76
76
  /**
@@ -121,7 +121,7 @@ export function createNodeAddV2(node, dot) {
121
121
  /**
122
122
  * Creates a NodeRemove operation with observed dots
123
123
  * @param {NodeId} node - Node ID to remove
124
- * @param {Dot[]} observedDots - Dots being removed
124
+ * @param {string[]} observedDots - Encoded dot strings being removed
125
125
  * @returns {OpV2NodeRemove} NodeRemove operation
126
126
  */
127
127
  export function createNodeRemoveV2(node, observedDots) {
@@ -145,7 +145,7 @@ export function createEdgeAddV2(from, to, label, dot) {
145
145
  * @param {NodeId} from - Source node ID
146
146
  * @param {NodeId} to - Target node ID
147
147
  * @param {string} label - Edge label
148
- * @param {Dot[]} observedDots - Dots being removed
148
+ * @param {string[]} observedDots - Encoded dot strings being removed
149
149
  * @returns {OpV2EdgeRemove} EdgeRemove operation
150
150
  */
151
151
  export function createEdgeRemoveV2(from, to, label, observedDots) {