@hraness/oh 0.3.2 → 0.4.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 (57) hide show
  1. package/README.md +164 -114
  2. package/dist/cli.d.ts +1 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +811 -97
  5. package/dist/errors.d.ts +39 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/graph.d.ts.map +1 -1
  8. package/dist/index.js +676 -61
  9. package/dist/libsql.d.ts.map +1 -1
  10. package/dist/libsql.js +162 -35
  11. package/dist/memory-page.js +2 -2
  12. package/dist/memory.d.ts +87 -6
  13. package/dist/memory.d.ts.map +1 -1
  14. package/dist/memory.js +1106 -148
  15. package/dist/operation.d.ts +3 -1
  16. package/dist/operation.d.ts.map +1 -1
  17. package/dist/projection-public.js +2 -2
  18. package/dist/projection-suss.js +2 -2
  19. package/dist/sdk.js +780 -88
  20. package/dist/semantic-cloud.js +2 -2
  21. package/dist/semantic.js +2 -2
  22. package/dist/sqlite/index.js +1251 -306
  23. package/dist/sqlite/port.d.ts +31 -3
  24. package/dist/sqlite/port.d.ts.map +1 -1
  25. package/dist/sqlite/store.d.ts +18 -2
  26. package/dist/sqlite/store.d.ts.map +1 -1
  27. package/dist/store.d.ts +3 -12
  28. package/dist/store.d.ts.map +1 -1
  29. package/dist/store.js +154 -32
  30. package/dist/sync.d.ts +7 -1
  31. package/dist/sync.d.ts.map +1 -1
  32. package/dist/sync.js +668 -35
  33. package/package.json +5 -1
  34. package/skills/oh/SKILL.md +42 -16
  35. package/spec/README.md +2 -2
  36. package/spec/v1/memory.md +134 -16
  37. package/spec/v1/storage.md +8 -5
  38. package/spec/v1/store.md +20 -0
  39. package/spec/v1/sync.md +77 -8
  40. package/src/cli.test.ts +53 -1
  41. package/src/cli.ts +34 -8
  42. package/src/errors.test.ts +87 -0
  43. package/src/errors.ts +185 -0
  44. package/src/graph.ts +2 -2
  45. package/src/libsql.test.ts +36 -0
  46. package/src/libsql.ts +26 -5
  47. package/src/memory.test.ts +1488 -18
  48. package/src/memory.ts +1199 -122
  49. package/src/operation.ts +13 -3
  50. package/src/sqlite/port.test.ts +209 -0
  51. package/src/sqlite/port.ts +118 -4
  52. package/src/sqlite/store.test.ts +106 -1
  53. package/src/sqlite/store.ts +168 -30
  54. package/src/store.test.ts +12 -0
  55. package/src/store.ts +30 -20
  56. package/src/sync.test.ts +570 -2
  57. package/src/sync.ts +586 -36
package/src/sync.ts CHANGED
@@ -1,15 +1,20 @@
1
+ import { isProxy } from "node:util/types";
2
+
1
3
  import {
2
4
  canonicalJson,
3
5
  canonicalSha256,
4
- hasExactKeys,
5
- isPlainRecord,
6
+ parseCanonicalInstantV1,
6
7
  parseSha256Hex,
7
8
  safeCode,
9
+ utf8ByteLength,
8
10
  type Sha256Hex,
9
11
  } from "./canonical";
10
12
  import { OH_CONTRACT_MANIFEST_V1, parseOhContractManifestV1, type OhContractManifestV1 } from "./contract";
11
- import { parseOhOperationV1, type OhOperationV1 } from "./operation";
13
+ import { OH_GRAPH_LIMITS_V1 } from "./graph";
14
+ import { OH_CONTRACT_ID_V1 } from "./ontology";
15
+ import { OH_OPERATION_MAX_BYTES_V1, parseOhOperationV1, type OhOperationV1 } from "./operation";
12
16
  import type { OhSqliteStore } from "./sqlite/store";
17
+ import type { OhHeadRefV1 } from "./store";
13
18
 
14
19
  export const OH_SYNC_PROTOCOL_V1 = "oh.sync.v1" as const;
15
20
 
@@ -19,21 +24,387 @@ export type OhSyncHeadV1 = Readonly<{
19
24
  v: 1;
20
25
  }>;
21
26
 
