@git-stunts/git-warp 12.1.0 → 12.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +8 -4
  2. package/bin/cli/commands/trust.js +37 -1
  3. package/bin/cli/infrastructure.js +14 -1
  4. package/bin/cli/schemas.js +4 -4
  5. package/bin/warp-graph.js +9 -2
  6. package/index.d.ts +18 -2
  7. package/package.json +1 -1
  8. package/src/domain/WarpGraph.js +4 -1
  9. package/src/domain/crdt/Dot.js +5 -0
  10. package/src/domain/crdt/LWW.js +3 -1
  11. package/src/domain/crdt/ORSet.js +63 -27
  12. package/src/domain/crdt/VersionVector.js +12 -0
  13. package/src/domain/errors/PatchError.js +27 -0
  14. package/src/domain/errors/StorageError.js +8 -0
  15. package/src/domain/errors/SyncError.js +1 -0
  16. package/src/domain/errors/TrustError.js +2 -0
  17. package/src/domain/errors/WriterError.js +5 -0
  18. package/src/domain/errors/index.js +1 -0
  19. package/src/domain/services/AuditVerifierService.js +32 -2
  20. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  21. package/src/domain/services/CheckpointService.js +12 -8
  22. package/src/domain/services/Frontier.js +18 -0
  23. package/src/domain/services/GCPolicy.js +25 -4
  24. package/src/domain/services/GraphTraversal.js +11 -50
  25. package/src/domain/services/HttpSyncServer.js +18 -29
  26. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  27. package/src/domain/services/JoinReducer.js +164 -31
  28. package/src/domain/services/MaterializedViewService.js +13 -2
  29. package/src/domain/services/PatchBuilderV2.js +210 -145
  30. package/src/domain/services/QueryBuilder.js +67 -30
  31. package/src/domain/services/SyncController.js +62 -18
  32. package/src/domain/services/SyncPayloadSchema.js +236 -0
  33. package/src/domain/services/SyncProtocol.js +102 -40
  34. package/src/domain/services/SyncTrustGate.js +146 -0
  35. package/src/domain/services/TranslationCost.js +2 -2
  36. package/src/domain/trust/TrustRecordService.js +161 -34
  37. package/src/domain/utils/CachedValue.js +34 -5
  38. package/src/domain/utils/EventId.js +4 -1
  39. package/src/domain/utils/LRUCache.js +3 -1
  40. package/src/domain/utils/RefLayout.js +4 -0
  41. package/src/domain/utils/canonicalStringify.js +48 -18
  42. package/src/domain/utils/matchGlob.js +7 -0
  43. package/src/domain/warp/PatchSession.js +30 -24
  44. package/src/domain/warp/Writer.js +12 -5
  45. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  46. package/src/domain/warp/checkpoint.methods.js +102 -16
  47. package/src/domain/warp/materialize.methods.js +47 -5
  48. package/src/domain/warp/materializeAdvanced.methods.js +52 -10
  49. package/src/domain/warp/patch.methods.js +24 -8
  50. package/src/domain/warp/query.methods.js +4 -4
  51. package/src/domain/warp/subscribe.methods.js +11 -19
  52. package/src/infrastructure/adapters/GitGraphAdapter.js +57 -54
  53. package/src/infrastructure/codecs/CborCodec.js +2 -0
  54. package/src/domain/utils/fnv1a.js +0 -20
@@ -9,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 Promise.all(
661
- workingSet.map(async (nodeId) => {
662
- const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
663
- const edgesOut = adjacency.outgoing.get(nodeId) || [];
664
- const edgesIn = adjacency.incoming.get(nodeId) || [];
665
- return {
666
- nodeId,
667
- snapshot: createNodeSnapshot({ id: nodeId, propsMap, edgesOut, edgesIn }),
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 Promise.all(
722
- workingSet.map(async (nodeId) => {
723
- const entry = {};
724
- if (includeId) {
725
- entry.id = nodeId;
726
- }
727
- if (includeProps) {
728
- const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
729
- const props = buildPropsSnapshot(propsMap);
730
- if (selectFields || Object.keys(props).length > 0) {
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
- return entry;
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
- for (const nodeId of workingSet) {
777
- const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
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
- applySyncResponse(response) {
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
- // Update cached state
296
- this._host._cachedState = result.state;
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
- // State is now in sync with the frontier -- clear dirty flag
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
- if (!response || typeof response !== 'object' ||
472
- response.type !== 'sync-response' ||
473
- !response.frontier || typeof response.frontier !== 'object' || Array.isArray(response.frontier) ||
474
- !Array.isArray(response.patches)) {
475
- throw new SyncError('Invalid sync response', {
476
- code: 'E_SYNC_PROTOCOL',
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
+ }