@git-stunts/git-warp 12.0.0 → 12.2.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 (31) hide show
  1. package/README.md +6 -9
  2. package/bin/warp-graph.js +6 -2
  3. package/index.d.ts +4 -4
  4. package/package.json +2 -1
  5. package/src/domain/WarpGraph.js +3 -0
  6. package/src/domain/crdt/ORSet.js +33 -4
  7. package/src/domain/errors/SyncError.js +1 -0
  8. package/src/domain/errors/TrustError.js +2 -0
  9. package/src/domain/services/CheckpointService.js +2 -7
  10. package/src/domain/services/Frontier.js +18 -0
  11. package/src/domain/services/GraphTraversal.js +8 -49
  12. package/src/domain/services/HttpSyncServer.js +18 -29
  13. package/src/domain/services/JoinReducer.js +23 -0
  14. package/src/domain/services/ObserverView.js +4 -32
  15. package/src/domain/services/PatchBuilderV2.js +29 -3
  16. package/src/domain/services/QueryBuilder.js +78 -74
  17. package/src/domain/services/SyncController.js +74 -11
  18. package/src/domain/services/SyncPayloadSchema.js +236 -0
  19. package/src/domain/services/SyncProtocol.js +27 -8
  20. package/src/domain/services/SyncTrustGate.js +146 -0
  21. package/src/domain/services/TranslationCost.js +8 -24
  22. package/src/domain/trust/TrustRecordService.js +119 -6
  23. package/src/domain/utils/matchGlob.js +51 -0
  24. package/src/domain/warp/Writer.js +7 -5
  25. package/src/domain/warp/checkpoint.methods.js +66 -9
  26. package/src/domain/warp/materialize.methods.js +3 -0
  27. package/src/domain/warp/materializeAdvanced.methods.js +2 -0
  28. package/src/domain/warp/patch.methods.js +8 -0
  29. package/src/domain/warp/query.methods.js +7 -5
  30. package/src/domain/warp/subscribe.methods.js +11 -19
  31. package/src/infrastructure/adapters/GitGraphAdapter.js +2 -2
package/README.md CHANGED
@@ -8,15 +8,12 @@
8
8
  <img src="docs/images/hero.gif" alt="git-warp CLI demo" width="600">
9
9
  </p>
10
10
 
11
- ## What's New in v12.0.0
12
-
13
- - **MaterializedViewService** unified service orchestrating build, persist, and load of bitmap indexes and property readers as a single coherent materialized view. Checkpoints now embed the index (schema:4) for instant hydration on open.
14
- - **GraphTraversal engine (11 algorithms)** — BFS, DFS, shortest path, Dijkstra, A\*, bidirectional A\*, topological sort, longest path, connected component, reachability, and common ancestors. All accessible via `graph.traverse.*`.
15
- - **NeighborProviderPort abstraction** — decouples traversal algorithms from storage. Two implementations: `AdjacencyNeighborProvider` (in-memory) and `BitmapNeighborProvider` (O(1) bitmap lookups).
16
- - **Logical bitmap index** — CBOR-sharded Roaring bitmap index with labeled edges, stable numeric IDs, and property indexes. `IncrementalIndexUpdater` enables O(diff) updates.
17
- - **`nodeWeightFn`** — node-weighted graph algorithms (Dijkstra, A\*, longest path) as an alternative to edge-weight functions.
18
- - **CLI: `verify-index` and `reindex`** — new commands for index integrity checks and forced rebuilds.
19
- - **Cross-runtime hardening** — eliminated bare `Buffer` usage across the index subsystem; bitmap indexes now work on Node, Bun, and Deno.
11
+ ## What's New in v12.2.0
12
+
13
+ - **O(N log N) topological sort** `topologicalSort()` now uses a MinHeap ready queue instead of sorted-array merging, eliminating the O() hot path for large DAGs.
14
+ - **QueryBuilder batching + memoization** — property fetches are now bounded (chunks of 100) and cached per-run, reducing redundant I/O across where-clauses, result building, and aggregation.
15
+ - **Fast materialization guard** — `_materializeGraph()` skips full materialization when cached state is clean, improving query/traversal latency.
16
+ - **Checkpoint `visible.cbor` removed** — checkpoints no longer write the unused visible-projection blob, saving one serialize + blob write per checkpoint.
20
17
 