22
- export type OhSyncBundleV1 = Readonly<{
23
- bundleSha256: Sha256Hex;
24
- contractSha256: Sha256Hex;
25
- operations: readonly OhOperationV1[];
26
- protocol: typeof OH_SYNC_PROTOCOL_V1;
27
- spaceId: string;
28
- v: 1;
27
+ const OH_SYNC_BUNDLE_MAX_OPERATIONS_V1 = 1000;
28
+ const OH_SYNC_BUNDLE_KEYS_V1 = [
29
+ "bundleSha256", "contractSha256", "operations", "protocol", "spaceId", "v",
30
+ ] as const;
31
+ const OH_SYNC_HEAD_KEYS_V1 = ["operationSha256", "sequence", "v"] as const;
32
+ const OH_SYNC_HEAD_REF_KEYS_V1 = ["operationSha256", "sequence"] as const;
33
+ const OH_OPERATION_KEYS_V1 = [
34
+ "actorId", "changes", "contractId", "graphRevisionSha256", "instant", "operationId",
35
+ "operationSha256", "parentOperationSha256", "recordsSha256", "sequence", "spaceId", "v",
36
+ ] as const;
37
+ const OH_PUT_CHANGE_KEYS_V1 = ["kind", "record", "v"] as const;
38
+ const OH_TOMBSTONE_CHANGE_KEYS_V1 = ["key", "kind", "priorSha256", "v"] as const;
39
+ const OH_RECORD_KEYS_V1 = ["dependencies", "key", "kind", "recordSha256", "v", "value"] as const;
40
+ const OH_SYNC_INGRESS_VALUE_DEPTH_V1 = 128;
41
+ const OH_SYNC_INGRESS_VALUE_NODES_V1 = OH_GRAPH_LIMITS_V1.recordBytes;
42
+ const OH_SYNC_INGRESS_OPERATION_DEPTH_V1 = OH_SYNC_INGRESS_VALUE_DEPTH_V1 + 4;
43
+ const OH_SYNC_INGRESS_OPERATION_NODES_V1 = OH_OPERATION_MAX_BYTES_V1;
44
+ export const OH_SYNC_BUNDLE_MAX_BYTES_V1 = OH_OPERATION_MAX_BYTES_V1 + 4 * 1024;
45
+ const OH_SYNC_INGRESS_BUNDLE_NODES_V1 = OH_SYNC_INGRESS_OPERATION_NODES_V1 + 4 * 1024;
46
+
47
+ type SyncIngressBudgetV1 = {
48
+ bytes: number;
49
+ maximumBytes: number;
50
+ maximumNodes: number;
51
+ nodes: number;
52
+ };
53
+
54
+ type SyncIngressTraversalLimitsV1 = Readonly<{
55
+ maximumBytes: number;
56
+ maximumDepth: number;
57
+ maximumNodes: number;
29
58
  }>;
30
59
 
31
- export function createOhSyncBundleV1(spaceId: string, operations: readonly OhOperationV1[]): OhSyncBundleV1 {
32
- const parsedSpaceId = safeCode(spaceId);
33
- if (parsedSpaceId === null || operations.length > 1000) throw new TypeError("Invalid sync bundle.");
60
+ function exactDataRecordV1(value: unknown, expectedKeys: readonly string[]): Record<string, unknown> | null {
61
+ try {
62
+ if (typeof value !== "object" || value === null || Array.isArray(value) || isProxy(value)) return null;
63
+ const prototype = Object.getPrototypeOf(value);
64
+ const keys = Reflect.ownKeys(value);
65
+ if ((prototype !== Object.prototype && prototype !== null)
66
+ || keys.length !== expectedKeys.length || keys.some((key) => typeof key !== "string")
67
+ || expectedKeys.some((key) => !keys.includes(key))) return null;
68
+ const detached = Object.create(null) as Record<string, unknown>;
69
+ for (const key of expectedKeys) {
70
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
71
+ if (descriptor === undefined || !descriptor.enumerable
72
+ || descriptor.get !== undefined || descriptor.set !== undefined) return null;
73
+ Object.defineProperty(detached, key, { configurable: false, enumerable: true,
74
+ value: descriptor.value, writable: false });
75
+ }
76
+ return detached;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ function exactDataArrayV1(
83
+ value: unknown,
84
+ maximumLength: number,
85
+ clone = true,
86
+ ): readonly unknown[] | null {
87
+ try {
88
+ if (typeof value !== "object" || value === null || isProxy(value) || !Array.isArray(value)) return null;
89
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
90
+ const length = lengthDescriptor?.value;
91
+ if (typeof length !== "number" || !Number.isSafeInteger(length)
92
+ || length < 0 || length > maximumLength) return null;
93
+ const keys = Reflect.ownKeys(value);
94
+ if (keys.length !== length + 1 || !keys.includes("length")
95
+ || keys.some((key) => key !== "length" && (typeof key !== "string"
96
+ || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= length))) return null;
97
+ const detached: unknown[] | null = clone ? [] : null;
98
+ for (let index = 0; index < length; index += 1) {
99
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
100
+ if (descriptor === undefined || !descriptor.enumerable
101
+ || descriptor.get !== undefined || descriptor.set !== undefined) return null;
102
+ detached?.push(descriptor.value);
103
+ }
104
+ return detached ?? value as readonly unknown[];
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function stagedSyncOperationV1(
111
+ value: unknown,
112
+ bundleBudget: SyncIngressBudgetV1,
113
+ ): Record<string, unknown> | null {
114
+ const operation = exactDataRecordV1(value, OH_OPERATION_KEYS_V1);
115
+ if (operation === null || operation.v !== 1 || operation.contractId !== OH_CONTRACT_ID_V1
116
+ || safeCode(operation.actorId) === null || safeCode(operation.operationId) === null
117
+ || safeCode(operation.spaceId) === null || parseCanonicalInstantV1(operation.instant) === null
118
+ || parseSha256Hex(operation.operationSha256) === null
119
+ || parseSha256Hex(operation.graphRevisionSha256) === null
120
+ || parseSha256Hex(operation.recordsSha256) === null) return null;
121
+ const parentOperationSha256 = operation.parentOperationSha256 === null
122
+ ? null : parseSha256Hex(operation.parentOperationSha256);
123
+ const sequence = Number.isSafeInteger(operation.sequence) && (operation.sequence as number) > 0
124
+ ? operation.sequence as number : null;
125
+ if (sequence === null || (operation.parentOperationSha256 !== null && parentOperationSha256 === null)
126
+ || ((sequence === 1) !== (parentOperationSha256 === null))) return null;
127
+ const changes = exactDataArrayV1(
128
+ operation.changes, OH_GRAPH_LIMITS_V1.changesPerOperation, false);
129
+ if (changes === null || changes.length === 0) return null;
130
+ const aggregateDataBudget: SyncIngressBudgetV1 = {
131
+ bytes: 0,
132
+ maximumBytes: OH_OPERATION_MAX_BYTES_V1,
133
+ maximumNodes: OH_SYNC_INGRESS_OPERATION_NODES_V1,
134
+ nodes: 0,
135
+ };
136
+ for (const change of changes) {
137
+ const put = exactDataRecordV1(change, OH_PUT_CHANGE_KEYS_V1);
138
+ if (put !== null && put.kind === "put" && put.v === 1) {
139
+ const record = exactDataRecordV1(put.record, OH_RECORD_KEYS_V1);
140
+ if (record === null) return null;
141
+ const valuePreflight = preflightSyncIngressValueV1(record.value, aggregateDataBudget);
142
+ const dependenciesPreflight = preflightSyncIngressDependenciesV1(
143
+ record.dependencies, aggregateDataBudget);
144
+ if (valuePreflight === null || dependenciesPreflight === null) return null;
145
+ continue;
146
+ }
147
+ const tombstone = exactDataRecordV1(change, OH_TOMBSTONE_CHANGE_KEYS_V1);
148
+ if (tombstone === null || tombstone.kind !== "tombstone" || tombstone.v !== 1) return null;
149
+ }
150
+ const detached = boundedSyncIngressV1(operation, {
151
+ maximumBytes: OH_OPERATION_MAX_BYTES_V1,
152
+ maximumDepth: OH_SYNC_INGRESS_OPERATION_DEPTH_V1,
153
+ maximumNodes: OH_SYNC_INGRESS_OPERATION_NODES_V1,
154
+ }, bundleBudget, true);
155
+ return detached === null ? null : detached.value as Record<string, unknown>;
156
+ }
157
+
158
+ function canonicalStringBytesV1(value: string, maximumBytes: number): number {
159
+ if (value.length + 2 > maximumBytes) throw new RangeError("String exceeds its canonical byte budget.");
160
+ let bytes = 2;
161
+ for (let index = 0; index < value.length; index += 1) {
162
+ const code = value.charCodeAt(index);
163
+ if (code >= 0xd800 && code <= 0xdbff) {
164
+ const next = value.charCodeAt(index + 1);
165
+ if (!(next >= 0xdc00 && next <= 0xdfff)) throw new TypeError("Invalid Unicode string.");
166
+ bytes += 4;
167
+ index += 1;
168
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
169
+ throw new TypeError("Invalid Unicode string.");
170
+ } else if (code === 0x22 || code === 0x5c || code === 0x08 || code === 0x09
171
+ || code === 0x0a || code === 0x0c || code === 0x0d) {
172
+ bytes += 2;
173
+ } else if (code <= 0x1f) {
174
+ bytes += 6;
175
+ } else if (code <= 0x7f) {
176
+ bytes += 1;
177
+ } else if (code <= 0x7ff) {
178
+ bytes += 2;
179
+ } else {
180
+ bytes += 3;
181
+ }
182
+ if (bytes > maximumBytes) throw new RangeError("String exceeds its canonical byte budget.");
183
+ }
184
+ return bytes;
185
+ }
186
+
187
+ function boundedSyncIngressV1(
188
+ value: unknown,
189
+ limits: SyncIngressTraversalLimitsV1,
190
+ aggregate: SyncIngressBudgetV1 | null,
191
+ clone: boolean,
192
+ ): Readonly<{ value: unknown }> | null {
193
+ const ancestors = new Set<object>();
194
+ const budget: SyncIngressBudgetV1 = {
195
+ bytes: 0,
196
+ maximumBytes: limits.maximumBytes,
197
+ maximumNodes: limits.maximumNodes,
198
+ nodes: 0,
199
+ };
200
+ const canSpendBytes = (count: number): boolean => budget.bytes + count <= budget.maximumBytes
201
+ && (aggregate === null || aggregate.bytes + count <= aggregate.maximumBytes);
202
+ const spendBytes = (count: number): void => {
203
+ if (!canSpendBytes(count)) throw new RangeError("Sync ingress exceeds its canonical byte budget.");
204
+ budget.bytes += count;
205
+ if (aggregate !== null) aggregate.bytes += count;
206
+ };
207
+ const canSpendNodes = (count: number): boolean => budget.nodes + count <= budget.maximumNodes
208
+ && (aggregate === null || aggregate.nodes + count <= aggregate.maximumNodes);
209
+ const spendNode = (): void => {
210
+ if (!canSpendNodes(1)) throw new RangeError("Sync ingress exceeds its node budget.");
211
+ budget.nodes += 1;
212
+ if (aggregate !== null) aggregate.nodes += 1;
213
+ };
214
+ const detach = (candidate: unknown, depth: number): unknown => {
215
+ if (depth > limits.maximumDepth) throw new RangeError("Sync ingress exceeds its depth budget.");
216
+ spendNode();
217
+ if (candidate === null) {
218
+ spendBytes(4);
219
+ return candidate;
220
+ }
221
+ if (typeof candidate === "boolean") {
222
+ spendBytes(candidate ? 4 : 5);
223
+ return candidate;
224
+ }
225
+ if (typeof candidate === "string") {
226
+ spendBytes(canonicalStringBytesV1(candidate, Math.min(
227
+ budget.maximumBytes - budget.bytes,
228
+ aggregate === null ? Number.MAX_SAFE_INTEGER : aggregate.maximumBytes - aggregate.bytes,
229
+ )));
230
+ return candidate;
231
+ }
232
+ if (typeof candidate === "number") {
233
+ if (!Number.isFinite(candidate) || Object.is(candidate, -0)) throw new TypeError("Invalid number.");
234
+ spendBytes(utf8ByteLength(canonicalJson(candidate)));
235
+ return candidate;
236
+ }
237
+ if (typeof candidate !== "object" || isProxy(candidate)) throw new TypeError("Invalid data value.");
238
+ if (ancestors.has(candidate)) throw new TypeError("Cyclic data value.");
239
+ ancestors.add(candidate);
240
+ try {
241
+ if (Array.isArray(candidate)) {
242
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(candidate, "length");
243
+ const length = lengthDescriptor?.value;
244
+ if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0
245
+ || !canSpendNodes(length)
246
+ || !canSpendBytes(2 + Math.max(0, length - 1) + length)) {
247
+ throw new TypeError("Invalid or over-budget data array.");
248
+ }
249
+ const keys = Reflect.ownKeys(candidate);
250
+ if (keys.length !== length + 1 || !keys.includes("length")
251
+ || keys.some((key) => key !== "length" && (typeof key !== "string"
252
+ || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= length))) {
253
+ throw new TypeError("Invalid data array.");
254
+ }
255
+ spendBytes(2 + Math.max(0, length - 1));
256
+ const detached: unknown[] | null = clone ? [] : null;
257
+ for (let index = 0; index < length; index += 1) {
258
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index));
259
+ if (descriptor === undefined || !descriptor.enumerable
260
+ || descriptor.get !== undefined || descriptor.set !== undefined) {
261
+ throw new TypeError("Invalid data array entry.");
262
+ }
263
+ const item = detach(descriptor.value, depth + 1);
264
+ detached?.push(item);
265
+ }
266
+ return detached === null ? candidate : Object.freeze(detached);
267
+ }
268
+ const prototype = Object.getPrototypeOf(candidate);
269
+ if (prototype !== Object.prototype && prototype !== null) {
270
+ throw new TypeError("Invalid data object.");
271
+ }
272
+ let containerBytes = 2;
273
+ let properties = 0;
274
+ for (const key in candidate) {
275
+ if (!Object.hasOwn(candidate, key)) continue;
276
+ properties += 1;
277
+ if (!canSpendNodes(properties)) throw new RangeError("Sync ingress exceeds its node budget.");
278
+ const remainingBytes = Math.min(
279
+ budget.maximumBytes - budget.bytes - containerBytes - properties,
280
+ aggregate === null ? Number.MAX_SAFE_INTEGER
281
+ : aggregate.maximumBytes - aggregate.bytes - containerBytes - properties,
282
+ );
283
+ containerBytes += (properties === 1 ? 0 : 1)
284
+ + canonicalStringBytesV1(key, remainingBytes) + 1;
285
+ if (!canSpendBytes(containerBytes + properties)) {
286
+ throw new RangeError("Sync ingress exceeds its canonical byte budget.");
287
+ }
288
+ }
289
+ const keys = Reflect.ownKeys(candidate);
290
+ if (keys.length !== properties || keys.some((key) => typeof key !== "string")) {
291
+ throw new TypeError("Invalid data object.");
292
+ }
293
+ spendBytes(containerBytes);
294
+ const detached: Record<string, unknown> | null = clone
295
+ ? Object.create(null) as Record<string, unknown>
296
+ : null;
297
+ for (const key of keys as string[]) {
298
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
299
+ if (descriptor === undefined || !descriptor.enumerable
300
+ || descriptor.get !== undefined || descriptor.set !== undefined) {
301
+ throw new TypeError("Invalid data property.");
302
+ }
303
+ const item = detach(descriptor.value, depth + 1);
304
+ if (detached !== null) {
305
+ Object.defineProperty(detached, key, { configurable: false, enumerable: true,
306
+ value: item, writable: false });
307
+ }
308
+ }
309
+ return detached === null ? candidate : Object.freeze(detached);
310
+ } finally {
311
+ ancestors.delete(candidate);
312
+ }
313
+ };
314
+ try {
315
+ return Object.freeze({ value: detach(value, 0) });
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+
321
+ function preflightSyncIngressValueV1(
322
+ value: unknown,
323
+ aggregate: SyncIngressBudgetV1,
324
+ ): Readonly<{ value: unknown }> | null {
325
+ return boundedSyncIngressV1(value, {
326
+ maximumBytes: OH_GRAPH_LIMITS_V1.recordBytes,
327
+ maximumDepth: OH_SYNC_INGRESS_VALUE_DEPTH_V1,
328
+ maximumNodes: OH_SYNC_INGRESS_VALUE_NODES_V1,
329
+ }, aggregate, false);
330
+ }
331
+
332
+ function preflightSyncIngressDependenciesV1(
333
+ value: unknown,
334
+ aggregate: SyncIngressBudgetV1,
335
+ ): Readonly<{ value: unknown }> | null {
336
+ try {
337
+ if (typeof value !== "object" || value === null || isProxy(value) || !Array.isArray(value)) {
338
+ return null;
339
+ }
340
+ const length = Object.getOwnPropertyDescriptor(value, "length")?.value;
341
+ if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0
342
+ || length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord) return null;
343
+ // Every valid dependency is an ASCII record key of at most 512 characters.
344
+ const maximumBytes = length === 0 ? 2 : 1 + length * 515;
345
+ return boundedSyncIngressV1(value, {
346
+ maximumBytes,
347
+ maximumDepth: 1,
348
+ maximumNodes: length + 1,
349
+ }, aggregate, false);
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ function syncIngressBundleBudgetV1(spaceId: string): SyncIngressBudgetV1 {
356
+ const emptyBundle = {
357
+ bundleSha256: "0".repeat(64),
358
+ contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256,
359
+ operations: [],
360
+ protocol: OH_SYNC_PROTOCOL_V1,
361
+ spaceId,
362
+ v: 1,
363
+ };
364
+ return {
365
+ bytes: utf8ByteLength(canonicalJson(emptyBundle)),
366
+ maximumBytes: OH_SYNC_BUNDLE_MAX_BYTES_V1,
367
+ maximumNodes: OH_SYNC_INGRESS_BUNDLE_NODES_V1,
368
+ // Root, five scalar fields, and the operations array.
369
+ nodes: 7,
370
+ };
371
+ }
372
+
373
+ function spendSyncIngressBudgetV1(
374
+ budget: SyncIngressBudgetV1,
375
+ bytes: number,
376
+ nodes = 0,
377
+ ): boolean {
378
+ if (budget.bytes + bytes > budget.maximumBytes
379
+ || budget.nodes + nodes > budget.maximumNodes) return false;
380
+ budget.bytes += bytes;
381
+ budget.nodes += nodes;
382
+ return true;
383
+ }
384
+
385
+ function measureSyncOperationV1(operation: OhOperationV1): SyncIngressBudgetV1 | null {
386
+ const measurement: SyncIngressBudgetV1 = {
387
+ bytes: 0,
388
+ maximumBytes: OH_OPERATION_MAX_BYTES_V1,
389
+ maximumNodes: OH_SYNC_INGRESS_OPERATION_NODES_V1,
390
+ nodes: 0,
391
+ };
392
+ return boundedSyncIngressV1(operation, {
393
+ maximumBytes: OH_OPERATION_MAX_BYTES_V1,
394
+ maximumDepth: OH_SYNC_INGRESS_OPERATION_DEPTH_V1,
395
+ maximumNodes: OH_SYNC_INGRESS_OPERATION_NODES_V1,
396
+ }, measurement, false) === null ? null : measurement;
397
+ }
398
+
399
+ function buildOhSyncBundleV1(
400
+ parsedSpaceId: string,
401
+ operations: readonly OhOperationV1[],
402
+ largestFittingPrefix: boolean,
403
+ ): OhSyncBundleV1 {
34
404
  let priorSequence: number | null = null;
35
405
  let priorSha256: Sha256Hex | null = null;
36
406
  const parsed: OhOperationV1[] = [];
407
+ const bundleBudget = syncIngressBundleBudgetV1(parsedSpaceId);
37
408
  for (const candidate of operations) {
38
409
  const operation = parseOhOperationV1(candidate);
39
410
  if (operation === null || operation.spaceId !== parsedSpaceId
@@ -41,6 +412,15 @@ export function createOhSyncBundleV1(spaceId: string, operations: readonly OhOpe
41
412
  || (priorSequence !== null && operation.parentOperationSha256 !== priorSha256)) {
42
413
  throw new TypeError("Sync operations must form one ordered chain.");
43
414
  }
415
+ const measurement = measureSyncOperationV1(operation);
416
+ if (measurement === null) {
417
+ throw new RangeError("Sync operation exceeds its canonical byte, node, or depth limit.");
418
+ }
419
+ if (!spendSyncIngressBudgetV1(bundleBudget,
420
+ measurement.bytes + (parsed.length === 0 ? 0 : 1), measurement.nodes)) {
421
+ if (largestFittingPrefix && parsed.length > 0) break;
422
+ throw new RangeError("Sync bundle exceeds its canonical byte or node limit.");
423
+ }
44
424
  parsed.push(operation);
45
425
  priorSequence = operation.sequence;
46
426
  priorSha256 = operation.operationSha256;
@@ -50,14 +430,78 @@ export function createOhSyncBundleV1(spaceId: string, operations: readonly OhOpe
50
430
  return { ...payload, bundleSha256: canonicalSha256(payload) };
51
431
  }
52
432
 
433
+ function parseSyncHeadRefEnvelopeV1(value: Record<string, unknown>): OhHeadRefV1 | null {
434
+ const operationSha256 = value.operationSha256 === null
435
+ ? null
436
+ : parseSha256Hex(value.operationSha256);
437
+ const sequence = Number.isSafeInteger(value.sequence) && (value.sequence as number) >= 0
438
+ && !Object.is(value.sequence, -0)
439
+ ? value.sequence as number
440
+ : null;
441
+ return sequence !== null
442
+ && (value.operationSha256 === null || operationSha256 !== null)
443
+ && ((sequence === 0) === (operationSha256 === null))
444
+ ? { operationSha256, sequence }
445
+ : null;
446
+ }
447
+
448
+ export function parseOhSyncHeadRefV1(value: unknown): OhHeadRefV1 | null {
449
+ const reference = exactDataRecordV1(value, OH_SYNC_HEAD_REF_KEYS_V1);
450
+ return reference === null ? null : parseSyncHeadRefEnvelopeV1(reference);
451
+ }
452
+
453
+ export function parseOhSyncHeadV1(value: unknown): OhSyncHeadV1 | null {
454
+ const envelope = exactDataRecordV1(value, OH_SYNC_HEAD_KEYS_V1);
455
+ if (envelope === null || envelope.v !== 1) return null;
456
+ const reference = parseSyncHeadRefEnvelopeV1(envelope);
457
+ return reference === null ? null : { ...reference, v: 1 };
458
+ }
459
+
460
+ export type OhSyncBundleV1 = Readonly<{
461
+ bundleSha256: Sha256Hex;
462
+ contractSha256: Sha256Hex;
463
+ operations: readonly OhOperationV1[];
464
+ protocol: typeof OH_SYNC_PROTOCOL_V1;
465
+ spaceId: string;
466
+ v: 1;
467
+ }>;
468
+
469
+ export function createOhSyncBundleV1(
470
+ spaceId: string,
471
+ operations: readonly OhOperationV1[],
472
+ options: Readonly<{ largestFittingPrefix?: boolean }> = {},
473
+ ): OhSyncBundleV1 {
474
+ const parsedSpaceId = safeCode(spaceId);
475
+ const largestFittingPrefix = options.largestFittingPrefix ?? false;
476
+ if (parsedSpaceId === null || operations.length > OH_SYNC_BUNDLE_MAX_OPERATIONS_V1
477
+ || typeof largestFittingPrefix !== "boolean") {
478
+ throw new TypeError("Invalid sync bundle.");
479
+ }
480
+ return buildOhSyncBundleV1(parsedSpaceId, operations, largestFittingPrefix);
481
+ }
482
+
53
483
  export function parseOhSyncBundleV1(value: unknown): OhSyncBundleV1 | null {
54
- if (!isPlainRecord(value) || !hasExactKeys(value, ["bundleSha256", "contractSha256", "operations", "protocol", "spaceId", "v"])
55
- || value.protocol !== OH_SYNC_PROTOCOL_V1 || value.v !== 1 || !Array.isArray(value.operations)) return null;
56
- const bundleSha256 = parseSha256Hex(value.bundleSha256);
57
- const contractSha256 = parseSha256Hex(value.contractSha256);
58
- if (bundleSha256 === null || contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256) return null;
484
+ const envelope = exactDataRecordV1(value, OH_SYNC_BUNDLE_KEYS_V1);
485
+ if (envelope === null || envelope.protocol !== OH_SYNC_PROTOCOL_V1 || envelope.v !== 1) return null;
486
+ const bundleSha256 = parseSha256Hex(envelope.bundleSha256);
487
+ const contractSha256 = parseSha256Hex(envelope.contractSha256);
488
+ const spaceId = safeCode(envelope.spaceId);
489
+ if (bundleSha256 === null || contractSha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256
490
+ || spaceId === null) return null;
491
+ const operations = exactDataArrayV1(envelope.operations, OH_SYNC_BUNDLE_MAX_OPERATIONS_V1);
492
+ if (operations === null) return null;
493
+ const detachedOperations: unknown[] = [];
494
+ const bundleBudget = syncIngressBundleBudgetV1(spaceId);
495
+ for (const operation of operations) {
496
+ if (detachedOperations.length > 0 && !spendSyncIngressBudgetV1(bundleBudget, 1)) return null;
497
+ const detached = stagedSyncOperationV1(operation, bundleBudget);
498
+ if (detached === null) return null;
499
+ detachedOperations.push(detached);
500
+ }
59
501
  try {
60
- const created = createOhSyncBundleV1(value.spaceId as string, value.operations as OhOperationV1[]);
502
+ const created = createOhSyncBundleV1(spaceId, detachedOperations as OhOperationV1[]);
503
+ if (detachedOperations.some((operation, index) =>
504
+ canonicalJson(operation) !== canonicalJson(created.operations[index]))) return null;
61
505
  return created.bundleSha256 === bundleSha256 ? { ...created, bundleSha256 } : null;
62
506
  } catch { return null; }
63
507
  }
@@ -95,40 +539,94 @@ export async function synchronizeOhStoreV1(
95
539
  await transport.handshake(OH_CONTRACT_MANIFEST_V1);
96
540
  let pulled = 0;
97
541
  let pushed = 0;
542
+ const settled = (head: OhSyncHeadV1, rounds: number): OhSyncResultV1 => {
543
+ store.updateSyncState(remoteId, { pulledSequence: head.sequence,
544
+ pushedSequence: head.sequence, remoteHeadSha256: head.operationSha256 });
545
+ return { head, pulled, pushed, rounds, v: 1 };
546
+ };
98
547
  for (let round = 1; round <= maximumRounds; round += 1) {
548
+ const remote = parseOhSyncHeadV1(await transport.head(store.spaceId));
549
+ if (remote === null) throw new Error("The sync transport returned an invalid head.");
99
550
  const local = store.head();
100
- const remote = await transport.head(store.spaceId);
101
551
  if (local.sequence === remote.sequence) {
102
552
  if (local.operationSha256 !== remote.operationSha256) {
103
553
  throw new Error("Sync conflict: equal sequence numbers have different heads.");
104
554
  }
105
- store.updateSyncState(remoteId, { pulledSequence: local.sequence,
106
- pushedSequence: local.sequence, remoteHeadSha256: remote.operationSha256 });
107
- return { head: remote, pulled, pushed, rounds: round, v: 1 };
555
+ return settled(remote, round);
108
556
  }
109
557
  if (local.sequence < remote.sequence) {
110
558
  const bundle = parseOhSyncBundleV1(await transport.pull(store.spaceId, local.sequence, batchSize));
559
+ const terminal = bundle?.operations.at(-1);
111
560
  if (bundle === null || bundle.operations.length === 0
561
+ || bundle.operations.length > batchSize
562
+ || bundle.spaceId !== store.spaceId
112
563
  || bundle.operations[0]?.sequence !== local.sequence + 1
113
- || bundle.operations[0]?.parentOperationSha256 !== local.operationSha256) {
564
+ || bundle.operations[0]?.parentOperationSha256 !== local.operationSha256
565
+ || terminal === undefined || terminal.sequence > remote.sequence
566
+ || (terminal.sequence === remote.sequence
567
+ && terminal.operationSha256 !== remote.operationSha256)) {
114
568
  throw new Error("Sync conflict: remote history does not extend the local head.");
115
569
  }
116
- for (const operation of bundle.operations) {
117
- store.importOperation(operation);
118
- pulled += 1;
570
+ store.importOperations({
571
+ expectedHead: {
572
+ operationSha256: local.operationSha256,
573
+ sequence: local.sequence,
574
+ },
575
+ operations: bundle.operations,
576
+ });
577
+ pulled += bundle.operations.length;
578
+ const afterPull = store.head();
579
+ if (afterPull.sequence === remote.sequence
580
+ && afterPull.operationSha256 === remote.operationSha256) {
581
+ const confirmed = parseOhSyncHeadV1(await transport.head(store.spaceId));
582
+ if (confirmed === null) throw new Error("The sync transport returned an invalid head.");
583
+ const confirmedLocal = store.head();
584
+ if (confirmed.sequence === remote.sequence
585
+ && confirmed.operationSha256 === remote.operationSha256
586
+ && confirmedLocal.sequence === confirmed.sequence
587
+ && confirmedLocal.operationSha256 === confirmed.operationSha256) {
588
+ return settled(confirmed, round);
589
+ }
119
590
  }
120
591
  } else {
121
- const operations = store.exportOperations(remote.sequence, batchSize);
122
- if (operations.length === 0 || operations[0]?.sequence !== remote.sequence + 1
123
- || operations[0]?.parentOperationSha256 !== remote.operationSha256) {
592
+ const candidates = store.changesSince({
593
+ operationSha256: remote.operationSha256,
594
+ sequence: remote.sequence,
595
+ }, {
596
+ limit: batchSize,
597
+ through: {
598
+ operationSha256: local.operationSha256,
599
+ sequence: local.sequence,
600
+ },
601
+ }).operations;
602
+ if (candidates.length === 0 || candidates[0]?.sequence !== remote.sequence + 1
603
+ || candidates[0]?.parentOperationSha256 !== remote.operationSha256) {
124
604
  throw new Error("Sync conflict: local history does not extend the remote head.");
125
605
  }
126
- const head = await transport.push(createOhSyncBundleV1(store.spaceId, operations));
606
+ const bundle = createOhSyncBundleV1(store.spaceId, candidates, {
607
+ largestFittingPrefix: true,
608
+ });
609
+ const operations = bundle.operations;
610
+ const head = parseOhSyncHeadV1(await transport.push(bundle));
611
+ if (head === null) throw new Error("The sync transport returned an invalid push head.");
127
612
  if (head.sequence !== operations.at(-1)?.sequence
128
613
  || head.operationSha256 !== operations.at(-1)?.operationSha256) {
129
614
  throw new Error("The sync transport acknowledged a different head.");
130
615
  }
131
616
  pushed += operations.length;
617
+ const afterPush = store.head();
618
+ if (afterPush.sequence === head.sequence
619
+ && afterPush.operationSha256 === head.operationSha256) {
620
+ const confirmed = parseOhSyncHeadV1(await transport.head(store.spaceId));
621
+ if (confirmed === null) throw new Error("The sync transport returned an invalid head.");
622
+ const confirmedLocal = store.head();
623
+ if (confirmed.sequence === head.sequence
624
+ && confirmed.operationSha256 === head.operationSha256
625
+ && confirmedLocal.sequence === confirmed.sequence
626
+ && confirmedLocal.operationSha256 === confirmed.operationSha256) {
627
+ return settled(confirmed, round);
628
+ }
629
+ }
132
630
  }
133
631
  }
134
632
  throw new Error("Sync did not settle within maximumRounds.");
@@ -193,17 +691,42 @@ export function createLibSqlOperationSyncTransportV1(client: LibSqlClientV1): Oh
193
691
  },
194
692
  head,
195
693
  pull: async (spaceId, afterSequence, limit) => {
694
+ const parsedSpaceId = safeCode(spaceId);
695
+ if (parsedSpaceId === null
696
+ || !Number.isSafeInteger(afterSequence) || afterSequence < 0 || Object.is(afterSequence, -0)
697
+ || !Number.isSafeInteger(limit) || limit < 1 || limit > OH_SYNC_BUNDLE_MAX_OPERATIONS_V1) {
698
+ throw new TypeError("Invalid sync pull request.");
699
+ }
196
700
  await ensure();
197
- const result = await client.execute({ sql: `SELECT operation_json FROM oh_sync_operations
198
- WHERE space_id = ? AND sequence > ? ORDER BY sequence LIMIT ?`, args: [spaceId, afterSequence, limit] });
199
- const operations = result.rows.map((row) => {
701
+ const bundleBudget = syncIngressBundleBudgetV1(parsedSpaceId);
702
+ const result = await client.execute({ sql: `SELECT operation_json FROM (
703
+ SELECT sequence, operation_json,
704
+ row_number() OVER (ORDER BY sequence) AS ordinal,
705
+ sum(length(CAST(operation_json AS BLOB))) OVER (
706
+ ORDER BY sequence ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
707
+ ) AS cumulative_bytes
708
+ FROM (
709
+ SELECT sequence, operation_json FROM oh_sync_operations
710
+ WHERE space_id = ? AND sequence > ? ORDER BY sequence LIMIT ?
711
+ )
712
+ ) WHERE ordinal = 1 OR cumulative_bytes + ordinal - 1 <= ? ORDER BY sequence`,
713
+ args: [parsedSpaceId, afterSequence, limit,
714
+ bundleBudget.maximumBytes - bundleBudget.bytes] });
715
+ const rows = result.rows;
716
+ if (!Array.isArray(rows) || rows.length > limit) {
717
+ throw new Error("Remote sync pull exceeded its requested row limit.");
718
+ }
719
+ const operations = rows.map((row) => {
200
720
  const json = rowValue(row, "operation_json", 0);
201
721
  if (typeof json !== "string") throw new Error("Invalid remote operation JSON.");
722
+ if (utf8ByteLength(json) > OH_OPERATION_MAX_BYTES_V1) {
723
+ throw new Error("Remote operation JSON exceeds the canonical operation byte limit.");
724
+ }
202
725
  const operation = parseOhOperationV1(JSON.parse(json));
203
726
  if (operation === null || canonicalJson(operation) !== json) throw new Error("Invalid remote operation.");
204
727
  return operation;
205
728
  });
206
- return createOhSyncBundleV1(spaceId, operations);
729
+ return createOhSyncBundleV1(parsedSpaceId, operations);
207
730
  },
208
731
  push: async (value) => {
209
732
  await ensure();
@@ -213,7 +736,34 @@ export function createLibSqlOperationSyncTransportV1(client: LibSqlClientV1): Oh
213
736
  const remote = await head(bundle.spaceId);
214
737
  const first = bundle.operations[0] as OhOperationV1;
215
738
  const last = bundle.operations.at(-1) as OhOperationV1;
216
- if (remote.sequence === last.sequence && remote.operationSha256 === last.operationSha256) return remote;
739
+ if (remote.sequence >= last.sequence) {
740
+ const result = await client.execute({ sql: `SELECT sequence, operation_sha256, operation_json
741
+ FROM oh_sync_operations WHERE space_id = ? AND sequence >= ? AND sequence <= ?
742
+ ORDER BY sequence LIMIT ?`, args: [bundle.spaceId, first.sequence, last.sequence,
743
+ bundle.operations.length] });
744
+ const rows = result.rows;
745
+ if (!Array.isArray(rows) || rows.length !== bundle.operations.length) {
746
+ throw new Error("Sync conflict: remote history does not contain the exact pushed operations.");
747
+ }
748
+ for (let index = 0; index < bundle.operations.length; index += 1) {
749
+ const operation = bundle.operations[index] as OhOperationV1;
750
+ const row = rows[index];
751
+ if (row === undefined) {
752
+ throw new Error("Sync conflict: remote history does not contain the exact pushed operations.");
753
+ }
754
+ const sequence = Number(rowValue(row, "sequence", 0));
755
+ const operationSha256 = parseSha256Hex(rowValue(row, "operation_sha256", 1));
756
+ const operationJson = rowValue(row, "operation_json", 2);
757
+ const expectedJson = canonicalJson(operation);
758
+ if (sequence !== operation.sequence || operationSha256 !== operation.operationSha256
759
+ || typeof operationJson !== "string"
760
+ || utf8ByteLength(operationJson) > OH_OPERATION_MAX_BYTES_V1
761
+ || operationJson !== expectedJson) {
762
+ throw new Error("Sync conflict: remote history differs from the pushed operations.");
763
+ }
764
+ }
765
+ return { operationSha256: last.operationSha256, sequence: last.sequence, v: 1 };
766
+ }
217
767
  if (first.sequence !== remote.sequence + 1 || first.parentOperationSha256 !== remote.operationSha256) {
218
768
  throw new Error("Sync conflict: pushed history does not extend the remote head.");
219
769
  }