@hraness/oh 0.3.2 → 0.4.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 +164 -114
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +811 -97
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/graph.d.ts.map +1 -1
- package/dist/index.js +676 -61
- package/dist/libsql.d.ts.map +1 -1
- package/dist/libsql.js +162 -35
- package/dist/memory-page.js +2 -2
- package/dist/memory.d.ts +87 -6
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +1106 -148
- package/dist/operation.d.ts +3 -1
- package/dist/operation.d.ts.map +1 -1
- package/dist/projection-public.js +2 -2
- package/dist/projection-suss.js +2 -2
- package/dist/sdk.js +780 -88
- package/dist/semantic-cloud.js +2 -2
- package/dist/semantic.js +2 -2
- package/dist/sqlite/index.js +1251 -306
- package/dist/sqlite/port.d.ts +31 -3
- package/dist/sqlite/port.d.ts.map +1 -1
- package/dist/sqlite/store.d.ts +18 -2
- package/dist/sqlite/store.d.ts.map +1 -1
- package/dist/store.d.ts +3 -12
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +154 -32
- package/dist/sync.d.ts +7 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +668 -35
- package/package.json +5 -1
- package/skills/oh/SKILL.md +42 -16
- package/spec/README.md +2 -2
- package/spec/v1/memory.md +134 -16
- package/spec/v1/storage.md +8 -5
- package/spec/v1/store.md +20 -0
- package/spec/v1/sync.md +77 -8
- package/src/cli.test.ts +53 -1
- package/src/cli.ts +34 -8
- package/src/errors.test.ts +87 -0
- package/src/errors.ts +185 -0
- package/src/graph.ts +2 -2
- package/src/libsql.test.ts +36 -0
- package/src/libsql.ts +26 -5
- package/src/memory.test.ts +1488 -18
- package/src/memory.ts +1199 -122
- package/src/operation.ts +13 -3
- package/src/sqlite/port.test.ts +209 -0
- package/src/sqlite/port.ts +118 -4
- package/src/sqlite/store.test.ts +106 -1
- package/src/sqlite/store.ts +168 -30
- package/src/store.test.ts +12 -0
- package/src/store.ts +30 -20
- package/src/sync.test.ts +570 -2
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
contractSha256
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
const bundleSha256 = parseSha256Hex(
|
|
57
|
-
const contractSha256 = parseSha256Hex(
|
|
58
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
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(
|
|
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
|
|
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
|
}
|