@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.
- 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.test.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
3
3
|
|
|
4
|
+
import { canonicalJson, canonicalSha256, type JsonValue } from "./canonical";
|
|
4
5
|
import { OH_CONTRACT_MANIFEST_V1 } from "./contract";
|
|
5
|
-
import { createKnowledgeGraphRecordV1
|
|
6
|
+
import { createKnowledgeGraphRecordV1, OH_GRAPH_LIMITS_V1, OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1,
|
|
7
|
+
type KnowledgeGraphRecordKindV1 } from "./graph";
|
|
8
|
+
import { createOhOperationV1, OH_OPERATION_MAX_BYTES_V1 } from "./operation";
|
|
6
9
|
import { OhSqliteStore } from "./sqlite/store";
|
|
7
10
|
import { createLibSqlOperationSyncTransportV1, createOhSyncBundleV1, parseOhSyncBundleV1,
|
|
11
|
+
OH_SYNC_PROTOCOL_V1, parseOhSyncHeadRefV1, parseOhSyncHeadV1,
|
|
8
12
|
synchronizeOhStoreV1, type LibSqlClientV1, type LibSqlStatementV1,
|
|
9
13
|
type LibSqlResultV1,
|
|
10
14
|
type OhOperationSyncTransportV1, type OhSyncBundleV1, type OhSyncHeadV1 } from "./sync";
|
|
@@ -42,6 +46,24 @@ function put(store: OhSqliteStore, key: string, name: string, operationId: strin
|
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
describe("operation sync", () => {
|
|
49
|
+
test("passes an immutable contract manifest to an untrusted transport", async () => {
|
|
50
|
+
const store = new OhSqliteStore({ path: ":memory:" });
|
|
51
|
+
const transport: OhOperationSyncTransportV1 = {
|
|
52
|
+
handshake: async (manifest) => {
|
|
53
|
+
expect(Object.isFrozen(manifest)).toBe(true);
|
|
54
|
+
expect(Object.isFrozen(manifest.recordKinds)).toBe(true);
|
|
55
|
+
expect(manifest.recordKinds).toBe(OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1);
|
|
56
|
+
expect(() => (manifest.recordKinds as KnowledgeGraphRecordKindV1[]).pop()).toThrow(TypeError);
|
|
57
|
+
},
|
|
58
|
+
head: async () => ({ operationSha256: null, sequence: 0, v: 1 }),
|
|
59
|
+
pull: async () => { throw new Error("Unexpected pull."); },
|
|
60
|
+
push: async () => { throw new Error("Unexpected push."); },
|
|
61
|
+
};
|
|
62
|
+
expect(await synchronizeOhStoreV1(store, transport)).toMatchObject({ pulled: 0, pushed: 0 });
|
|
63
|
+
expect(OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1).toHaveLength(18);
|
|
64
|
+
store.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
45
67
|
test("round-trips fast-forward logs and settles idempotently", async () => {
|
|
46
68
|
const remote = new MemoryTransport();
|
|
47
69
|
const first = new OhSqliteStore({ path: ":memory:" });
|
|
@@ -59,6 +81,176 @@ describe("operation sync", () => {
|
|
|
59
81
|
first.close(); second.close(); remote.close();
|
|
60
82
|
});
|
|
61
83
|
|
|
84
|
+
test("settles on the terminal pull or push without reserving an observation round", async () => {
|
|
85
|
+
const pullRemote = new MemoryTransport();
|
|
86
|
+
put(pullRemote.store, "entity:remote-1", "Remote 1", "op_remote_1");
|
|
87
|
+
put(pullRemote.store, "entity:remote-2", "Remote 2", "op_remote_2");
|
|
88
|
+
put(pullRemote.store, "entity:remote-3", "Remote 3", "op_remote_3");
|
|
89
|
+
const pulled = new OhSqliteStore({ path: ":memory:" });
|
|
90
|
+
const pullHead = pullRemote.store.head();
|
|
91
|
+
expect(await synchronizeOhStoreV1(pulled, pullRemote, {
|
|
92
|
+
batchSize: 1,
|
|
93
|
+
maximumRounds: 3,
|
|
94
|
+
})).toMatchObject({ head: { operationSha256: pullHead.operationSha256,
|
|
95
|
+
sequence: pullHead.sequence }, pulled: 3, pushed: 0, rounds: 3 });
|
|
96
|
+
|
|
97
|
+
const pushRemote = new MemoryTransport();
|
|
98
|
+
const pushed = new OhSqliteStore({ path: ":memory:" });
|
|
99
|
+
put(pushed, "entity:local", "Local", "op_local");
|
|
100
|
+
const pushHead = pushed.head();
|
|
101
|
+
expect(await synchronizeOhStoreV1(pushed, pushRemote, {
|
|
102
|
+
maximumRounds: 1,
|
|
103
|
+
})).toMatchObject({ head: { operationSha256: pushHead.operationSha256,
|
|
104
|
+
sequence: pushHead.sequence }, pulled: 0, pushed: 1, rounds: 1 });
|
|
105
|
+
pulled.close(); pullRemote.close(); pushed.close(); pushRemote.close();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("samples the local head after an awaited remote head read", async () => {
|
|
109
|
+
const local = new OhSqliteStore({ path: ":memory:" });
|
|
110
|
+
put(local, "entity:first", "First", "op_first");
|
|
111
|
+
const remote = new MemoryTransport();
|
|
112
|
+
remote.store.importOperation(local.exportOperations()[0]);
|
|
113
|
+
let mutated = false;
|
|
114
|
+
const transport: OhOperationSyncTransportV1 = {
|
|
115
|
+
handshake: async (manifest) => remote.handshake(manifest),
|
|
116
|
+
head: async (spaceId) => {
|
|
117
|
+
const captured = await remote.head(spaceId);
|
|
118
|
+
if (!mutated) {
|
|
119
|
+
mutated = true;
|
|
120
|
+
put(local, "entity:second", "Second", "op_second");
|
|
121
|
+
}
|
|
122
|
+
return captured;
|
|
123
|
+
},
|
|
124
|
+
pull: async (spaceId, afterSequence, limit) => remote.pull(spaceId, afterSequence, limit),
|
|
125
|
+
push: async (bundle) => remote.push(bundle),
|
|
126
|
+
};
|
|
127
|
+
const result = await synchronizeOhStoreV1(local, transport, { maximumRounds: 1 });
|
|
128
|
+
expect(result).toMatchObject({ head: { operationSha256: local.head().operationSha256,
|
|
129
|
+
sequence: 2 }, pulled: 0, pushed: 1, rounds: 1 });
|
|
130
|
+
expect(remote.store.head().operationSha256).toBe(local.head().operationSha256);
|
|
131
|
+
local.close(); remote.close();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("does not settle a pushed tail when the remote advances before confirmation", async () => {
|
|
135
|
+
const local = new OhSqliteStore({ path: ":memory:" });
|
|
136
|
+
put(local, "entity:local", "Local", "op_local");
|
|
137
|
+
const remote = new MemoryTransport();
|
|
138
|
+
let advanced = false;
|
|
139
|
+
const transport: OhOperationSyncTransportV1 = {
|
|
140
|
+
handshake: async (manifest) => remote.handshake(manifest),
|
|
141
|
+
head: async (spaceId) => remote.head(spaceId),
|
|
142
|
+
pull: async (spaceId, afterSequence, limit) => remote.pull(spaceId, afterSequence, limit),
|
|
143
|
+
push: async (bundle) => {
|
|
144
|
+
const acknowledged = await remote.push(bundle);
|
|
145
|
+
if (!advanced) {
|
|
146
|
+
advanced = true;
|
|
147
|
+
put(remote.store, "entity:remote", "Remote", "op_remote");
|
|
148
|
+
}
|
|
149
|
+
return acknowledged;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
expect(await synchronizeOhStoreV1(local, transport, { maximumRounds: 2 }))
|
|
153
|
+
.toMatchObject({ pulled: 1, pushed: 1, rounds: 2 });
|
|
154
|
+
expect(local.head()).toEqual(remote.store.head());
|
|
155
|
+
local.close();
|
|
156
|
+
remote.close();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("does not settle a pulled tail when the remote advances during the pull", async () => {
|
|
160
|
+
const local = new OhSqliteStore({ path: ":memory:" });
|
|
161
|
+
const remote = new MemoryTransport();
|
|
162
|
+
put(remote.store, "entity:first", "First", "op_first");
|
|
163
|
+
let advanced = false;
|
|
164
|
+
const transport: OhOperationSyncTransportV1 = {
|
|
165
|
+
handshake: async (manifest) => remote.handshake(manifest),
|
|
166
|
+
head: async (spaceId) => remote.head(spaceId),
|
|
167
|
+
pull: async (spaceId, afterSequence, limit) => {
|
|
168
|
+
const bundle = await remote.pull(spaceId, afterSequence, limit);
|
|
169
|
+
if (!advanced) {
|
|
170
|
+
advanced = true;
|
|
171
|
+
put(remote.store, "entity:second", "Second", "op_second");
|
|
172
|
+
}
|
|
173
|
+
return bundle;
|
|
174
|
+
},
|
|
175
|
+
push: async (bundle) => remote.push(bundle),
|
|
176
|
+
};
|
|
177
|
+
expect(await synchronizeOhStoreV1(local, transport, { maximumRounds: 2 }))
|
|
178
|
+
.toMatchObject({ pulled: 2, pushed: 0, rounds: 2 });
|
|
179
|
+
expect(local.head()).toEqual(remote.store.head());
|
|
180
|
+
local.close();
|
|
181
|
+
remote.close();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("rechecks the local head after awaiting terminal confirmation", async () => {
|
|
185
|
+
const local = new OhSqliteStore({ path: ":memory:" });
|
|
186
|
+
put(local, "entity:first", "First", "op_first");
|
|
187
|
+
const remote = new MemoryTransport();
|
|
188
|
+
let headReads = 0;
|
|
189
|
+
const transport: OhOperationSyncTransportV1 = {
|
|
190
|
+
handshake: async (manifest) => remote.handshake(manifest),
|
|
191
|
+
head: async (spaceId) => {
|
|
192
|
+
headReads += 1;
|
|
193
|
+
const captured = await remote.head(spaceId);
|
|
194
|
+
if (headReads === 2) put(local, "entity:second", "Second", "op_second");
|
|
195
|
+
return captured;
|
|
196
|
+
},
|
|
197
|
+
pull: async (spaceId, afterSequence, limit) => remote.pull(spaceId, afterSequence, limit),
|
|
198
|
+
push: async (bundle) => remote.push(bundle),
|
|
199
|
+
};
|
|
200
|
+
expect(await synchronizeOhStoreV1(local, transport, { maximumRounds: 2 }))
|
|
201
|
+
.toMatchObject({ pulled: 0, pushed: 2, rounds: 2 });
|
|
202
|
+
expect(local.head()).toEqual(remote.store.head());
|
|
203
|
+
local.close();
|
|
204
|
+
remote.close();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("rechecks a pulled local head after awaiting terminal confirmation", async () => {
|
|
208
|
+
const local = new OhSqliteStore({ path: ":memory:" });
|
|
209
|
+
const remote = new MemoryTransport();
|
|
210
|
+
put(remote.store, "entity:first", "First", "op_first");
|
|
211
|
+
let headReads = 0;
|
|
212
|
+
const transport: OhOperationSyncTransportV1 = {
|
|
213
|
+
handshake: async (manifest) => remote.handshake(manifest),
|
|
214
|
+
head: async (spaceId) => {
|
|
215
|
+
headReads += 1;
|
|
216
|
+
const captured = await remote.head(spaceId);
|
|
217
|
+
if (headReads === 2) put(local, "entity:second", "Second", "op_second");
|
|
218
|
+
return captured;
|
|
219
|
+
},
|
|
220
|
+
pull: async (spaceId, afterSequence, limit) => remote.pull(spaceId, afterSequence, limit),
|
|
221
|
+
push: async (bundle) => remote.push(bundle),
|
|
222
|
+
};
|
|
223
|
+
expect(await synchronizeOhStoreV1(local, transport, { maximumRounds: 2 }))
|
|
224
|
+
.toMatchObject({ pulled: 1, pushed: 1, rounds: 2 });
|
|
225
|
+
expect(local.head()).toEqual(remote.store.head());
|
|
226
|
+
local.close();
|
|
227
|
+
remote.close();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("counts a fetched pull page when another actor imports it first", async () => {
|
|
231
|
+
const local = new OhSqliteStore({ path: ":memory:" });
|
|
232
|
+
const remote = new MemoryTransport();
|
|
233
|
+
put(remote.store, "entity:remote", "Remote", "op_remote");
|
|
234
|
+
let raced = false;
|
|
235
|
+
const transport: OhOperationSyncTransportV1 = {
|
|
236
|
+
handshake: async (manifest) => remote.handshake(manifest),
|
|
237
|
+
head: async (spaceId) => remote.head(spaceId),
|
|
238
|
+
pull: async (spaceId, afterSequence, limit) => {
|
|
239
|
+
const bundle = await remote.pull(spaceId, afterSequence, limit);
|
|
240
|
+
if (!raced) {
|
|
241
|
+
raced = true;
|
|
242
|
+
local.importOperation(bundle.operations[0]);
|
|
243
|
+
}
|
|
244
|
+
return bundle;
|
|
245
|
+
},
|
|
246
|
+
push: async (bundle) => remote.push(bundle),
|
|
247
|
+
};
|
|
248
|
+
expect(await synchronizeOhStoreV1(local, transport, { maximumRounds: 1 }))
|
|
249
|
+
.toMatchObject({ pulled: 1, pushed: 0, rounds: 1 });
|
|
250
|
+
expect(local.head().operationSha256).toBe(remote.store.head().operationSha256);
|
|
251
|
+
local.close(); remote.close();
|
|
252
|
+
});
|
|
253
|
+
|
|
62
254
|
test("rejects diverged heads without modifying either log", async () => {
|
|
63
255
|
const remote = new MemoryTransport();
|
|
64
256
|
const local = new OhSqliteStore({ path: ":memory:" });
|
|
@@ -74,11 +266,257 @@ describe("operation sync", () => {
|
|
|
74
266
|
|
|
75
267
|
test("binds every bundle byte to its digest", () => {
|
|
76
268
|
const source = new OhSqliteStore({ path: ":memory:" });
|
|
77
|
-
|
|
269
|
+
source.commit({ actorId: "agent.test", changes: [
|
|
270
|
+
{ kind: "put", record: record("entity:a", "A"), v: 1 },
|
|
271
|
+
{ kind: "put", record: record("entity:b", "B"), v: 1 },
|
|
272
|
+
], expectedHead: source.head(), operationId: "op_a_and_b" });
|
|
78
273
|
const bundle = createOhSyncBundleV1(source.spaceId, source.exportOperations());
|
|
79
274
|
expect(parseOhSyncBundleV1(bundle)).toEqual(bundle);
|
|
80
275
|
expect(parseOhSyncBundleV1({ ...bundle, spaceId: "other" })).toBeNull();
|
|
81
276
|
expect(parseOhSyncBundleV1({ ...bundle, contractSha256: "a".repeat(64) })).toBeNull();
|
|
277
|
+
const extraChange = JSON.parse(JSON.stringify(bundle)) as {
|
|
278
|
+
operations: Array<{ changes: Array<Record<string, unknown>> }>;
|
|
279
|
+
};
|
|
280
|
+
const firstExtraChange = extraChange.operations[0]?.changes[0];
|
|
281
|
+
if (firstExtraChange === undefined) throw new Error("Expected one graph change.");
|
|
282
|
+
firstExtraChange.extra = true;
|
|
283
|
+
expect(parseOhSyncBundleV1(extraChange)).toBeNull();
|
|
284
|
+
const reorderedChanges = JSON.parse(JSON.stringify(bundle)) as {
|
|
285
|
+
operations: Array<{ changes: Array<Record<string, unknown>> }>;
|
|
286
|
+
};
|
|
287
|
+
reorderedChanges.operations[0]?.changes.reverse();
|
|
288
|
+
expect(parseOhSyncBundleV1(reorderedChanges)).toBeNull();
|
|
289
|
+
|
|
290
|
+
let accessorReads = 0;
|
|
291
|
+
const accessorBundle = { ...bundle } as Record<PropertyKey, unknown>;
|
|
292
|
+
Object.defineProperty(accessorBundle, "protocol", { enumerable: true,
|
|
293
|
+
get() { accessorReads += 1; throw new Error("must not execute"); } });
|
|
294
|
+
expect(parseOhSyncBundleV1(accessorBundle)).toBeNull();
|
|
295
|
+
const operation = bundle.operations[0];
|
|
296
|
+
if (operation === undefined) throw new Error("Expected one bundled operation.");
|
|
297
|
+
expect(parseOhSyncBundleV1({ ...bundle,
|
|
298
|
+
operations: Array.from({ length: 1001 }, () => operation) })).toBeNull();
|
|
299
|
+
const oversizedChanges = JSON.parse(JSON.stringify(bundle)) as {
|
|
300
|
+
operations: Array<{ changes: Array<Record<string, unknown>> }>;
|
|
301
|
+
};
|
|
302
|
+
const firstChange = oversizedChanges.operations[0]?.changes[0];
|
|
303
|
+
if (firstChange === undefined) throw new Error("Expected one graph change.");
|
|
304
|
+
oversizedChanges.operations[0]!.changes = Array.from({ length: 8193 }, () => firstChange);
|
|
305
|
+
expect(parseOhSyncBundleV1(oversizedChanges)).toBeNull();
|
|
306
|
+
const accessorOperation = { ...operation } as Record<PropertyKey, unknown>;
|
|
307
|
+
Object.defineProperty(accessorOperation, "operationSha256", { enumerable: true,
|
|
308
|
+
get() { accessorReads += 1; throw new Error("must not execute"); } });
|
|
309
|
+
expect(parseOhSyncBundleV1({ ...bundle, operations: [accessorOperation] })).toBeNull();
|
|
310
|
+
expect(accessorReads).toBe(0);
|
|
311
|
+
|
|
312
|
+
let proxyReads = 0;
|
|
313
|
+
const proxyBundle = new Proxy(bundle, {
|
|
314
|
+
get() { proxyReads += 1; throw new Error("must not execute"); },
|
|
315
|
+
});
|
|
316
|
+
const proxyOperation = new Proxy(operation, {
|
|
317
|
+
get() { proxyReads += 1; throw new Error("must not execute"); },
|
|
318
|
+
});
|
|
319
|
+
expect(parseOhSyncBundleV1(proxyBundle)).toBeNull();
|
|
320
|
+
expect(parseOhSyncBundleV1({ ...bundle, operations: [proxyOperation] })).toBeNull();
|
|
321
|
+
expect(proxyReads).toBe(0);
|
|
322
|
+
source.close();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("bounds deeply nested and oversized record values before detaching them", () => {
|
|
326
|
+
const nestedValue = (depth: number): unknown => {
|
|
327
|
+
let value: unknown = "leaf";
|
|
328
|
+
for (let index = 0; index < depth; index += 1) value = { child: value };
|
|
329
|
+
return value;
|
|
330
|
+
};
|
|
331
|
+
const bundleWithValue = (spaceId: string, value: unknown): OhSyncBundleV1 => {
|
|
332
|
+
const source = new OhSqliteStore({ path: ":memory:", spaceId });
|
|
333
|
+
const deepRecord = createKnowledgeGraphRecordV1({ dependencies: [], key: "entity:deep",
|
|
334
|
+
kind: "entity", v: 1, value: value as JsonValue });
|
|
335
|
+
source.commit({ actorId: "agent.test", changes: [{ kind: "put", record: deepRecord, v: 1 }],
|
|
336
|
+
expectedHead: source.head(), operationId: "op_deep" });
|
|
337
|
+
const payload = { contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256,
|
|
338
|
+
operations: source.exportOperations(), protocol: OH_SYNC_PROTOCOL_V1, spaceId, v: 1 as const };
|
|
339
|
+
const bundle = { ...payload, bundleSha256: canonicalSha256(payload) };
|
|
340
|
+
source.close();
|
|
341
|
+
return bundle;
|
|
342
|
+
};
|
|
343
|
+
expect(parseOhSyncBundleV1(bundleWithValue("null-value", null))).not.toBeNull();
|
|
344
|
+
const atLimit = bundleWithValue("depth-at-limit", nestedValue(128));
|
|
345
|
+
expect(parseOhSyncBundleV1(atLimit)).toEqual(atLimit);
|
|
346
|
+
expect(parseOhSyncBundleV1(bundleWithValue("depth-over-limit", nestedValue(129)))).toBeNull();
|
|
347
|
+
const byteLimit = bundleWithValue("bytes-at-limit",
|
|
348
|
+
"x".repeat(OH_GRAPH_LIMITS_V1.recordBytes - 2));
|
|
349
|
+
expect(parseOhSyncBundleV1(byteLimit)).toEqual(byteLimit);
|
|
350
|
+
|
|
351
|
+
type MutableBundle = {
|
|
352
|
+
operations: Array<{ changes: Array<{ record?: {
|
|
353
|
+
dependencies?: unknown;
|
|
354
|
+
value?: unknown;
|
|
355
|
+
} }> }>;
|
|
356
|
+
};
|
|
357
|
+
const oversized = JSON.parse(JSON.stringify(atLimit)) as MutableBundle;
|
|
358
|
+
const oversizedRecord = oversized.operations[0]?.changes[0]?.record;
|
|
359
|
+
if (oversizedRecord === undefined) throw new Error("Expected one mutable record.");
|
|
360
|
+
oversizedRecord.value = "x".repeat(OH_GRAPH_LIMITS_V1.recordBytes - 1);
|
|
361
|
+
expect(parseOhSyncBundleV1(oversized)).toBeNull();
|
|
362
|
+
|
|
363
|
+
const amplified = JSON.parse(JSON.stringify(atLimit)) as MutableBundle;
|
|
364
|
+
const amplifiedRecord = amplified.operations[0]?.changes[0]?.record;
|
|
365
|
+
if (amplifiedRecord === undefined) throw new Error("Expected one mutable record.");
|
|
366
|
+
const sharedLeaf = { payload: "x".repeat(64 * 1024) };
|
|
367
|
+
amplifiedRecord.value = Array.from({ length: 17 }, () => sharedLeaf);
|
|
368
|
+
expect(parseOhSyncBundleV1(amplified)).toBeNull();
|
|
369
|
+
|
|
370
|
+
const dependencyAmplified = JSON.parse(JSON.stringify(atLimit)) as MutableBundle;
|
|
371
|
+
const sharedPut = dependencyAmplified.operations[0]?.changes[0];
|
|
372
|
+
if (sharedPut?.record === undefined) throw new Error("Expected one mutable put.");
|
|
373
|
+
sharedPut.record.dependencies = Array.from({ length: OH_GRAPH_LIMITS_V1.dependenciesPerRecord },
|
|
374
|
+
(_, index) => `a${String(index).padStart(4, "0")}${"x".repeat(507)}`);
|
|
375
|
+
dependencyAmplified.operations[0]!.changes = Array.from({ length: 2048 }, () => sharedPut);
|
|
376
|
+
expect(parseOhSyncBundleV1(dependencyAmplified)).toBeNull();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("bounds cumulative canonical data across a multi-operation bundle", () => {
|
|
380
|
+
const largeRecord = createKnowledgeGraphRecordV1({ dependencies: [], key: "entity:large",
|
|
381
|
+
kind: "entity", v: 1, value: "x".repeat(OH_GRAPH_LIMITS_V1.recordBytes - 2) });
|
|
382
|
+
const operations = [] as ReturnType<typeof createOhOperationV1>[];
|
|
383
|
+
for (let sequence = 1; sequence <= 65; sequence += 1) {
|
|
384
|
+
const parentOperationSha256 = operations.at(-1)?.operationSha256 ?? null;
|
|
385
|
+
operations.push(createOhOperationV1({
|
|
386
|
+
actorId: "agent.test",
|
|
387
|
+
changes: [{ kind: "put", record: largeRecord, v: 1 }],
|
|
388
|
+
contractId: OH_CONTRACT_MANIFEST_V1.contractId,
|
|
389
|
+
graphRevisionSha256: canonicalSha256(`graph:${sequence}`),
|
|
390
|
+
instant: "2026-09-06T00:00:00.000Z",
|
|
391
|
+
operationId: `op_large_${sequence}`,
|
|
392
|
+
parentOperationSha256,
|
|
393
|
+
recordsSha256: canonicalSha256(`records:${sequence}`),
|
|
394
|
+
sequence,
|
|
395
|
+
spaceId: "bundle-budget",
|
|
396
|
+
v: 1,
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
expect(() => createOhSyncBundleV1("bundle-budget", operations)).toThrow(RangeError);
|
|
400
|
+
const prefix = createOhSyncBundleV1("bundle-budget", operations, {
|
|
401
|
+
largestFittingPrefix: true,
|
|
402
|
+
});
|
|
403
|
+
expect(prefix.operations.length).toBeGreaterThan(0);
|
|
404
|
+
expect(prefix.operations.length).toBeLessThan(operations.length);
|
|
405
|
+
const payload = { contractSha256: OH_CONTRACT_MANIFEST_V1.contractSha256,
|
|
406
|
+
operations, protocol: OH_SYNC_PROTOCOL_V1, spaceId: "bundle-budget", v: 1 as const };
|
|
407
|
+
expect(parseOhSyncBundleV1({ ...payload, bundleSha256: canonicalSha256(payload) })).toBeNull();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("strictly parses transport heads", () => {
|
|
411
|
+
const digest = canonicalSha256("head");
|
|
412
|
+
expect(parseOhSyncHeadV1({ operationSha256: null, sequence: 0, v: 1 }))
|
|
413
|
+
.toEqual({ operationSha256: null, sequence: 0, v: 1 });
|
|
414
|
+
expect(parseOhSyncHeadV1({ operationSha256: digest, sequence: 1, v: 1 }))
|
|
415
|
+
.toEqual({ operationSha256: digest, sequence: 1, v: 1 });
|
|
416
|
+
expect(parseOhSyncHeadV1({ operationSha256: null, sequence: 1, v: 1 })).toBeNull();
|
|
417
|
+
expect(parseOhSyncHeadV1({ operationSha256: null, sequence: -0, v: 1 })).toBeNull();
|
|
418
|
+
expect(parseOhSyncHeadV1({ extra: true, operationSha256: digest, sequence: 1, v: 1 })).toBeNull();
|
|
419
|
+
expect(parseOhSyncHeadRefV1({ operationSha256: null, sequence: 0 }))
|
|
420
|
+
.toEqual({ operationSha256: null, sequence: 0 });
|
|
421
|
+
expect(parseOhSyncHeadRefV1({ operationSha256: digest, sequence: 1 }))
|
|
422
|
+
.toEqual({ operationSha256: digest, sequence: 1 });
|
|
423
|
+
expect(parseOhSyncHeadRefV1({ generation: 0, graphRevisionSha256: null,
|
|
424
|
+
operationSha256: null, recordsSha256: canonicalSha256([]), sequence: 0, v: 1 }))
|
|
425
|
+
.toBeNull();
|
|
426
|
+
expect(parseOhSyncHeadRefV1({ operationSha256: null, sequence: -0 })).toBeNull();
|
|
427
|
+
|
|
428
|
+
let accessorReads = 0;
|
|
429
|
+
const accessor = { operationSha256: null, sequence: 0 } as Record<PropertyKey, unknown>;
|
|
430
|
+
Object.defineProperty(accessor, "v", { enumerable: true,
|
|
431
|
+
get() { accessorReads += 1; throw new Error("must not execute"); } });
|
|
432
|
+
expect(parseOhSyncHeadV1(accessor)).toBeNull();
|
|
433
|
+
const accessorReference = { operationSha256: null } as Record<PropertyKey, unknown>;
|
|
434
|
+
Object.defineProperty(accessorReference, "sequence", { enumerable: true,
|
|
435
|
+
get() { accessorReads += 1; throw new Error("must not execute"); } });
|
|
436
|
+
expect(parseOhSyncHeadRefV1(accessorReference)).toBeNull();
|
|
437
|
+
expect(accessorReads).toBe(0);
|
|
438
|
+
|
|
439
|
+
let proxyReads = 0;
|
|
440
|
+
const proxy = new Proxy({ operationSha256: null, sequence: 0, v: 1 }, {
|
|
441
|
+
get() { proxyReads += 1; throw new Error("must not execute"); },
|
|
442
|
+
});
|
|
443
|
+
expect(parseOhSyncHeadV1(proxy)).toBeNull();
|
|
444
|
+
const proxyReference = new Proxy({ operationSha256: null, sequence: 0 }, {
|
|
445
|
+
get() { proxyReads += 1; throw new Error("must not execute"); },
|
|
446
|
+
});
|
|
447
|
+
expect(parseOhSyncHeadRefV1(proxyReference)).toBeNull();
|
|
448
|
+
expect(proxyReads).toBe(0);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("rolls back a valid pulled prefix when a later operation is invalid", async () => {
|
|
452
|
+
const source = new OhSqliteStore({ path: ":memory:" });
|
|
453
|
+
const local = new OhSqliteStore({ path: ":memory:" });
|
|
454
|
+
put(source, "entity:first", "First", "op_first");
|
|
455
|
+
put(source, "entity:second", "Second", "op_second");
|
|
456
|
+
const [first, second] = source.exportOperations();
|
|
457
|
+
if (first === undefined || second === undefined) throw new Error("Expected two source operations.");
|
|
458
|
+
const { operationSha256: _operationSha256, ...payload } = second;
|
|
459
|
+
const hostileSecond = createOhOperationV1({
|
|
460
|
+
...payload,
|
|
461
|
+
graphRevisionSha256: canonicalSha256("hostile graph revision"),
|
|
462
|
+
recordsSha256: canonicalSha256("hostile records"),
|
|
463
|
+
});
|
|
464
|
+
const bundle = createOhSyncBundleV1(source.spaceId, [first, hostileSecond]);
|
|
465
|
+
const transport: OhOperationSyncTransportV1 = {
|
|
466
|
+
handshake: async () => undefined,
|
|
467
|
+
head: async () => ({ operationSha256: hostileSecond.operationSha256, sequence: 2, v: 1 }),
|
|
468
|
+
pull: async () => bundle,
|
|
469
|
+
push: async () => { throw new Error("unexpected push"); },
|
|
470
|
+
};
|
|
471
|
+
await expect(synchronizeOhStoreV1(local, transport)).rejects.toThrow("does not reproduce");
|
|
472
|
+
expect(local.head().sequence).toBe(0);
|
|
473
|
+
expect(local.snapshotRecords()).toEqual([]);
|
|
474
|
+
local.close();
|
|
475
|
+
source.close();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("rejects a pulled terminal that differs from the observed remote head before import", async () => {
|
|
479
|
+
const source = new OhSqliteStore({ path: ":memory:" });
|
|
480
|
+
const local = new OhSqliteStore({ path: ":memory:" });
|
|
481
|
+
put(source, "entity:fork", "Fork", "op_fork");
|
|
482
|
+
const bundle = createOhSyncBundleV1(source.spaceId, source.exportOperations());
|
|
483
|
+
const transport: OhOperationSyncTransportV1 = {
|
|
484
|
+
handshake: async () => undefined,
|
|
485
|
+
head: async () => ({ operationSha256: canonicalSha256("different remote head"),
|
|
486
|
+
sequence: 1, v: 1 }),
|
|
487
|
+
pull: async () => bundle,
|
|
488
|
+
push: async () => { throw new Error("unexpected push"); },
|
|
489
|
+
};
|
|
490
|
+
await expect(synchronizeOhStoreV1(local, transport))
|
|
491
|
+
.rejects.toThrow("remote history does not extend");
|
|
492
|
+
expect(local.head().sequence).toBe(0);
|
|
493
|
+
expect(local.snapshotRecords()).toEqual([]);
|
|
494
|
+
local.close();
|
|
495
|
+
source.close();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("rejects a pull response above the requested batch bound before import", async () => {
|
|
499
|
+
const source = new OhSqliteStore({ path: ":memory:" });
|
|
500
|
+
const local = new OhSqliteStore({ path: ":memory:" });
|
|
501
|
+
put(source, "entity:first", "First", "op_first");
|
|
502
|
+
put(source, "entity:second", "Second", "op_second");
|
|
503
|
+
const bundle = createOhSyncBundleV1(source.spaceId, source.exportOperations());
|
|
504
|
+
const sourceHead = source.head();
|
|
505
|
+
const transport: OhOperationSyncTransportV1 = {
|
|
506
|
+
handshake: async () => undefined,
|
|
507
|
+
head: async () => ({ operationSha256: sourceHead.operationSha256,
|
|
508
|
+
sequence: sourceHead.sequence, v: 1 }),
|
|
509
|
+
pull: async (_spaceId, _afterSequence, limit) => {
|
|
510
|
+
expect(limit).toBe(1);
|
|
511
|
+
return bundle;
|
|
512
|
+
},
|
|
513
|
+
push: async () => { throw new Error("unexpected push"); },
|
|
514
|
+
};
|
|
515
|
+
await expect(synchronizeOhStoreV1(local, transport, { batchSize: 1 }))
|
|
516
|
+
.rejects.toThrow("remote history does not extend");
|
|
517
|
+
expect(local.head().sequence).toBe(0);
|
|
518
|
+
expect(local.snapshotRecords()).toEqual([]);
|
|
519
|
+
local.close();
|
|
82
520
|
source.close();
|
|
83
521
|
});
|
|
84
522
|
|
|
@@ -114,4 +552,134 @@ describe("operation sync", () => {
|
|
|
114
552
|
source.close();
|
|
115
553
|
database.close();
|
|
116
554
|
});
|
|
555
|
+
|
|
556
|
+
test("acknowledges an exact pushed tail after libSQL history advances", async () => {
|
|
557
|
+
const database = new Database(":memory:", { strict: true });
|
|
558
|
+
const replayLimits: number[] = [];
|
|
559
|
+
const execute = (statement: LibSqlStatementV1 | string): LibSqlResultV1 => {
|
|
560
|
+
const sql = typeof statement === "string" ? statement : statement.sql;
|
|
561
|
+
const args = typeof statement === "string" ? [] : statement.args ?? [];
|
|
562
|
+
if (sql.includes("SELECT sequence, operation_sha256, operation_json")) {
|
|
563
|
+
replayLimits.push(Number(args[3]));
|
|
564
|
+
}
|
|
565
|
+
const bindings: SQLQueryBindings[] = args.map((value) => value instanceof Date
|
|
566
|
+
? value.toISOString() : value instanceof ArrayBuffer ? new Uint8Array(value) : value);
|
|
567
|
+
if (/^\s*SELECT\b/iu.test(sql)) {
|
|
568
|
+
return { rows: database.query<Record<string, unknown>, SQLQueryBindings[]>(sql).all(...bindings) };
|
|
569
|
+
}
|
|
570
|
+
database.query<never, SQLQueryBindings[]>(sql).run(...bindings);
|
|
571
|
+
return { rows: [] };
|
|
572
|
+
};
|
|
573
|
+
const client: LibSqlClientV1 = {
|
|
574
|
+
execute: async (statement) => execute(statement),
|
|
575
|
+
batch: async (statements) => database.transaction((items: LibSqlStatementV1[]) =>
|
|
576
|
+
items.map((statement) => execute(statement)))(statements),
|
|
577
|
+
};
|
|
578
|
+
const transport = createLibSqlOperationSyncTransportV1(client);
|
|
579
|
+
const source = new OhSqliteStore({ path: ":memory:" });
|
|
580
|
+
put(source, "entity:a", "A", "op_a");
|
|
581
|
+
put(source, "entity:b", "B", "op_b");
|
|
582
|
+
put(source, "entity:c", "C", "op_c");
|
|
583
|
+
const operations = source.exportOperations();
|
|
584
|
+
const [first, second, third] = operations;
|
|
585
|
+
if (first === undefined || second === undefined || third === undefined) {
|
|
586
|
+
throw new Error("Expected three source operations.");
|
|
587
|
+
}
|
|
588
|
+
const submitted = createOhSyncBundleV1(source.spaceId, [first, second]);
|
|
589
|
+
const later = createOhSyncBundleV1(source.spaceId, [third]);
|
|
590
|
+
await transport.push(submitted);
|
|
591
|
+
await transport.push(later);
|
|
592
|
+
expect(await transport.push(submitted)).toEqual({
|
|
593
|
+
operationSha256: second.operationSha256,
|
|
594
|
+
sequence: 2,
|
|
595
|
+
v: 1,
|
|
596
|
+
});
|
|
597
|
+
expect(replayLimits.at(-1)).toBe(submitted.operations.length);
|
|
598
|
+
|
|
599
|
+
database.query("UPDATE oh_sync_operations SET operation_json = ? WHERE space_id = ? AND sequence = 1")
|
|
600
|
+
.run("{}", source.spaceId);
|
|
601
|
+
await expect(transport.push(submitted)).rejects.toThrow("differs from the pushed operations");
|
|
602
|
+
database.query("UPDATE oh_sync_operations SET operation_json = ? WHERE space_id = ? AND sequence = 1")
|
|
603
|
+
.run(canonicalJson(first), source.spaceId);
|
|
604
|
+
database.query("DELETE FROM oh_sync_operations WHERE space_id = ? AND sequence = 2")
|
|
605
|
+
.run(source.spaceId);
|
|
606
|
+
await expect(transport.push(submitted)).rejects.toThrow("does not contain the exact pushed operations");
|
|
607
|
+
source.close();
|
|
608
|
+
database.close();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("paginates synchronization when valid history exceeds the bundle byte budget", async () => {
|
|
612
|
+
const database = new Database(":memory:", { strict: true });
|
|
613
|
+
const execute = (statement: LibSqlStatementV1 | string): LibSqlResultV1 => {
|
|
614
|
+
const sql = typeof statement === "string" ? statement : statement.sql;
|
|
615
|
+
const args = typeof statement === "string" ? [] : statement.args ?? [];
|
|
616
|
+
const bindings: SQLQueryBindings[] = args.map((value) => value instanceof Date
|
|
617
|
+
? value.toISOString() : value instanceof ArrayBuffer ? new Uint8Array(value) : value);
|
|
618
|
+
if (/^\s*SELECT\b/iu.test(sql)) {
|
|
619
|
+
return { rows: database.query<Record<string, unknown>, SQLQueryBindings[]>(sql).all(...bindings) };
|
|
620
|
+
}
|
|
621
|
+
database.query<never, SQLQueryBindings[]>(sql).run(...bindings);
|
|
622
|
+
return { rows: [] };
|
|
623
|
+
};
|
|
624
|
+
const client: LibSqlClientV1 = {
|
|
625
|
+
execute: async (statement) => execute(statement),
|
|
626
|
+
batch: async (statements) => database.transaction((items: LibSqlStatementV1[]) =>
|
|
627
|
+
items.map((statement) => execute(statement)))(statements),
|
|
628
|
+
};
|
|
629
|
+
const transport = createLibSqlOperationSyncTransportV1(client);
|
|
630
|
+
const source = new OhSqliteStore({ path: ":memory:" });
|
|
631
|
+
const destination = new OhSqliteStore({ path: ":memory:" });
|
|
632
|
+
const value = "x".repeat(OH_GRAPH_LIMITS_V1.recordBytes - 2);
|
|
633
|
+
for (let page = 0; page < 2; page += 1) {
|
|
634
|
+
const changes = Array.from({ length: 32 }, (_, index) => ({
|
|
635
|
+
kind: "put" as const,
|
|
636
|
+
record: createKnowledgeGraphRecordV1({ dependencies: [],
|
|
637
|
+
key: `entity:large-${page}${String(index).padStart(2, "0")}`,
|
|
638
|
+
kind: "entity", v: 1, value }),
|
|
639
|
+
v: 1 as const,
|
|
640
|
+
}));
|
|
641
|
+
source.commit({ actorId: "agent.test", changes, expectedHead: source.head(),
|
|
642
|
+
operationId: `op_large_page_${page}` });
|
|
643
|
+
}
|
|
644
|
+
expect(await synchronizeOhStoreV1(source, transport, {
|
|
645
|
+
batchSize: 100,
|
|
646
|
+
maximumRounds: 2,
|
|
647
|
+
})).toMatchObject({ pulled: 0, pushed: 2, rounds: 2 });
|
|
648
|
+
expect(await synchronizeOhStoreV1(destination, transport, {
|
|
649
|
+
batchSize: 100,
|
|
650
|
+
maximumRounds: 2,
|
|
651
|
+
})).toMatchObject({ pulled: 2, pushed: 0, rounds: 2 });
|
|
652
|
+
expect(destination.head()).toEqual(source.head());
|
|
653
|
+
destination.close();
|
|
654
|
+
source.close();
|
|
655
|
+
database.close();
|
|
656
|
+
}, 30_000);
|
|
657
|
+
|
|
658
|
+
test("bounds libSQL pull results and raw operation JSON before parsing", async () => {
|
|
659
|
+
let pullRows: readonly (Readonly<Record<string, unknown>> | readonly unknown[])[] = [];
|
|
660
|
+
const client: LibSqlClientV1 = {
|
|
661
|
+
batch: async () => [],
|
|
662
|
+
execute: async (statement) => {
|
|
663
|
+
const sql = typeof statement === "string" ? statement : statement.sql;
|
|
664
|
+
if (sql.includes("SELECT contract_sha256")) {
|
|
665
|
+
return { rows: [{ contract_sha256: OH_CONTRACT_MANIFEST_V1.contractSha256,
|
|
666
|
+
manifest_json: canonicalJson(OH_CONTRACT_MANIFEST_V1) }] };
|
|
667
|
+
}
|
|
668
|
+
return { rows: pullRows };
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
const transport = createLibSqlOperationSyncTransportV1(client);
|
|
672
|
+
let rowReads = 0;
|
|
673
|
+
const overLimit = new Array(2) as (Readonly<Record<string, unknown>> | readonly unknown[])[];
|
|
674
|
+
Object.defineProperty(overLimit, "0", { configurable: true, enumerable: true,
|
|
675
|
+
get() { rowReads += 1; throw new Error("must not read an over-limit row"); } });
|
|
676
|
+
pullRows = overLimit;
|
|
677
|
+
await expect(transport.pull("default", 0, 1)).rejects.toThrow("requested row limit");
|
|
678
|
+
expect(rowReads).toBe(0);
|
|
679
|
+
|
|
680
|
+
pullRows = [{ operation_json: "x".repeat(OH_OPERATION_MAX_BYTES_V1 + 1) }];
|
|
681
|
+
await expect(transport.pull("default", 0, 1)).rejects.toThrow("operation byte limit");
|
|
682
|
+
await expect(transport.pull("default", -0, 1)).rejects.toThrow(TypeError);
|
|
683
|
+
await expect(transport.pull("default", 0, 1001)).rejects.toThrow(TypeError);
|
|
684
|
+
});
|
|
117
685
|
});
|