@git-stunts/git-warp 12.1.0 → 12.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/bin/cli/commands/trust.js +37 -1
- package/bin/cli/infrastructure.js +14 -1
- package/bin/cli/schemas.js +4 -4
- package/bin/warp-graph.js +9 -2
- package/index.d.ts +18 -2
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +4 -1
- package/src/domain/crdt/Dot.js +5 -0
- package/src/domain/crdt/LWW.js +3 -1
- package/src/domain/crdt/ORSet.js +63 -27
- package/src/domain/crdt/VersionVector.js +12 -0
- package/src/domain/errors/PatchError.js +27 -0
- package/src/domain/errors/StorageError.js +8 -0
- package/src/domain/errors/SyncError.js +1 -0
- package/src/domain/errors/TrustError.js +2 -0
- package/src/domain/errors/WriterError.js +5 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditVerifierService.js +32 -2
- package/src/domain/services/BitmapIndexBuilder.js +14 -9
- package/src/domain/services/CheckpointService.js +12 -8
- package/src/domain/services/Frontier.js +18 -0
- package/src/domain/services/GCPolicy.js +25 -4
- package/src/domain/services/GraphTraversal.js +11 -50
- package/src/domain/services/HttpSyncServer.js +18 -29
- package/src/domain/services/IncrementalIndexUpdater.js +179 -36
- package/src/domain/services/JoinReducer.js +164 -31
- package/src/domain/services/MaterializedViewService.js +13 -2
- package/src/domain/services/PatchBuilderV2.js +210 -145
- package/src/domain/services/QueryBuilder.js +67 -30
- package/src/domain/services/SyncController.js +62 -18
- package/src/domain/services/SyncPayloadSchema.js +236 -0
- package/src/domain/services/SyncProtocol.js +102 -40
- package/src/domain/services/SyncTrustGate.js +146 -0
- package/src/domain/services/TranslationCost.js +2 -2
- package/src/domain/trust/TrustRecordService.js +161 -34
- package/src/domain/utils/CachedValue.js +34 -5
- package/src/domain/utils/EventId.js +4 -1
- package/src/domain/utils/LRUCache.js +3 -1
- package/src/domain/utils/RefLayout.js +4 -0
- package/src/domain/utils/canonicalStringify.js +48 -18
- package/src/domain/utils/matchGlob.js +7 -0
- package/src/domain/warp/PatchSession.js +30 -24
- package/src/domain/warp/Writer.js +12 -5
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/checkpoint.methods.js +102 -16
- package/src/domain/warp/materialize.methods.js +47 -5
- package/src/domain/warp/materializeAdvanced.methods.js +52 -10
- package/src/domain/warp/patch.methods.js +24 -8
- package/src/domain/warp/query.methods.js +4 -4
- package/src/domain/warp/subscribe.methods.js +11 -19
- package/src/infrastructure/adapters/GitGraphAdapter.js +57 -54
- package/src/infrastructure/codecs/CborCodec.js +2 -0
- package/src/domain/utils/fnv1a.js +0 -20
|
@@ -9,6 +9,27 @@ import { matchGlob } from '../utils/matchGlob.js';
|
|
|
9
9
|
|
|
10
10
|
const DEFAULT_PATTERN = '*';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Processes items in batches with bounded concurrency.
|
|
14
|
+
*
|
|
15
|
+
* @template T, R
|
|
16
|
+
* @param {T[]} items - Items to process
|
|
17
|
+
* @param {(item: T) => Promise<R>} fn - Async function to apply to each item
|
|
18
|
+
* @param {number} [limit=100] - Maximum concurrent operations per batch
|
|
19
|
+
* @returns {Promise<R[]>} Results in input order
|
|
20
|
+
*/
|
|
21
|
+
async function batchMap(items, fn, limit = 100) {
|
|
22
|
+
const results = [];
|
|
23
|
+
for (let i = 0; i < items.length; i += limit) {
|
|
24
|
+
const batch = items.slice(i, i + limit);
|
|
25
|
+
const batchResults = await Promise.all(batch.map(fn));
|
|
26
|
+
for (const r of batchResults) {
|
|
27
|
+
results.push(r);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
/**
|
|
13
34
|
* @typedef {Object} QueryNodeSnapshot
|
|
14
35
|
* @property {string} id - The unique identifier of the node
|
|
@@ -200,6 +221,10 @@ function deepFreeze(obj) {
|
|
|
200
221
|
/**
|
|
201
222
|
* Creates a deep clone of a value.
|
|
202
223
|
*
|
|
224
|
+
* Results are deep-frozen to prevent accidental mutation of cached state.
|
|
225
|
+
* structuredClone is preferred; JSON round-trip is the fallback for
|
|
226
|
+
* environments without structuredClone support.
|
|
227
|
+
*
|
|
203
228
|
* Attempts structuredClone first (Node 17+ / modern browsers), falls back
|
|
204
229
|
* to JSON round-trip, and returns the original value if both fail (e.g.,
|
|
205
230
|
* for values containing functions or circular references).
|
|
@@ -652,22 +677,33 @@ export default class QueryBuilder {
|
|
|
652
677
|
|
|
653
678
|
const pattern = this._pattern ?? DEFAULT_PATTERN;
|
|
654
679
|
|
|
680
|
+
// Per-run props memo to avoid redundant getNodeProps calls
|
|
681
|
+
/** @type {Map<string, Map<string, unknown>>} */
|
|
682
|
+
const propsMemo = new Map();
|
|
683
|
+
const getProps = async (/** @type {string} */ nodeId) => {
|
|
684
|
+
const cached = propsMemo.get(nodeId);
|
|
685
|
+
if (cached !== undefined) {
|
|
686
|
+
return cached;
|
|
687
|
+
}
|
|
688
|
+
const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
|
|
689
|
+
propsMemo.set(nodeId, propsMap);
|
|
690
|
+
return propsMap;
|
|
691
|
+
};
|
|
692
|
+
|
|
655
693
|
let workingSet;
|
|
656
694
|
workingSet = allNodes.filter((nodeId) => matchGlob(pattern, nodeId));
|
|
657
695
|
|
|
658
696
|
for (const op of this._operations) {
|
|
659
697
|
if (op.type === 'where') {
|
|
660
|
-
const snapshots = await
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
})
|
|
670
|
-
);
|
|
698
|
+
const snapshots = await batchMap(workingSet, async (nodeId) => {
|
|
699
|
+
const propsMap = await getProps(nodeId);
|
|
700
|
+
const edgesOut = adjacency.outgoing.get(nodeId) || [];
|
|
701
|
+
const edgesIn = adjacency.incoming.get(nodeId) || [];
|
|
702
|
+
return {
|
|
703
|
+
nodeId,
|
|
704
|
+
snapshot: createNodeSnapshot({ id: nodeId, propsMap, edgesOut, edgesIn }),
|
|
705
|
+
};
|
|
706
|
+
});
|
|
671
707
|
const predicate = /** @type {(node: QueryNodeSnapshot) => boolean} */ (op.fn);
|
|
672
708
|
const filtered = snapshots
|
|
673
709
|
.filter(({ snapshot }) => predicate(snapshot))
|
|
@@ -698,7 +734,7 @@ export default class QueryBuilder {
|
|
|
698
734
|
}
|
|
699
735
|
|
|
700
736
|
if (this._aggregate) {
|
|
701
|
-
return await this._runAggregate(workingSet, stateHash);
|
|
737
|
+
return await this._runAggregate(workingSet, stateHash, getProps);
|
|
702
738
|
}
|
|
703
739
|
|
|
704
740
|
const selected = this._select;
|
|
@@ -718,22 +754,20 @@ export default class QueryBuilder {
|
|
|
718
754
|
const includeId = !selectFields || selectFields.includes('id');
|
|
719
755
|
const includeProps = !selectFields || selectFields.includes('props');
|
|
720
756
|
|
|
721
|
-
const nodes = await
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
entry.props = props;
|
|
732
|
-
}
|
|
757
|
+
const nodes = await batchMap(workingSet, async (nodeId) => {
|
|
758
|
+
const entry = {};
|
|
759
|
+
if (includeId) {
|
|
760
|
+
entry.id = nodeId;
|
|
761
|
+
}
|
|
762
|
+
if (includeProps) {
|
|
763
|
+
const propsMap = await getProps(nodeId);
|
|
764
|
+
const props = buildPropsSnapshot(propsMap);
|
|
765
|
+
if (selectFields || Object.keys(props).length > 0) {
|
|
766
|
+
entry.props = props;
|
|
733
767
|
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
);
|
|
768
|
+
}
|
|
769
|
+
return entry;
|
|
770
|
+
});
|
|
737
771
|
|
|
738
772
|
return { stateHash, nodes };
|
|
739
773
|
}
|
|
@@ -747,10 +781,11 @@ export default class QueryBuilder {
|
|
|
747
781
|
*
|
|
748
782
|
* @param {string[]} workingSet - Array of matched node IDs
|
|
749
783
|
* @param {string} stateHash - Hash of the materialized state
|
|
784
|
+
* @param {(nodeId: string) => Promise<Map<string, unknown>>} getProps - Memoized props fetcher
|
|
750
785
|
* @returns {Promise<AggregateResult>} Object containing stateHash and requested aggregation values
|
|
751
786
|
* @private
|
|
752
787
|
*/
|
|
753
|
-
async _runAggregate(workingSet, stateHash) {
|
|
788
|
+
async _runAggregate(workingSet, stateHash, getProps) {
|
|
754
789
|
const spec = /** @type {AggregateSpec} */ (this._aggregate);
|
|
755
790
|
/** @type {AggregateResult} */
|
|
756
791
|
const result = { stateHash };
|
|
@@ -773,8 +808,10 @@ export default class QueryBuilder {
|
|
|
773
808
|
});
|
|
774
809
|
}
|
|
775
810
|
|
|
776
|
-
|
|
777
|
-
|
|
811
|
+
// Pre-fetch all props with bounded concurrency
|
|
812
|
+
const propsList = await batchMap(workingSet, getProps);
|
|
813
|
+
|
|
814
|
+
for (const propsMap of propsList) {
|
|
778
815
|
for (const { segments, values } of propsByAgg.values()) {
|
|
779
816
|
/** @type {unknown} */
|
|
780
817
|
let value = propsMap.get(segments[0]);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import SyncError from '../errors/SyncError.js';
|
|
12
12
|
import OperationAbortedError from '../errors/OperationAbortedError.js';
|
|
13
13
|
import { QueryError, E_NO_STATE_MSG } from '../warp/_internal.js';
|
|
14
|
+
import { validateSyncResponse } from './SyncPayloadSchema.js';
|
|
14
15
|
import {
|
|
15
16
|
createSyncRequest as createSyncRequestImpl,
|
|
16
17
|
processSyncRequest as processSyncRequestImpl,
|
|
@@ -25,6 +26,7 @@ import { collectGCMetrics } from './GCMetrics.js';
|
|
|
25
26
|
import HttpSyncServer from './HttpSyncServer.js';
|
|
26
27
|
import { signSyncRequest, canonicalizePath } from './SyncAuthService.js';
|
|
27
28
|
import { isError } from '../types/WarpErrors.js';
|
|
29
|
+
import SyncTrustGate from './SyncTrustGate.js';
|
|
28
30
|
|
|
29
31
|
/** @typedef {import('../types/WarpPersistence.js').CorePersistence} CorePersistence */
|
|
30
32
|
|
|
@@ -49,6 +51,7 @@ import { isError } from '../types/WarpErrors.js';
|
|
|
49
51
|
* @property {number} _patchesSinceCheckpoint
|
|
50
52
|
* @property {(op: string, t0: number, opts?: {metrics?: string, error?: Error}) => void} _logTiming
|
|
51
53
|
* @property {(options?: Record<string, unknown>) => Promise<unknown>} materialize
|
|
54
|
+
* @property {(state: import('../services/JoinReducer.js').WarpStateV5) => Promise<unknown>} _setMaterializedState
|
|
52
55
|
* @property {() => Promise<string[]>} discoverWriters
|
|
53
56
|
*/
|
|
54
57
|
|
|
@@ -128,10 +131,14 @@ async function buildSyncAuthHeaders({ auth, bodyStr, targetUrl, crypto }) {
|
|
|
128
131
|
export default class SyncController {
|
|
129
132
|
/**
|
|
130
133
|
* @param {SyncHost} host - The WarpGraph instance (or any object satisfying SyncHost)
|
|
134
|
+
* @param {Object} [options]
|
|
135
|
+
* @param {SyncTrustGate} [options.trustGate] - Trust gate for evaluating patch authors
|
|
131
136
|
*/
|
|
132
|
-
constructor(host) {
|
|
137
|
+
constructor(host, options = {}) {
|
|
133
138
|
/** @type {SyncHost} */
|
|
134
139
|
this._host = host;
|
|
140
|
+
/** @type {SyncTrustGate|null} */
|
|
141
|
+
this._trustGate = options.trustGate || null;
|
|
135
142
|
}
|
|
136
143
|
|
|
137
144
|
/**
|
|
@@ -268,7 +275,7 @@ export default class SyncController {
|
|
|
268
275
|
localFrontier,
|
|
269
276
|
persistence,
|
|
270
277
|
this._host._graphName,
|
|
271
|
-
{ codec: this._host._codec }
|
|
278
|
+
{ codec: this._host._codec, logger: this._host._logger || undefined }
|
|
272
279
|
);
|
|
273
280
|
}
|
|
274
281
|
|
|
@@ -282,18 +289,58 @@ export default class SyncController {
|
|
|
282
289
|
* @returns {{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} Result with updated state and frontier
|
|
283
290
|
* @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
|
|
284
291
|
*/
|
|
285
|
-
|
|
292
|
+
/**
|
|
293
|
+
* Applies a sync response to the local graph state.
|
|
294
|
+
* Updates the cached state with received patches.
|
|
295
|
+
*
|
|
296
|
+
* When a trust gate is configured, evaluates patch authors (writersApplied)
|
|
297
|
+
* against trust policy. In enforce mode, untrusted writers cause rejection
|
|
298
|
+
* before any state mutation.
|
|
299
|
+
*
|
|
300
|
+
* **Requires a cached state.**
|
|
301
|
+
*
|
|
302
|
+
* @param {import('./SyncProtocol.js').SyncResponse} response - The sync response
|
|
303
|
+
* @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number, trustVerdict?: string, writersApplied?: string[], skippedWriters: Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>}>} Result with updated state and frontier
|
|
304
|
+
* @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
|
|
305
|
+
* @throws {SyncError} If trust gate rejects untrusted writers (code: `E_SYNC_UNTRUSTED_WRITER`)
|
|
306
|
+
*/
|
|
307
|
+
async applySyncResponse(response) {
|
|
286
308
|
if (!this._host._cachedState) {
|
|
287
309
|
throw new QueryError(E_NO_STATE_MSG, {
|
|
288
310
|
code: 'E_NO_STATE',
|
|
289
311
|
});
|
|
290
312
|
}
|
|
291
313
|
|
|
314
|
+
// Extract actual patch authors for trust evaluation (B1)
|
|
315
|
+
const writersApplied = SyncTrustGate.extractWritersFromPatches(response.patches || []);
|
|
316
|
+
|
|
317
|
+
// Evaluate trust BEFORE applying any patches
|
|
318
|
+
if (this._trustGate && writersApplied.length > 0) {
|
|
319
|
+
const verdict = await this._trustGate.evaluate(writersApplied, {
|
|
320
|
+
graphName: this._host._graphName,
|
|
321
|
+
});
|
|
322
|
+
if (!verdict.allowed) {
|
|
323
|
+
throw new SyncError('Sync rejected: untrusted writer(s)', {
|
|
324
|
+
code: 'E_SYNC_UNTRUSTED_WRITER',
|
|
325
|
+
context: {
|
|
326
|
+
writersApplied,
|
|
327
|
+
untrustedWriters: verdict.untrustedWriters,
|
|
328
|
+
verdict: verdict.verdict,
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
292
334
|
const currentFrontier = this._host._lastFrontier || createFrontier();
|
|
293
335
|
const result = /** @type {{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} */ (applySyncResponseImpl(response, this._host._cachedState, currentFrontier));
|
|
294
336
|
|
|
295
|
-
//
|
|
296
|
-
|
|
337
|
+
// Route through canonical state-install path (B105 / C1 fix).
|
|
338
|
+
// _setMaterializedState sets _cachedState, clears _stateDirty, computes
|
|
339
|
+
// state hash, builds adjacency, and rebuilds indexes via _buildView().
|
|
340
|
+
// Bookkeeping is deferred until after install succeeds so that a failed
|
|
341
|
+
// _setMaterializedState does not leave _lastFrontier/_patchesSinceGC
|
|
342
|
+
// advanced while _cachedState remains stale.
|
|
343
|
+
await this._host._setMaterializedState(result.state);
|
|
297
344
|
|
|
298
345
|
// Keep _lastFrontier in sync so hasFrontierChanged() won't misreport stale.
|
|
299
346
|
this._host._lastFrontier = result.frontier;
|
|
@@ -301,10 +348,7 @@ export default class SyncController {
|
|
|
301
348
|
// Track patches for GC
|
|
302
349
|
this._host._patchesSinceGC += result.applied;
|
|
303
350
|
|
|
304
|
-
|
|
305
|
-
this._host._stateDirty = false;
|
|
306
|
-
|
|
307
|
-
return result;
|
|
351
|
+
return { ...result, writersApplied, skippedWriters: response.skippedWriters || [] };
|
|
308
352
|
}
|
|
309
353
|
|
|
310
354
|
/**
|
|
@@ -333,7 +377,7 @@ export default class SyncController {
|
|
|
333
377
|
* @param {(event: {type: string, attempt: number, durationMs?: number, status?: number, error?: Error}) => void} [options.onStatus]
|
|
334
378
|
* @param {boolean} [options.materialize=false] - Auto-materialize after sync
|
|
335
379
|
* @param {{ secret: string, keyId?: string }} [options.auth] - Client auth credentials
|
|
336
|
-
* @returns {Promise<{applied: number, attempts: number, state?: import('./JoinReducer.js').WarpStateV5}>}
|
|
380
|
+
* @returns {Promise<{applied: number, attempts: number, skippedWriters: Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>, state?: import('./JoinReducer.js').WarpStateV5}>}
|
|
337
381
|
*/
|
|
338
382
|
async syncWith(remote, options = {}) {
|
|
339
383
|
const t0 = this._host._clock.now();
|
|
@@ -468,12 +512,12 @@ export default class SyncController {
|
|
|
468
512
|
}
|
|
469
513
|
}
|
|
470
514
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
throw new SyncError(
|
|
476
|
-
code: '
|
|
515
|
+
// Validate response shape + resource limits via Zod schema (B64).
|
|
516
|
+
// For HTTP responses, always validate — untrusted boundary.
|
|
517
|
+
const validation = validateSyncResponse(response);
|
|
518
|
+
if (!validation.ok) {
|
|
519
|
+
throw new SyncError(`Invalid sync response: ${validation.error}`, {
|
|
520
|
+
code: 'E_SYNC_PAYLOAD_INVALID',
|
|
477
521
|
});
|
|
478
522
|
}
|
|
479
523
|
|
|
@@ -482,12 +526,12 @@ export default class SyncController {
|
|
|
482
526
|
emit('materialized');
|
|
483
527
|
}
|
|
484
528
|
|
|
485
|
-
const result = this.applySyncResponse(response);
|
|
529
|
+
const result = await this.applySyncResponse(response);
|
|
486
530
|
emit('applied', { applied: result.applied });
|
|
487
531
|
|
|
488
532
|
const durationMs = this._host._clock.now() - attemptStart;
|
|
489
533
|
emit('complete', { durationMs, applied: result.applied });
|
|
490
|
-
return { applied: result.applied, attempts: attempt };
|
|
534
|
+
return { applied: result.applied, attempts: attempt, skippedWriters: result.skippedWriters || [] };
|
|
491
535
|
};
|
|
492
536
|
|
|
493
537
|
try {
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncPayloadSchema -- Zod schemas for sync protocol messages.
|
|
3
|
+
*
|
|
4
|
+
* Validates both shape and resource limits for sync requests and responses
|
|
5
|
+
* at the trust boundary (HTTP ingress/egress). Prevents malformed or
|
|
6
|
+
* oversized payloads from reaching the CRDT merge engine.
|
|
7
|
+
*
|
|
8
|
+
* @module domain/services/SyncPayloadSchema
|
|
9
|
+
* @see B64 -- Sync ingress payload validation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
// ── Resource Limits ─────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default resource limits for sync payload validation.
|
|
18
|
+
* Configurable per-deployment via `createSyncResponseSchema(limits)`.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {Object} SyncPayloadLimits
|
|
21
|
+
* @property {number} maxWritersInFrontier - Maximum writers in a frontier object
|
|
22
|
+
* @property {number} maxPatches - Maximum patches in a sync response
|
|
23
|
+
* @property {number} maxOpsPerPatch - Maximum operations per patch
|
|
24
|
+
* @property {number} maxStringBytes - Maximum bytes for string values (writer ID, node ID, etc.)
|
|
25
|
+
* @property {number} maxBlobBytes - Maximum bytes for blob values
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @type {Readonly<SyncPayloadLimits>} */
|
|
29
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
30
|
+
maxWritersInFrontier: 10_000,
|
|
31
|
+
maxPatches: 100_000,
|
|
32
|
+
maxOpsPerPatch: 50_000,
|
|
33
|
+
maxStringBytes: 4096,
|
|
34
|
+
maxBlobBytes: 16 * 1024 * 1024,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── Schema Version ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Current sync protocol schema version.
|
|
41
|
+
* Responses with unknown versions are rejected.
|
|
42
|
+
*/
|
|
43
|
+
export const SYNC_SCHEMA_VERSION = 1;
|
|
44
|
+
|
|
45
|
+
// ── Shared Primitives ───────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bounded string: rejects strings exceeding maxStringBytes.
|
|
49
|
+
* @param {number} maxBytes
|
|
50
|
+
* @returns {z.ZodString}
|
|
51
|
+
*/
|
|
52
|
+
function boundedString(maxBytes) {
|
|
53
|
+
return z.string().max(maxBytes);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Frontier Schema ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Normalizes a frontier value that may be a Map (cbor-x decodes maps)
|
|
60
|
+
* or a plain object into a validated plain object.
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} value
|
|
63
|
+
* @returns {Record<string, string>|null} Normalized object, or null if invalid
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeFrontier(value) {
|
|
66
|
+
if (value instanceof Map) {
|
|
67
|
+
/** @type {Record<string, string>} */
|
|
68
|
+
const obj = {};
|
|
69
|
+
for (const [k, v] of value) {
|
|
70
|
+
if (typeof k !== 'string' || typeof v !== 'string') {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
obj[k] = v;
|
|
74
|
+
}
|
|
75
|
+
return obj;
|
|
76
|
+
}
|
|
77
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
78
|
+
return /** @type {Record<string, string>} */ (value);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Creates a frontier schema with a size limit.
|
|
85
|
+
*
|
|
86
|
+
* Frontier values are strings (typically hex SHAs) but we don't enforce
|
|
87
|
+
* hex format here — semantic SHA validation happens at a higher level.
|
|
88
|
+
* This schema validates shape + resource limits only.
|
|
89
|
+
*
|
|
90
|
+
* @param {number} maxWriters
|
|
91
|
+
* @returns {z.ZodType<Record<string, string>>}
|
|
92
|
+
*/
|
|
93
|
+
function frontierSchema(maxWriters) {
|
|
94
|
+
return /** @type {z.ZodType<Record<string, string>>} */ (z.record(
|
|
95
|
+
boundedString(256),
|
|
96
|
+
z.string(),
|
|
97
|
+
).refine(
|
|
98
|
+
(obj) => Object.keys(obj).length <= maxWriters,
|
|
99
|
+
(obj) => ({ message: `Frontier exceeds max writers: ${Object.keys(obj).length} > ${maxWriters}` }),
|
|
100
|
+
));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Op Schema ───────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Minimal op validation — checks for type field and basic shape.
|
|
107
|
+
* Deeper semantic validation happens in JoinReducer/WarpMessageCodec.
|
|
108
|
+
*/
|
|
109
|
+
const opSchema = z.object({
|
|
110
|
+
type: z.string(),
|
|
111
|
+
}).passthrough();
|
|
112
|
+
|
|
113
|
+
// ── Patch Schema ────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Creates a patch schema with ops limit.
|
|
117
|
+
* @param {SyncPayloadLimits} limits
|
|
118
|
+
*/
|
|
119
|
+
function patchSchema(limits) {
|
|
120
|
+
return z.object({
|
|
121
|
+
schema: z.number().int().min(1).optional(),
|
|
122
|
+
writer: boundedString(limits.maxStringBytes).optional(),
|
|
123
|
+
lamport: z.number().int().min(0).optional(),
|
|
124
|
+
ops: z.array(opSchema).max(limits.maxOpsPerPatch),
|
|
125
|
+
context: z.unknown().optional(),
|
|
126
|
+
}).passthrough();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Creates a patches-array entry schema.
|
|
131
|
+
* @param {SyncPayloadLimits} limits
|
|
132
|
+
*/
|
|
133
|
+
function patchEntrySchema(limits) {
|
|
134
|
+
return z.object({
|
|
135
|
+
writerId: boundedString(limits.maxStringBytes),
|
|
136
|
+
sha: z.string(),
|
|
137
|
+
patch: patchSchema(limits),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Sync Request Schema ─────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Creates a validated SyncRequest schema.
|
|
145
|
+
* @param {SyncPayloadLimits} [limits]
|
|
146
|
+
* @returns {z.ZodType}
|
|
147
|
+
*/
|
|
148
|
+
export function createSyncRequestSchema(limits = DEFAULT_LIMITS) {
|
|
149
|
+
return z.object({
|
|
150
|
+
type: z.literal('sync-request'),
|
|
151
|
+
frontier: frontierSchema(limits.maxWritersInFrontier),
|
|
152
|
+
}).strict();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Default SyncRequest schema with default limits */
|
|
156
|
+
export const SyncRequestSchema = createSyncRequestSchema();
|
|
157
|
+
|
|
158
|
+
// ── Sync Response Schema ────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Creates a validated SyncResponse schema.
|
|
162
|
+
* @param {SyncPayloadLimits} [limits]
|
|
163
|
+
* @returns {z.ZodType}
|
|
164
|
+
*/
|
|
165
|
+
export function createSyncResponseSchema(limits = DEFAULT_LIMITS) {
|
|
166
|
+
return z.object({
|
|
167
|
+
type: z.literal('sync-response'),
|
|
168
|
+
frontier: frontierSchema(limits.maxWritersInFrontier),
|
|
169
|
+
patches: z.array(patchEntrySchema(limits)).max(limits.maxPatches),
|
|
170
|
+
}).passthrough();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Default SyncResponse schema with default limits */
|
|
174
|
+
export const SyncResponseSchema = createSyncResponseSchema();
|
|
175
|
+
|
|
176
|
+
// ── Validation Helpers ──────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Validates a sync request payload. Returns the parsed value or throws.
|
|
180
|
+
*
|
|
181
|
+
* Handles Map→object normalization for cbor-x compatibility.
|
|
182
|
+
*
|
|
183
|
+
* @param {unknown} payload - Raw parsed payload (from JSON.parse or cbor-x decode)
|
|
184
|
+
* @param {SyncPayloadLimits} [limits] - Resource limits
|
|
185
|
+
* @returns {{ ok: true, value: { type: 'sync-request', frontier: Record<string, string> } } | { ok: false, error: string }}
|
|
186
|
+
*/
|
|
187
|
+
export function validateSyncRequest(payload, limits = DEFAULT_LIMITS) {
|
|
188
|
+
// Normalize Map frontier from cbor-x
|
|
189
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
190
|
+
const p = /** @type {Record<string, unknown>} */ (payload);
|
|
191
|
+
if (p.frontier instanceof Map) {
|
|
192
|
+
const normalized = normalizeFrontier(p.frontier);
|
|
193
|
+
if (!normalized) {
|
|
194
|
+
return { ok: false, error: 'Invalid frontier: Map contains non-string entries' };
|
|
195
|
+
}
|
|
196
|
+
p.frontier = normalized;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const schema = limits === DEFAULT_LIMITS ? SyncRequestSchema : createSyncRequestSchema(limits);
|
|
201
|
+
const result = schema.safeParse(payload);
|
|
202
|
+
if (!result.success) {
|
|
203
|
+
return { ok: false, error: result.error.message };
|
|
204
|
+
}
|
|
205
|
+
return { ok: true, value: /** @type {{ type: 'sync-request', frontier: Record<string, string> }} */ (result.data) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Validates a sync response payload. Returns the parsed value or an error.
|
|
210
|
+
*
|
|
211
|
+
* Handles Map→object normalization for cbor-x compatibility.
|
|
212
|
+
*
|
|
213
|
+
* @param {unknown} payload - Raw parsed payload
|
|
214
|
+
* @param {SyncPayloadLimits} [limits] - Resource limits
|
|
215
|
+
* @returns {{ ok: true, value: unknown } | { ok: false, error: string }}
|
|
216
|
+
*/
|
|
217
|
+
export function validateSyncResponse(payload, limits = DEFAULT_LIMITS) {
|
|
218
|
+
// Normalize Map frontier from cbor-x
|
|
219
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
220
|
+
const p = /** @type {Record<string, unknown>} */ (payload);
|
|
221
|
+
if (p.frontier instanceof Map) {
|
|
222
|
+
const normalized = normalizeFrontier(p.frontier);
|
|
223
|
+
if (!normalized) {
|
|
224
|
+
return { ok: false, error: 'Invalid frontier: Map contains non-string entries' };
|
|
225
|
+
}
|
|
226
|
+
p.frontier = normalized;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const schema = limits === DEFAULT_LIMITS ? SyncResponseSchema : createSyncResponseSchema(limits);
|
|
231
|
+
const result = schema.safeParse(payload);
|
|
232
|
+
if (!result.success) {
|
|
233
|
+
return { ok: false, error: result.error.message };
|
|
234
|
+
}
|
|
235
|
+
return { ok: true, value: result.data };
|
|
236
|
+
}
|