21
18
  See the [full changelog](CHANGELOG.md) for details.
22
19
 
package/bin/warp-graph.js CHANGED
@@ -9,6 +9,10 @@ import { COMMANDS } from './cli/commands/registry.js';
9
9
 
10
10
  const VIEW_SUPPORTED_COMMANDS = ['info', 'check', 'history', 'path', 'materialize', 'query', 'seek'];
11
11
 
12
+ // C8: Capture output format early so the error handler can use it
13
+ const hasJsonFlag = process.argv.includes('--json');
14
+ const hasNdjsonFlag = process.argv.includes('--ndjson');
15
+
12
16
  /**
13
17
  * CLI entry point. Parses arguments, dispatches to the appropriate command handler,
14
18
  * and emits the result to stdout (JSON or human-readable).
@@ -78,8 +82,8 @@ main().catch((error) => {
78
82
  payload.error.cause = error.cause instanceof Error ? error.cause.message : error.cause;
79
83
  }
80
84
 
81
- if (process.argv.includes('--json') || process.argv.includes('--ndjson')) {
82
- const stringify = process.argv.includes('--ndjson') ? compactStringify : stableStringify;
85
+ if (hasJsonFlag || hasNdjsonFlag) {
86
+ const stringify = hasNdjsonFlag ? compactStringify : stableStringify;
83
87
  process.stdout.write(`${stringify(payload)}\n`);
84
88
  } else {
85
89
  process.stderr.write(renderError(payload));
package/index.d.ts CHANGED
@@ -226,7 +226,7 @@ export interface HopOptions {
226
226
  * Fluent query builder.
227
227
  */
