@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
@@ -5,9 +5,31 @@
5
5
  */
6
6
 
7
7
  import QueryError from '../errors/QueryError.js';
8
+ import { matchGlob } from '../utils/matchGlob.js';
8
9
 
9
10
  const DEFAULT_PATTERN = '*';
10
11
 
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
+
11
33
  /**
12
34
  * @typedef {Object} QueryNodeSnapshot
13
35
  * @property {string} id - The unique identifier of the node
@@ -48,15 +70,18 @@ const DEFAULT_PATTERN = '*';
48
70
  */
49
71
 
50
72
  /**
51
- * Asserts that a match pattern is a string.
73
+ * Asserts that a match pattern is a string or array of strings.
52
74
  *
53
75
  * @param {unknown} pattern - The pattern to validate
54
- * @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
76
+ * @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
55
77
  * @private
56
78
  */
57
79
  function assertMatchPattern(pattern) {
58
- if (typeof pattern !== 'string') {
59
- throw new QueryError('match() expects a string pattern', {
80
+ const isString = typeof pattern === 'string';
81
+ const isStringArray = Array.isArray(pattern) && pattern.every((p) => typeof p === 'string');
82
+
83
+ if (!isString && !isStringArray) {
84
+ throw new QueryError('match() expects a string pattern or array of string patterns', {
60
85
  code: 'E_QUERY_MATCH_TYPE',
61
86
  context: { receivedType: typeof pattern },
62
87
  });
@@ -165,41 +190,6 @@ function sortIds(ids) {
165
190
  return [...ids].sort();
166
191
  }
167
192
 
168
- /**
169
- * Escapes special regex characters in a string so it can be used as a literal match.
170
- *
171
- * @param {string} value - The string to escape
172
- * @returns {string} The escaped string safe for use in a RegExp
173
- * @private
174
- */
175
- function escapeRegex(value) {
176
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
177
- }
178
-
179
- /**
180
- * Tests whether a node ID matches a glob-style pattern.
181
- *
182
- * Supports:
183
- * - `*` as the default pattern, matching all node IDs
184
- * - Wildcard `*` anywhere in the pattern, matching zero or more characters
185
- * - Literal match when pattern contains no wildcards
186
- *
187
- * @param {string} nodeId - The node ID to test
188
- * @param {string} pattern - The glob pattern (e.g., "user:*", "*:admin", "*")
189
- * @returns {boolean} True if the node ID matches the pattern
190
- * @private
191
- */
192
- function matchesPattern(nodeId, pattern) {
193
- if (pattern === DEFAULT_PATTERN) {
194
- return true;
195
- }
196
- if (pattern.includes('*')) {
197
- const regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
198
- return regex.test(nodeId);
199
- }
200
- return nodeId === pattern;
201
- }
202
-
203
193
  /**
204
194
  * Recursively freezes an object and all nested objects/arrays.
205
195
  *
@@ -494,7 +484,7 @@ export default class QueryBuilder {
494
484
  */
495
485
  constructor(graph) {
496
486
  this._graph = graph;
497
- /** @type {string|null} */
487
+ /** @type {string|string[]|null} */
498
488
  this._pattern = null;
499
489
  /** @type {Array<{type: string, fn?: (node: QueryNodeSnapshot) => boolean, label?: string, depth?: [number, number]}>} */
500
490
  this._operations = [];
@@ -505,19 +495,21 @@ export default class QueryBuilder {
505
495
  }
506
496
 
507
497
  /**
508
- * Sets the match pattern for filtering nodes by ID.
498
+ * Sets the match pattern(s) for filtering nodes by ID.
509
499
  *
510
500
  * Supports glob-style patterns:
511
501
  * - `*` matches all nodes
512
502
  * - `user:*` matches all nodes starting with "user:"
513
503
  * - `*:admin` matches all nodes ending with ":admin"
504
+ * - Array of patterns: `['campaign:*', 'milestone:*']` (OR semantics)
514
505
  *
515
- * @param {string} pattern - Glob pattern to match node IDs against
506
+ * @param {string|string[]} pattern - Glob pattern or array of patterns to match node IDs against
516
507
  * @returns {QueryBuilder} This builder for chaining
517
- * @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
508
+ * @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
518
509
  */
519
510
  match(pattern) {
520
511
  assertMatchPattern(pattern);
512
+ /** @type {string|string[]|null} */
521
513
  this._pattern = pattern;
522
514
  return this;
523
515
  }
@@ -681,22 +673,33 @@ export default class QueryBuilder {
681
673
 
682
674
  const pattern = this._pattern ?? DEFAULT_PATTERN;
683
675
 
676
+ // Per-run props memo to avoid redundant getNodeProps calls
677
+ /** @type {Map<string, Map<string, unknown>>} */
678
+ const propsMemo = new Map();
679
+ const getProps = async (/** @type {string} */ nodeId) => {
680
+ const cached = propsMemo.get(nodeId);
681
+ if (cached !== undefined) {
682
+ return cached;
683
+ }
684
+ const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
685
+ propsMemo.set(nodeId, propsMap);
686
+ return propsMap;
687
+ };
688
+
684
689
  let workingSet;
685
- workingSet = allNodes.filter((nodeId) => matchesPattern(nodeId, pattern));
690
+ workingSet = allNodes.filter((nodeId) => matchGlob(pattern, nodeId));
686
691
 
687
692
  for (const op of this._operations) {
688
693
  if (op.type === 'where') {
689
- const snapshots = await Promise.all(
690
- workingSet.map(async (nodeId) => {
691
- const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
692
- const edgesOut = adjacency.outgoing.get(nodeId) || [];
693
- const edgesIn = adjacency.incoming.get(nodeId) || [];
694
- return {
695
- nodeId,
696
- snapshot: createNodeSnapshot({ id: nodeId, propsMap, edgesOut, edgesIn }),
697
- };
698
- })
699
- );
694
+ const snapshots = await batchMap(workingSet, async (nodeId) => {
695
+ const propsMap = await getProps(nodeId);
696
+ const edgesOut = adjacency.outgoing.get(nodeId) || [];
697
+ const edgesIn = adjacency.incoming.get(nodeId) || [];
698
+ return {
699
+ nodeId,
700
+ snapshot: createNodeSnapshot({ id: nodeId, propsMap, edgesOut, edgesIn }),
701
+ };
702
+ });
700
703
  const predicate = /** @type {(node: QueryNodeSnapshot) => boolean} */ (op.fn);
701
704
  const filtered = snapshots
702
705
  .filter(({ snapshot }) => predicate(snapshot))
@@ -727,7 +730,7 @@ export default class QueryBuilder {
727
730
  }
728
731
 
729
732
  if (this._aggregate) {
730
- return await this._runAggregate(workingSet, stateHash);
733
+ return await this._runAggregate(workingSet, stateHash, getProps);
731
734
  }
732
735
 
733
736
  const selected = this._select;
@@ -747,22 +750,20 @@ export default class QueryBuilder {
747
750
  const includeId = !selectFields || selectFields.includes('id');
748
751
  const includeProps = !selectFields || selectFields.includes('props');
749
752
 
750
- const nodes = await Promise.all(
751
- workingSet.map(async (nodeId) => {
752
- const entry = {};
753
- if (includeId) {
754
- entry.id = nodeId;
755
- }
756
- if (includeProps) {
757
- const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
758
- const props = buildPropsSnapshot(propsMap);
759
- if (selectFields || Object.keys(props).length > 0) {
760
- entry.props = props;
761
- }
753
+ const nodes = await batchMap(workingSet, async (nodeId) => {
754
+ const entry = {};
755
+ if (includeId) {
756
+ entry.id = nodeId;
757
+ }
758
+ if (includeProps) {
759
+ const propsMap = await getProps(nodeId);
760
+ const props = buildPropsSnapshot(propsMap);
761
+ if (selectFields || Object.keys(props).length > 0) {
762
+ entry.props = props;
762
763
  }
763
- return entry;
764
- })
765
- );
764
+ }
765
+ return entry;
766
+ });
766
767
 
767
768
  return { stateHash, nodes };
768
769
  }
@@ -776,10 +777,11 @@ export default class QueryBuilder {
776
777
  *
777
778
  * @param {string[]} workingSet - Array of matched node IDs
778
779
  * @param {string} stateHash - Hash of the materialized state
780
+ * @param {(nodeId: string) => Promise<Map<string, unknown>>} getProps - Memoized props fetcher
779
781
  * @returns {Promise<AggregateResult>} Object containing stateHash and requested aggregation values
780
782
  * @private
781
783
  */
782
- async _runAggregate(workingSet, stateHash) {
784
+ async _runAggregate(workingSet, stateHash, getProps) {
783
785
  const spec = /** @type {AggregateSpec} */ (this._aggregate);
784
786
  /** @type {AggregateResult} */
785
787
  const result = { stateHash };
@@ -802,8 +804,10 @@ export default class QueryBuilder {
802
804
  });
803
805
  }
804
806
 
805
- for (const nodeId of workingSet) {
806
- const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
807
+ // Pre-fetch all props with bounded concurrency
808
+ const propsList = await batchMap(workingSet, getProps);
809
+
810
+ for (const propsMap of propsList) {
807
811
  for (const { segments, values } of propsByAgg.values()) {
808
812
  /** @type {unknown} */
809
813
  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
 
@@ -128,10 +130,14 @@ async function buildSyncAuthHeaders({ auth, bodyStr, targetUrl, crypto }) {
128
130
  export default class SyncController {
129
131
  /**
130
132
  * @param {SyncHost} host - The WarpGraph instance (or any object satisfying SyncHost)
133
+ * @param {Object} [options]
134
+ * @param {SyncTrustGate} [options.trustGate] - Trust gate for evaluating patch authors
131
135
  */
132
- constructor(host) {
136
+ constructor(host, options = {}) {
133
137
  /** @type {SyncHost} */
134
138
  this._host = host;
139
+ /** @type {SyncTrustGate|null} */
140
+ this._trustGate = options.trustGate || null;
135
141
  }
136
142
 
137
143
  /**
@@ -268,7 +274,7 @@ export default class SyncController {
268
274
  localFrontier,
269
275
  persistence,
270
276
  this._host._graphName,
271
- { codec: this._host._codec }
277
+ { codec: this._host._codec, logger: this._host._logger || undefined }
272
278
  );
273
279
  }
274
280
 
@@ -282,13 +288,48 @@ export default class SyncController {
282
288
  * @returns {{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} Result with updated state and frontier
283
289
  * @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
284
290
  */
285
- applySyncResponse(response) {
291
+ /**
292
+ * Applies a sync response to the local graph state.
293
+ * Updates the cached state with received patches.
294
+ *
295
+ * When a trust gate is configured, evaluates patch authors (writersApplied)
296
+ * against trust policy. In enforce mode, untrusted writers cause rejection
297
+ * before any state mutation.
298
+ *
299
+ * **Requires a cached state.**
300
+ *
301
+ * @param {import('./SyncProtocol.js').SyncResponse} response - The sync response
302
+ * @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number, trustVerdict?: string, writersApplied?: string[]}>} Result with updated state and frontier
303
+ * @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
304
+ * @throws {SyncError} If trust gate rejects untrusted writers (code: `E_SYNC_UNTRUSTED_WRITER`)
305
+ */
306
+ async applySyncResponse(response) {
286
307
  if (!this._host._cachedState) {
287
308
  throw new QueryError(E_NO_STATE_MSG, {
288
309
  code: 'E_NO_STATE',
289
310
  });
290
311
  }
291
312
 
313
+ // Extract actual patch authors for trust evaluation (B1)
314
+ const writersApplied = SyncTrustGate.extractWritersFromPatches(response.patches || []);
315
+
316
+ // Evaluate trust BEFORE applying any patches
317
+ if (this._trustGate && writersApplied.length > 0) {
318
+ const verdict = await this._trustGate.evaluate(writersApplied, {
319
+ graphName: this._host._graphName,
320
+ });
321
+ if (!verdict.allowed) {
322
+ throw new SyncError('Sync rejected: untrusted writer(s)', {
323
+ code: 'E_SYNC_UNTRUSTED_WRITER',
324
+ context: {
325
+ writersApplied,
326
+ untrustedWriters: verdict.untrustedWriters,
327
+ verdict: verdict.verdict,
328
+ },
329
+ });
330
+ }
331
+ }
332
+
292
333
  const currentFrontier = this._host._lastFrontier || createFrontier();
293
334
  const result = /** @type {{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} */ (applySyncResponseImpl(response, this._host._cachedState, currentFrontier));
294
335
 
@@ -301,10 +342,32 @@ export default class SyncController {
301
342
  // Track patches for GC
302
343
  this._host._patchesSinceGC += result.applied;
303
344
 
345
+ // Invalidate derived caches (C1) — sync changes underlying state
346
+ this._invalidateDerivedCaches();
347
+
304
348
  // State is now in sync with the frontier -- clear dirty flag
305
349
  this._host._stateDirty = false;
306
350
 
307
- return result;
351
+ return { ...result, writersApplied };
352
+ }
353
+
354
+ /**
355
+ * Invalidates all derived caches on the host graph.
356
+ *
357
+ * Called after sync apply or join to ensure stale index/provider/view
358
+ * data is not returned to callers. The next query or traversal will
359
+ * trigger a rebuild.
360
+ *
361
+ * @private
362
+ */
363
+ _invalidateDerivedCaches() {
364
+ const h = /** @type {import('../WarpGraph.js').default} */ (this._host);
365
+ h._materializedGraph = null;
366
+ h._logicalIndex = null;
367
+ h._propertyReader = null;
368
+ h._cachedViewHash = null;
369
+ h._cachedIndexTree = null;
370
+ h._stateDirty = true;
308
371
  }
309
372
 
310
373
  /**
@@ -468,12 +531,12 @@ export default class SyncController {
468
531
  }
469
532
  }
470
533
 
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',
534
+ // Validate response shape + resource limits via Zod schema (B64).
535
+ // For HTTP responses, always validate — untrusted boundary.
536
+ const validation = validateSyncResponse(response);
537
+ if (!validation.ok) {
538
+ throw new SyncError(`Invalid sync response: ${validation.error}`, {
539
+ code: 'E_SYNC_PAYLOAD_INVALID',
477
540
  });
478
541
  }
479
542
 
@@ -482,7 +545,7 @@ export default class SyncController {
482
545
  emit('materialized');
483
546
  }
484
547
 
485
- const result = this.applySyncResponse(response);
548
+ const result = await this.applySyncResponse(response);
486
549
  emit('applied', { applied: result.applied });
487
550
 
488
551
  const durationMs = this._host._clock.now() - attemptStart;
@@ -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
+ }