@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.
- package/README.md +6 -9
- package/bin/warp-graph.js +6 -2
- package/index.d.ts +4 -4
- package/package.json +2 -1
- package/src/domain/WarpGraph.js +3 -0
- package/src/domain/crdt/ORSet.js +33 -4
- package/src/domain/errors/SyncError.js +1 -0
- package/src/domain/errors/TrustError.js +2 -0
- package/src/domain/services/CheckpointService.js +2 -7
- package/src/domain/services/Frontier.js +18 -0
- package/src/domain/services/GraphTraversal.js +8 -49
- package/src/domain/services/HttpSyncServer.js +18 -29
- package/src/domain/services/JoinReducer.js +23 -0
- package/src/domain/services/ObserverView.js +4 -32
- package/src/domain/services/PatchBuilderV2.js +29 -3
- package/src/domain/services/QueryBuilder.js +78 -74
- package/src/domain/services/SyncController.js +74 -11
- package/src/domain/services/SyncPayloadSchema.js +236 -0
- package/src/domain/services/SyncProtocol.js +27 -8
- package/src/domain/services/SyncTrustGate.js +146 -0
- package/src/domain/services/TranslationCost.js +8 -24
- package/src/domain/trust/TrustRecordService.js +119 -6
- package/src/domain/utils/matchGlob.js +51 -0
- package/src/domain/warp/Writer.js +7 -5
- package/src/domain/warp/checkpoint.methods.js +66 -9
- package/src/domain/warp/materialize.methods.js +3 -0
- package/src/domain/warp/materializeAdvanced.methods.js +2 -0
- package/src/domain/warp/patch.methods.js +8 -0
- package/src/domain/warp/query.methods.js +7 -5
- package/src/domain/warp/subscribe.methods.js +11 -19
- 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.
|
|
12
|
-
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
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(N²) 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 (
|
|
82
|
-
const stringify =
|
|
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.
|
|
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",
|
package/src/domain/WarpGraph.js
CHANGED
package/src/domain/crdt/ORSet.js
CHANGED
|
@@ -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
|
-
|
|
299
|
-
set.tombstones.delete(encodedDot);
|
|
302
|
+
toDelete.push({ element, dot: encodedDot });
|
|
300
303
|
}
|
|
301
304
|
}
|
|
302
|
-
|
|
303
|
-
|
|
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 {
|
|
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.
|
|
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 —
|
|
834
|
-
|
|
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.
|
|
837
|
+
ready.insert(nodeId, 0);
|
|
839
838
|
}
|
|
840
839
|
}
|
|
841
|
-
ready.sort(lexTieBreaker);
|
|
842
840
|
|
|
841
|
+
/** @type {string[]} */
|
|
843
842
|
const sorted = [];
|
|
844
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|