228
228
  export class QueryBuilder {
229
- match(pattern: string): QueryBuilder;
229
+ match(pattern: string | string[]): QueryBuilder;
230
230
  where(fn: ((node: QueryNodeSnapshot) => boolean) | Record<string, unknown>): QueryBuilder;
231
231
  outgoing(label?: string, options?: HopOptions): QueryBuilder;
232
232
  incoming(label?: string, options?: HopOptions): QueryBuilder;
@@ -1194,8 +1194,8 @@ export const CONTENT_PROPERTY_KEY: '_content';
1194
1194
  * Configuration for an observer view.
1195
1195
  */
1196
1196
  export interface ObserverConfig {
1197
- /** Glob pattern for visible nodes (e.g. 'user:*') */
1198
- match: string;
1197
+ /** Glob pattern or array of patterns for visible nodes (e.g. 'user:*' or ['user:*', 'team:*']) */
1198
+ match: string | string[];
1199
1199
  /** Property keys to include (whitelist). If omitted, all non-redacted properties are visible. */
1200
1200
  expose?: string[];
1201
1201
  /** Property keys to exclude (blacklist). Takes precedence over expose. */
@@ -1915,7 +1915,7 @@ export default class WarpGraph {
1915
1915
 
1916
1916
  /** Filtered watcher that only fires for changes matching a glob pattern. */
1917
1917
  watch(
1918
- pattern: string,
1918
+ pattern: string | string[],
1919
1919
  options: {
1920
1920
  onChange: (diff: StateDiffResult) => void;
1921
1921
  onError?: (error: Error) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@git-stunts/git-warp",
3
- "version": "12.0.0",
3
+ "version": "12.2.0",
4
4
  "description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -79,6 +79,7 @@
79
79
  "setup:hooks": "node scripts/setup-hooks.js",
80
80
  "prepare": "patch-package && node scripts/setup-hooks.js",
81
81
  "prepack": "npm run lint && npm run test:local && npm run typecheck:consumer",
82
+ "release:preflight": "bash scripts/release-preflight.sh",
82
83
  "install:git-warp": "bash scripts/install-git-warp.sh",
83
84
  "uninstall:git-warp": "bash scripts/uninstall-git-warp.sh",
84
85
  "test:node20": "docker compose -f docker-compose.test.yml --profile node20 run --build --rm test-node20",
@@ -195,6 +195,9 @@ export default class WarpGraph {
195
195
 
196
196
  /** @type {Record<string, Uint8Array>|null} */
197
197
  this._cachedIndexTree = null;
198
+
199
+ /** @type {boolean} */
200
+ this._indexDegraded = false;
198
201
  }
199
202
 
200
203
  /**
@@ -290,19 +290,48 @@ export function orsetJoin(a, b) {
290
290
  * All replicas are known to have observed at least this causal context.
291
291
  */
292
292
  export function orsetCompact(set, includedVV) {
293
+ // Collect deletions in temp arrays to avoid mutation-during-iteration (J8)
294
+ /** @type {Array<{element: string, dot: string}>} */
295
+ const toDelete = [];
296
+
293
297
  for (const [element, dots] of set.entries) {
294
298
  for (const encodedDot of dots) {
295
299
  const dot = decodeDot(encodedDot);
296
300
  // Only compact if: (1) dot is tombstoned AND (2) dot <= includedVV
297
301
  if (set.tombstones.has(encodedDot) && vvContains(includedVV, dot)) {
298
- dots.delete(encodedDot);
299
- set.tombstones.delete(encodedDot);
302
+ toDelete.push({ element, dot: encodedDot });
300
303
  }
301
304
  }
302
- if (dots.size === 0) {
303
- set.entries.delete(element);
305
+ }
306
+
307
+ // Apply deletions
308
+ for (const { element, dot: encodedDot } of toDelete) {
309
+ const dots = set.entries.get(element);
310
+ if (dots) {
311
+ dots.delete(encodedDot);
312
+ if (dots.size === 0) {
313
+ set.entries.delete(element);
314
+ }
304
315
  }
316
+ set.tombstones.delete(encodedDot);
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Creates a deep clone of an ORSet.
322
+ *
323
+ * @param {ORSet} set - The ORSet to clone
324
+ * @returns {ORSet} A new ORSet with independent data structures
325
+ */
326
+ export function orsetClone(set) {
327
+ const result = createORSet();
328
+ for (const [element, dots] of set.entries) {
329
+ result.entries.set(element, new Set(dots));
330
+ }
331
+ for (const dot of set.tombstones) {
332
+ result.tombstones.add(dot);
305
333
  }
334
+ return result;
306
335
  }
307
336
 
308
337
  /**
@@ -14,6 +14,7 @@ import WarpError from './WarpError.js';
14
14
  * | `E_SYNC_TIMEOUT` | Sync request exceeded timeout |
15
15
  * | `E_SYNC_REMOTE` | Remote server returned a 5xx error |
16
16
  * | `E_SYNC_PROTOCOL` | Protocol violation: 4xx, invalid JSON, or malformed response |
17
+ * | `E_SYNC_PAYLOAD_INVALID` | Sync payload failed shape/resource-limit validation (B64) |
17
18
  * | `SYNC_ERROR` | Generic/default sync error |
18
19
  *
19
20
  * @class SyncError
@@ -9,6 +9,8 @@ import WarpError from './WarpError.js';
9
9
  * |------|-------------|
10
10
  * | `E_TRUST_UNSUPPORTED_ALGORITHM` | Algorithm is not `ed25519` |
11
11
  * | `E_TRUST_INVALID_KEY` | Public key is malformed (wrong length or bad base64) |
12
+ * | `E_TRUST_CAS_CONFLICT` | Concurrent append advanced the trust chain; caller must rebuild + re-sign |
13
+ * | `E_TRUST_CAS_EXHAUSTED` | CAS retry budget exhausted (transient failures) |
12
14
  * | `TRUST_ERROR` | Generic/default trust error |
13
15
  *
14
16
  * @class TrustError
@@ -11,7 +11,7 @@
11
11
  * @see WARP Spec Section 10
12
12
  */
13
13
 
14
- import { serializeStateV5, computeStateHashV5 } from './StateSerializerV5.js';
14
+ import { computeStateHashV5 } from './StateSerializerV5.js';
15
15
  import {
16
16
  serializeFullStateV5,
17
17
  deserializeFullStateV5,
@@ -86,7 +86,6 @@ function partitionTreeOids(rawOids) {
86
86
  * ```
87
87
  * <checkpoint_commit_tree>/
88
88
  * ├── state.cbor # AUTHORITATIVE: Full V5 state (ORSets + props)
89
- * ├── visible.cbor # CACHE ONLY: Visible projection for fast queries
90
89
  * ├── frontier.cbor # Writer frontiers
91
90
  * ├── appliedVV.cbor # Version vector of dots in state
92
91
  * └── provenanceIndex.cbor # Optional: node-to-patchSha index (HG/IO/2)
@@ -116,7 +115,6 @@ export async function create({ persistence, graphName, state, frontier, parents
116
115
  * ```
117
116
  * <checkpoint_tree>/
118
117
  * ├── state.cbor # AUTHORITATIVE: Full V5 state (ORSets + props)
119
- * ├── visible.cbor # CACHE ONLY: Visible projection for fast queries
120
118
  * ├── frontier.cbor # Writer frontiers
121
119
  * ├── appliedVV.cbor # Version vector of dots in state
122
120
  * └── provenanceIndex.cbor # Optional: node-to-patchSha index (HG/IO/2)
@@ -161,8 +159,7 @@ export async function createV5({
161
159
  // 3. Serialize full state (AUTHORITATIVE)
162
160
  const stateBuffer = serializeFullStateV5(checkpointState, { codec });
163
161
 
164
- // 4. Serialize visible projection (CACHE)
165
- const visibleBuffer = serializeStateV5(checkpointState, { codec });
162
+ // 4. Compute state hash
166
163
  const stateHash = await computeStateHashV5(checkpointState, { codec, crypto: /** @type {import('../../ports/CryptoPort.js').default} */ (crypto) });
167
164
 
168
165
  // 5. Serialize frontier and appliedVV
@@ -171,7 +168,6 @@ export async function createV5({
171
168
 
172
169
  // 6. Write blobs to git
173
170
  const stateBlobOid = await persistence.writeBlob(/** @type {Buffer} */ (stateBuffer));
174
- const visibleBlobOid = await persistence.writeBlob(/** @type {Buffer} */ (visibleBuffer));
175
171
  const frontierBlobOid = await persistence.writeBlob(/** @type {Buffer} */ (frontierBuffer));
176
172
  const appliedVVBlobOid = await persistence.writeBlob(/** @type {Buffer} */ (appliedVVBuffer));
177
173
 
@@ -207,7 +203,6 @@ export async function createV5({
207
203
  `100644 blob ${appliedVVBlobOid}\tappliedVV.cbor`,
208
204
  `100644 blob ${frontierBlobOid}\tfrontier.cbor`,
209
205
  `100644 blob ${stateBlobOid}\tstate.cbor`,
210
- `100644 blob ${visibleBlobOid}\tvisible.cbor`,
211
206
  ];
212
207
 
213
208
  // Add provenance index if present
@@ -91,6 +91,24 @@ export function cloneFrontier(frontier) {
91
91
  return new Map(frontier);
92
92
  }
93
93
 
94
+ /**
95
+ * Produces a stable, deterministic fingerprint of a frontier.
96
+ *
97
+ * Sorts entries by writer ID and JSON-stringifies the sorted pairs.
98
+ * Two frontiers produce the same fingerprint iff they have identical
99
+ * writer→SHA mappings. Used for snapshot isolation checks (B63)
100
+ * and diagnostic logging.
101
+ *
102
+ * @param {Frontier} frontier
103
+ * @returns {string} Deterministic JSON string of sorted entries
104
+ */
105
+ export function frontierFingerprint(frontier) {
106
+ const sorted = [...frontier.entries()].sort(
107
+ ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
108
+ );
109
+ return JSON.stringify(sorted);
110
+ }
111
+
94
112
  /**
95
113
  * Merges two frontiers, taking the "later" entry for each writer.
96
114
  * Note: This is a simple merge that takes entries from both.
@@ -830,52 +830,38 @@ export default class GraphTraversal {
830
830
  }
831
831
  }
832
832
 
833
- // Phase 2: Kahn's — collect zero-indegree nodes, sort them lex, yield in order
834
- /** @type {string[]} */
835
- const ready = [];
833
+ // Phase 2: Kahn's — MinHeap for O(N log N) zero-indegree processing
834
+ const ready = new MinHeap({ tieBreaker: lexTieBreaker });
836
835
  for (const nodeId of discovered) {
837
836
  if ((inDegree.get(nodeId) || 0) === 0) {
838
- ready.push(nodeId);
837
+ ready.insert(nodeId, 0);
839
838
  }
840
839
  }
841
- ready.sort(lexTieBreaker);
842
840
 
841
+ /** @type {string[]} */
843
842
  const sorted = [];
844
- let rHead = 0;
845
- while (rHead < ready.length && sorted.length < maxNodes) {
843
+ while (!ready.isEmpty() && sorted.length < maxNodes) {
846
844
  if (sorted.length % 1000 === 0) {
847
845
  checkAborted(signal, 'topologicalSort');
848
846
  }
849
- const nodeId = /** @type {string} */ (ready[rHead++]);
847
+ const nodeId = /** @type {string} */ (ready.extractMin());
850
848
  sorted.push(nodeId);
851
849
 
852
850
  const neighbors = adjList.get(nodeId) || [];
853
- /** @type {string[]} */
854
- const newlyReady = [];
855
851
  for (const neighborId of neighbors) {
856
852
  const deg = /** @type {number} */ (inDegree.get(neighborId)) - 1;
857
853
  inDegree.set(neighborId, deg);
858
854
  if (deg === 0) {
859
- newlyReady.push(neighborId);
855
+ ready.insert(neighborId, 0);
860
856
  }
861
857
  }
862
- // Insert newly ready nodes in sorted position
863
- if (newlyReady.length > 0) {
864
- newlyReady.sort(lexTieBreaker);
865
- // Compact consumed prefix before merge to keep rHead at 0
866
- if (rHead > 0) {
867
- ready.splice(0, rHead);
868
- rHead = 0;
869
- }
870
- this._insertSorted(ready, newlyReady);
871
- }
872
858
  }
873
859
 
874
860
  const hasCycle = computeTopoHasCycle({
875
861
  sortedLength: sorted.length,
876
862
  discoveredSize: discovered.size,
877
863
  maxNodes,
878
- readyRemaining: rHead < ready.length,
864
+ readyRemaining: !ready.isEmpty(),
879
865
  });
880
866
  if (hasCycle && throwOnCycle) {
881
867
  // Find a back-edge as witness
@@ -1209,31 +1195,4 @@ export default class GraphTraversal {
1209
1195
  return candidatePred < current;
1210
1196
  }
1211
1197
 
1212
- /**
1213
- * Inserts sorted items into a sorted array maintaining order.
1214
- * Both input arrays must be sorted by lexTieBreaker.
1215
- *
1216
- * @param {string[]} target - Sorted array to insert into (mutated in place)
1217
- * @param {string[]} items - Sorted items to insert
1218
- * @private
1219
- */
1220
- _insertSorted(target, items) {
1221
- // O(n+k) merge: build merged array from two sorted inputs
1222
- const merged = [];
1223
- let ti = 0;
1224
- let ii = 0;
1225
- while (ti < target.length && ii < items.length) {
1226
- if (target[ti] <= items[ii]) {
1227
- merged.push(target[ti++]);
1228
- } else {
1229
- merged.push(items[ii++]);
1230
- }
1231
- }
1232
- while (ti < target.length) { merged.push(target[ti++]); }
1233
- while (ii < items.length) { merged.push(items[ii++]); }
1234
- target.length = 0;
1235
- for (let i = 0; i < merged.length; i++) {
1236
- target.push(merged[i]);
1237
- }
1238
- }
1239
1198
  }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { z } from 'zod';
12
12
  import SyncAuthService from './SyncAuthService.js';
13
+ import { validateSyncRequest } from './SyncPayloadSchema.js';
13
14
 
14
15
  const DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024;
15
16
  const MAX_REQUEST_BYTES_CEILING = 128 * 1024 * 1024; // 134217728
@@ -117,26 +118,7 @@ function jsonResponse(data) {
117
118
  };
118
119
  }
119
120
 
120
- /**
121
- * Validates that a sync request object has the expected shape.
122
- *
123
- * @param {unknown} parsed - Parsed JSON body
124
- * @returns {boolean} True if valid
125
- * @private
126
- */
127
- function isValidSyncRequest(parsed) {
128
- if (!parsed || typeof parsed !== 'object') {
129
- return false;
130
- }
131
- const rec = /** @type {Record<string, unknown>} */ (parsed);
132
- if (rec.type !== 'sync-request') {
133
- return false;
134
- }
135
- if (!rec.frontier || typeof rec.frontier !== 'object' || Array.isArray(rec.frontier)) {
136
- return false;
137
- }
138
- return true;
139
- }
121
+ // isValidSyncRequest replaced by SyncPayloadSchema.validateSyncRequest (B64)
140
122
 
141
123
  /**
142
124
  * Checks the content-type header. Returns an error response if the
@@ -200,6 +182,7 @@ function checkBodySize(body, maxBytes) {
200
182
 
201
183
  /**
202
184
  * Parses and validates the request body as a sync request.
185
+ * Uses Zod-based SyncPayloadSchema for shape + resource limit validation.
203
186
  *
204
187
  * @param {Buffer|undefined} body
205
188
  * @returns {{ error: { status: number, headers: Object, body: string }, parsed: null } | { error: null, parsed: import('./SyncProtocol.js').SyncRequest }}
@@ -215,11 +198,12 @@ function parseBody(body) {
215
198
  return { error: errorResponse(400, 'Invalid JSON'), parsed: null };
216
199
  }
217
200
 
218
- if (!isValidSyncRequest(parsed)) {
219
- return { error: errorResponse(400, 'Invalid sync request'), parsed: null };
201
+ const validation = validateSyncRequest(parsed);
202
+ if (!validation.ok) {
203
+ return { error: errorResponse(400, `Invalid sync request: ${validation.error}`), parsed: null };
220
204
  }
221
205
 
222
- return { error: null, parsed };
206
+ return { error: null, parsed: /** @type {import('./SyncProtocol.js').SyncRequest} */ (validation.value) };
223
207
  }
224
208
 
225
209
  /**
@@ -298,12 +282,17 @@ export default class HttpSyncServer {
298
282
  this._auth.recordLogOnlyPassthrough();
299
283
  }
300
284
 
301
- // Writer whitelist (uses parsed body for writer IDs)
302
- if (parsed.patches && typeof parsed.patches === 'object') {
303
- const writerIds = Object.keys(parsed.patches);
304
- const writerResult = this._auth.enforceWriters(writerIds);
305
- if (!writerResult.ok) {
306
- return errorResponse(writerResult.status, writerResult.reason);
285
+ // Writer whitelist: for sync-requests, extract writer IDs from frontier
286
+ // keys (the writers the peer claims to have). Sync-requests don't carry
287
+ // patches the server generates the response. For sync-responses with
288
+ // patches, trust-gate should be on patch authors (handled client-side).
289
+ if (parsed.frontier && typeof parsed.frontier === 'object') {
290
+ const writerIds = Object.keys(/** @type {Record<string, string>} */ (parsed.frontier));
291
+ if (writerIds.length > 0) {
292
+ const writerResult = this._auth.enforceWriters(writerIds);
293
+ if (!writerResult.ok) {
294
+ return errorResponse(writerResult.status, writerResult.reason);
295
+ }
307
296
  }
308
297
  }
309
298
 
@@ -86,6 +86,29 @@ export function createEmptyStateV5() {
86
86
  * @param {import('../utils/EventId.js').EventId} eventId - Event ID for causality tracking
87
87
  * @returns {void}
88
88
  */
89
+ /**
90
+ * Known V2 operation types. Used for forward-compatibility validation.
91
+ * @type {ReadonlySet<string>}
92
+ */
93
+ const KNOWN_OPS = new Set(['NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove', 'PropSet', 'BlobValue']);
94
+
95
+ /**
96
+ * Validates that an operation has a known type.
97
+ *
98
+ * @param {{ type: string }} op
99
+ * @returns {boolean} True if the op type is in KNOWN_OPS
100
+ */
101
+ export function isKnownOp(op) {
102
+ return op && typeof op.type === 'string' && KNOWN_OPS.has(op.type);
103
+ }
104
+
105
+ /**
106
+ * Applies a single V2 operation to the given CRDT state.
107
+ *
108
+ * @param {WarpStateV5} state - The mutable CRDT state to update
109
+ * @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
110
+ * @param {import('../utils/EventId.js').EventId} eventId - The event ID for LWW ordering
111
+ */
89
112
  export function applyOpV2(state, op, eventId) {
90
113
  switch (op.type) {
91
114
  case 'NodeAdd':
@@ -13,35 +13,7 @@ import QueryBuilder from './QueryBuilder.js';
13
13
  import LogicalTraversal from './LogicalTraversal.js';
14
14
  import { orsetContains, orsetElements } from '../crdt/ORSet.js';
15
15
  import { decodeEdgeKey } from './KeyCodec.js';
16
-
17
- /** @type {Map<string, RegExp>} Module-level cache for compiled glob regexes. */
18
- const globRegexCache = new Map();
19
-
20
- /**
21
- * Tests whether a string matches a glob-style pattern.
22
- *
23
- * Supports `*` as a wildcard matching zero or more characters.
24
- * A lone `*` matches everything.
25
- *
26
- * @param {string} pattern - Glob pattern (e.g. 'user:*', '*:admin', '*')
27
- * @param {string} str - The string to test
28
- * @returns {boolean} True if the string matches the pattern
29
- */
30
- function matchGlob(pattern, str) {
31
- if (pattern === '*') {
32
- return true;
33
- }
34
- if (!pattern.includes('*')) {
35
- return pattern === str;
36
- }
37
- let regex = globRegexCache.get(pattern);
38
- if (!regex) {
39
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
40
- regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
41
- globRegexCache.set(pattern, regex);
42
- }
43
- return regex.test(str);
44
- }
16
+ import { matchGlob } from '../utils/matchGlob.js';
45
17
 
46
18
  /**
47
19
  * Filters a properties Map based on expose and redact lists.
@@ -94,7 +66,7 @@ function sortNeighbors(list) {
94
66
  * Builds filtered adjacency maps by scanning all edges in the OR-Set.
95
67
  *
96
68
  * @param {import('./JoinReducer.js').WarpStateV5} state
97
- * @param {string} pattern
69
+ * @param {string|string[]} pattern
98
70
  * @returns {{ outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]> }}
99
71
  */
100
72
  function buildAdjacencyFromEdges(state, pattern) {
@@ -187,7 +159,7 @@ export default class ObserverView {
187
159
  * @param {Object} options
188
160
  * @param {string} options.name - Observer name
189
161
  * @param {Object} options.config - Observer configuration
190
- * @param {string} options.config.match - Glob pattern for visible nodes
162
+ * @param {string|string[]} options.config.match - Glob pattern(s) for visible nodes
191
163
  * @param {string[]} [options.config.expose] - Property keys to include
192
164
  * @param {string[]} [options.config.redact] - Property keys to exclude (takes precedence over expose)
193
165
  * @param {import('../WarpGraph.js').default} options.graph - The source WarpGraph instance
@@ -196,7 +168,7 @@ export default class ObserverView {
196
168
  /** @type {string} */
197
169
  this._name = name;
198
170
 
199
- /** @type {string} */
171
+ /** @type {string|string[]} */
200
172
  this._matchPattern = config.match;
201
173
 
202
174
  /** @type {string[]|undefined} */
@@ -103,6 +103,15 @@ export class PatchBuilderV2 {
103
103
  /** @type {Function} */
104
104
  this._getCurrentState = getCurrentState; // Function to get current materialized state
105
105
 
106
+ /**
107
+ * Snapshot of state captured at construction time (C4).
108
+ * Lazily populated on first call to _getSnapshotState().
109
+ * Prevents TOCTOU races where concurrent writes change state
110
+ * between remove operations in the same patch.
111
+ * @type {import('./JoinReducer.js').WarpStateV5|null}
112
+ */
113
+ this._snapshotState = /** @type {import('./JoinReducer.js').WarpStateV5|null} */ (/** @type {unknown} */ (undefined)); // undefined = not yet captured
114
+
106
115
  /** @type {string|null} */
107
116
  this._expectedParentSha = expectedParentSha;
108
117
 
@@ -156,6 +165,23 @@ export class PatchBuilderV2 {
156
165
  this._writes = new Set();
157
166
  }
158
167
 
168
+ /**
169
+ * Returns a snapshot of the current state, captured lazily on first call (C4).
170
+ *
171
+ * All remove operations within this patch observe dots from the same
172
+ * state snapshot, preventing TOCTOU races where concurrent writers
173
+ * change state between operations.
174
+ *
175
+ * @returns {import('./JoinReducer.js').WarpStateV5|null}
176
+ * @private
177
+ */
178
+ _getSnapshotState() {
179
+ if (this._snapshotState === undefined) {
180
+ this._snapshotState = this._getCurrentState() || null;
181
+ }
182
+ return this._snapshotState;
183
+ }
184
+
159
185
  /**
160
186
  * Adds a node to the graph.
161
187
  *
@@ -213,7 +239,7 @@ export class PatchBuilderV2 {
213
239
  */
214
240
  removeNode(nodeId) {
215
241
  // Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
216
- const state = this._getCurrentState();
242
+ const state = this._getSnapshotState();
217
243
 
218
244
  // Cascade mode: auto-generate EdgeRemove ops for all connected edges before NodeRemove.
219
245
  // Generated ops appear in the patch for auditability.
@@ -330,7 +356,7 @@ export class PatchBuilderV2 {
330
356
  */
331
357
  removeEdge(from, to, label) {
332
358
  // Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
333
- const state = this._getCurrentState();
359
+ const state = this._getSnapshotState();
334
360
  const edgeKey = encodeEdgeKey(from, to, label);
335
361
  const observedDots = state ? [...orsetGetDots(state.edgeAlive, edgeKey)] : [];
336
362
  this._ops.push(createEdgeRemoveV2(from, to, label, observedDots));
@@ -418,7 +444,7 @@ export class PatchBuilderV2 {
418
444
  // Validate edge exists in this patch or in current state
419
445
  const ek = encodeEdgeKey(from, to, label);
420
446
  if (!this._edgesAdded.has(ek)) {
421
- const state = this._getCurrentState();
447
+ const state = this._getSnapshotState();
422
448
  if (!state || !orsetContains(state.edgeAlive, ek)) {
423
449
  throw new Error(`Cannot set property on unknown edge (${from} → ${to} [${label}]): add the edge first`);
424
450
  }