@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/operation.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
safeCode,
|
|
9
9
|
type Sha256Hex,
|
|
10
10
|
} from "./canonical";
|
|
11
|
+
import { OhOperationSizeError } from "./errors";
|
|
11
12
|
import { OH_CONTRACT_ID_V1 } from "./ontology";
|
|
12
13
|
import { canonicalKnowledgeGraphChangesV1, OH_GRAPH_LIMITS_V1, type KnowledgeGraphChangeV1 } from "./graph";
|
|
13
14
|
|
|
@@ -58,12 +59,21 @@ function parsePayload(value: unknown): OhOperationPayloadV1 | null {
|
|
|
58
59
|
: null;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
export function createOhOperationV1(
|
|
62
|
+
export function createOhOperationV1(
|
|
63
|
+
input: OhOperationPayloadV1,
|
|
64
|
+
options: Readonly<{ maximumOperationBytes?: number }> = {},
|
|
65
|
+
): OhOperationV1 {
|
|
66
|
+
const maximumOperationBytes = options.maximumOperationBytes ?? OH_OPERATION_MAX_BYTES_V1;
|
|
67
|
+
if (!Number.isSafeInteger(maximumOperationBytes) || maximumOperationBytes < 1
|
|
68
|
+
|| maximumOperationBytes > OH_OPERATION_MAX_BYTES_V1) {
|
|
69
|
+
throw new TypeError("Invalid Oh operation byte bound.");
|
|
70
|
+
}
|
|
62
71
|
const payload = parsePayload(input);
|
|
63
72
|
if (payload === null) throw new TypeError("Invalid Oh operation payload.");
|
|
64
73
|
const operation = { ...payload, operationSha256: canonicalSha256(payload) };
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
const operationBytes = Buffer.byteLength(canonicalJson(operation), "utf8");
|
|
75
|
+
if (operationBytes > maximumOperationBytes) {
|
|
76
|
+
throw new OhOperationSizeError(operationBytes, maximumOperationBytes);
|
|
67
77
|
}
|
|
68
78
|
return operation;
|
|
69
79
|
}
|
package/src/sqlite/port.test.ts
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
2
3
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
|
|
7
|
+
import { canonicalSha256 } from "../canonical";
|
|
6
8
|
import { createKnowledgeGraphRecordV1 } from "../graph";
|
|
9
|
+
import { createOhOperationV1 } from "../operation";
|
|
7
10
|
import {
|
|
8
11
|
createOhStoreBindingV1,
|
|
9
12
|
OH_CANONICAL_STORE_PROFILE_V1,
|
|
10
13
|
OH_WORKING_STORE_PROFILE_V1,
|
|
14
|
+
OhConflictError,
|
|
11
15
|
OhProfileError,
|
|
12
16
|
OhPurgedSpaceError,
|
|
17
|
+
type OhHeadRefV1,
|
|
13
18
|
} from "../store";
|
|
19
|
+
import { createOhSyncBundleV1 } from "../sync";
|
|
14
20
|
import { createOhSqliteStoreAuthorityV1 } from "./port";
|
|
15
21
|
import { OhSqliteStore } from "./store";
|
|
16
22
|
|
|
@@ -115,12 +121,215 @@ describe("promise-based SQLite store port", () => {
|
|
|
115
121
|
await authority.store.close();
|
|
116
122
|
});
|
|
117
123
|
|
|
124
|
+
test("keeps pinned canonical replication on host control", async () => {
|
|
125
|
+
const authority = createOhSqliteStoreAuthorityV1({ path: ":memory:",
|
|
126
|
+
profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:replication", spaceId: "replication" });
|
|
127
|
+
expect("replication" in authority.store).toBe(false);
|
|
128
|
+
const replication = authority.host.replication;
|
|
129
|
+
expect(replication).not.toBeNull();
|
|
130
|
+
if (replication === null) throw new Error("Expected canonical replication authority.");
|
|
131
|
+
expect(Object.isFrozen(replication)).toBe(true);
|
|
132
|
+
const empty = await authority.store.head();
|
|
133
|
+
const first = await authority.store.commit({ actorId: "host.test", changes: [{ kind: "put",
|
|
134
|
+
record: entity("entity:first", "First"), v: 1 }], expectedHead: empty,
|
|
135
|
+
instant: "2026-09-06T01:00:00.000Z", operationId: "op_first" });
|
|
136
|
+
const second = await authority.store.commit({ actorId: "host.test", changes: [{ kind: "put",
|
|
137
|
+
record: entity("entity:second", "Second"), v: 1 }], expectedHead: await authority.store.head(),
|
|
138
|
+
instant: "2026-09-06T01:01:00.000Z", operationId: "op_second" });
|
|
139
|
+
const pinned = await authority.store.head();
|
|
140
|
+
await authority.store.commit({ actorId: "host.test", changes: [{ kind: "put",
|
|
141
|
+
record: entity("entity:third", "Third"), v: 1 }], expectedHead: pinned,
|
|
142
|
+
instant: "2026-09-06T01:02:00.000Z", operationId: "op_third" });
|
|
143
|
+
|
|
144
|
+
const firstPage = await replication.exportBundle({
|
|
145
|
+
after: { operationSha256: empty.operationSha256, sequence: empty.sequence },
|
|
146
|
+
limit: 1,
|
|
147
|
+
through: { operationSha256: pinned.operationSha256, sequence: pinned.sequence },
|
|
148
|
+
});
|
|
149
|
+
expect(firstPage.bundle.operations.map(({ operationId }) => operationId)).toEqual(["op_first"]);
|
|
150
|
+
expect(firstPage).toMatchObject({
|
|
151
|
+
from: { operationSha256: null, sequence: 0 },
|
|
152
|
+
hasMore: true,
|
|
153
|
+
through: pinned,
|
|
154
|
+
to: { operationSha256: first.operationSha256, sequence: first.sequence },
|
|
155
|
+
v: 1,
|
|
156
|
+
});
|
|
157
|
+
const finalPage = await replication.exportBundle({
|
|
158
|
+
after: firstPage.to,
|
|
159
|
+
limit: 1,
|
|
160
|
+
through: { operationSha256: pinned.operationSha256, sequence: pinned.sequence },
|
|
161
|
+
});
|
|
162
|
+
expect(finalPage.bundle.operations.map(({ operationId }) => operationId)).toEqual(["op_second"]);
|
|
163
|
+
expect(finalPage).toMatchObject({
|
|
164
|
+
from: { operationSha256: first.operationSha256, sequence: first.sequence },
|
|
165
|
+
hasMore: false,
|
|
166
|
+
through: pinned,
|
|
167
|
+
to: { operationSha256: second.operationSha256, sequence: second.sequence },
|
|
168
|
+
v: 1,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const bundle = await replication.exportBundle({
|
|
172
|
+
after: { operationSha256: empty.operationSha256, sequence: empty.sequence },
|
|
173
|
+
through: { operationSha256: pinned.operationSha256, sequence: pinned.sequence },
|
|
174
|
+
});
|
|
175
|
+
expect(bundle.bundle.operations.map(({ operationId }) => operationId)).toEqual(["op_first", "op_second"]);
|
|
176
|
+
expect(bundle.bundle.operations.at(-1)?.operationSha256).toBe(second.operationSha256);
|
|
177
|
+
expect(bundle.bundle.operations).not.toContainEqual(expect.objectContaining({ operationId: "op_third" }));
|
|
178
|
+
expect(bundle).toMatchObject({ from: { sequence: 0 }, hasMore: false,
|
|
179
|
+
through: pinned, to: { sequence: 2 }, v: 1 });
|
|
180
|
+
await expect(replication.exportBundle({
|
|
181
|
+
after: { operationSha256: first.operationSha256, sequence: first.sequence },
|
|
182
|
+
through: { operationSha256: canonicalSha256("wrong through head"), sequence: pinned.sequence },
|
|
183
|
+
})).rejects.toThrow();
|
|
184
|
+
|
|
185
|
+
const working = createOhSqliteStoreAuthorityV1({ path: ":memory:",
|
|
186
|
+
profile: OH_WORKING_STORE_PROFILE_V1, realmId: "realm:working-replication",
|
|
187
|
+
spaceId: "working-replication" });
|
|
188
|
+
expect(working.host.replication).toBeNull();
|
|
189
|
+
await working.store.close();
|
|
190
|
+
await authority.store.close();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("imports canonical replication bundles atomically and replays them exactly", async () => {
|
|
194
|
+
const source = createOhSqliteStoreAuthorityV1({ path: ":memory:",
|
|
195
|
+
profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:atomic", spaceId: "atomic" });
|
|
196
|
+
const targetDatabase = new Database(":memory:", { strict: true });
|
|
197
|
+
const target = createOhSqliteStoreAuthorityV1({ database: targetDatabase,
|
|
198
|
+
profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:atomic", spaceId: "atomic" });
|
|
199
|
+
const empty = await target.store.head();
|
|
200
|
+
const first = await source.store.commit({ actorId: "host.test", changes: [{ kind: "put",
|
|
201
|
+
record: entity("entity:first", "First"), v: 1 }], expectedHead: await source.store.head(),
|
|
202
|
+
instant: "2026-09-06T02:00:00.000Z", operationId: "op_atomic_first" });
|
|
203
|
+
const second = await source.store.commit({ actorId: "host.test", changes: [{ kind: "put",
|
|
204
|
+
record: entity("entity:second", "Second"), v: 1 }], expectedHead: await source.store.head(),
|
|
205
|
+
instant: "2026-09-06T02:01:00.000Z", operationId: "op_atomic_second" });
|
|
206
|
+
const { operationSha256: _operationSha256, ...secondPayload } = second;
|
|
207
|
+
const hostileSecond = createOhOperationV1({
|
|
208
|
+
...secondPayload,
|
|
209
|
+
graphRevisionSha256: canonicalSha256("hostile graph revision"),
|
|
210
|
+
recordsSha256: canonicalSha256("hostile records"),
|
|
211
|
+
});
|
|
212
|
+
const hostile = createOhSyncBundleV1("atomic", [first, hostileSecond]);
|
|
213
|
+
const targetReplication = target.host.replication;
|
|
214
|
+
const sourceReplication = source.host.replication;
|
|
215
|
+
if (targetReplication === null || sourceReplication === null) {
|
|
216
|
+
throw new Error("Expected canonical replication authority.");
|
|
217
|
+
}
|
|
218
|
+
const firstBundle = createOhSyncBundleV1("atomic", [first]);
|
|
219
|
+
await expect(targetReplication.importBundle({
|
|
220
|
+
bundle: firstBundle,
|
|
221
|
+
expectedHead: { operationSha256: null, sequence: -0 },
|
|
222
|
+
})).rejects.toThrow(TypeError);
|
|
223
|
+
let rejectedBundleReads = 0;
|
|
224
|
+
const rejectedBundle = new Proxy({}, {
|
|
225
|
+
get() { rejectedBundleReads += 1; throw new Error("must not read bundle"); },
|
|
226
|
+
getOwnPropertyDescriptor() { rejectedBundleReads += 1; throw new Error("must not inspect bundle"); },
|
|
227
|
+
getPrototypeOf() { rejectedBundleReads += 1; throw new Error("must not inspect bundle"); },
|
|
228
|
+
ownKeys() { rejectedBundleReads += 1; throw new Error("must not inspect bundle"); },
|
|
229
|
+
});
|
|
230
|
+
await expect(targetReplication.importBundle({
|
|
231
|
+
bundle: rejectedBundle,
|
|
232
|
+
expectedHead: { operationSha256: null, sequence: -0 },
|
|
233
|
+
})).rejects.toThrow(TypeError);
|
|
234
|
+
expect(rejectedBundleReads).toBe(0);
|
|
235
|
+
let expectedHeadAccessorReads = 0;
|
|
236
|
+
const accessorExpectedHead = { operationSha256: null } as Record<PropertyKey, unknown>;
|
|
237
|
+
Object.defineProperty(accessorExpectedHead, "sequence", { enumerable: true,
|
|
238
|
+
get() { expectedHeadAccessorReads += 1; throw new Error("must not execute"); } });
|
|
239
|
+
await expect(targetReplication.importBundle({
|
|
240
|
+
bundle: firstBundle,
|
|
241
|
+
expectedHead: accessorExpectedHead as OhHeadRefV1,
|
|
242
|
+
})).rejects.toThrow(TypeError);
|
|
243
|
+
let expectedHeadProxyReads = 0;
|
|
244
|
+
const proxyExpectedHead = new Proxy({ operationSha256: null, sequence: 0 } as const, {
|
|
245
|
+
get() { expectedHeadProxyReads += 1; throw new Error("must not execute"); },
|
|
246
|
+
});
|
|
247
|
+
await expect(targetReplication.importBundle({
|
|
248
|
+
bundle: firstBundle,
|
|
249
|
+
expectedHead: proxyExpectedHead,
|
|
250
|
+
})).rejects.toThrow(TypeError);
|
|
251
|
+
expect(expectedHeadAccessorReads).toBe(0);
|
|
252
|
+
expect(expectedHeadProxyReads).toBe(0);
|
|
253
|
+
expect(await target.store.head()).toEqual(empty);
|
|
254
|
+
await expect(targetReplication.importBundle({
|
|
255
|
+
bundle: hostile,
|
|
256
|
+
expectedHead: { operationSha256: empty.operationSha256, sequence: empty.sequence },
|
|
257
|
+
})).rejects.toThrow("does not reproduce");
|
|
258
|
+
expect(await target.store.head()).toEqual(empty);
|
|
259
|
+
expect((await target.store.snapshot()).records).toEqual([]);
|
|
260
|
+
expect(await target.store.verify()).toMatchObject({ operations: 0, records: 0 });
|
|
261
|
+
for (const table of ["oh_operations", "oh_operation_records", "oh_records", "oh_dependencies",
|
|
262
|
+
"oh_search_documents", "oh_search_fts", "oh_sync_outbox"]) {
|
|
263
|
+
const count = targetDatabase.query<{ count: number }, []>(
|
|
264
|
+
`SELECT count(*) AS count FROM ${table}`,
|
|
265
|
+
).get()?.count;
|
|
266
|
+
expect(count, table).toBe(0);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const sourceHead = await source.store.head();
|
|
270
|
+
const exact = await sourceReplication.exportBundle({
|
|
271
|
+
after: { operationSha256: empty.operationSha256, sequence: empty.sequence },
|
|
272
|
+
through: { operationSha256: sourceHead.operationSha256, sequence: sourceHead.sequence },
|
|
273
|
+
});
|
|
274
|
+
expect(await targetReplication.importBundle({
|
|
275
|
+
bundle: exact.bundle,
|
|
276
|
+
expectedHead: { operationSha256: empty.operationSha256, sequence: empty.sequence },
|
|
277
|
+
})).toMatchObject({ head: sourceHead, imported: 2, status: "imported" });
|
|
278
|
+
expect(await targetReplication.importBundle({
|
|
279
|
+
bundle: exact.bundle,
|
|
280
|
+
expectedHead: { operationSha256: empty.operationSha256, sequence: empty.sequence },
|
|
281
|
+
})).toMatchObject({ head: sourceHead, imported: 0, status: "already-present" });
|
|
282
|
+
|
|
283
|
+
const third = await target.store.commit({ actorId: "host.test", changes: [{ kind: "put",
|
|
284
|
+
record: entity("entity:third", "Third"), v: 1 }], expectedHead: await target.store.head(),
|
|
285
|
+
instant: "2026-09-06T02:02:00.000Z", operationId: "op_atomic_third" });
|
|
286
|
+
expect(await targetReplication.importBundle({
|
|
287
|
+
bundle: exact.bundle,
|
|
288
|
+
expectedHead: { operationSha256: empty.operationSha256, sequence: empty.sequence },
|
|
289
|
+
})).toMatchObject({ head: { operationSha256: third.operationSha256, sequence: third.sequence },
|
|
290
|
+
imported: 0, status: "already-present" });
|
|
291
|
+
|
|
292
|
+
const fork = createOhSqliteStoreAuthorityV1({ path: ":memory:",
|
|
293
|
+
profile: OH_CANONICAL_STORE_PROFILE_V1, realmId: "realm:atomic", spaceId: "atomic" });
|
|
294
|
+
const forkReplication = fork.host.replication;
|
|
295
|
+
if (forkReplication === null) throw new Error("Expected fork replication authority.");
|
|
296
|
+
await forkReplication.importBundle({
|
|
297
|
+
bundle: createOhSyncBundleV1("atomic", [first]),
|
|
298
|
+
expectedHead: { operationSha256: empty.operationSha256, sequence: empty.sequence },
|
|
299
|
+
});
|
|
300
|
+
const divergent = await fork.store.commit({ actorId: "host.fork", changes: [{ kind: "put",
|
|
301
|
+
record: entity("entity:fork", "Fork"), v: 1 }], expectedHead: await fork.store.head(),
|
|
302
|
+
instant: "2026-09-06T02:01:30.000Z", operationId: "op_atomic_fork" });
|
|
303
|
+
await expect(targetReplication.importBundle({
|
|
304
|
+
bundle: createOhSyncBundleV1("atomic", [divergent]),
|
|
305
|
+
expectedHead: { operationSha256: first.operationSha256, sequence: first.sequence },
|
|
306
|
+
})).rejects.toThrow(OhConflictError);
|
|
307
|
+
expect(await target.store.head()).toMatchObject({
|
|
308
|
+
operationSha256: third.operationSha256,
|
|
309
|
+
sequence: third.sequence,
|
|
310
|
+
});
|
|
311
|
+
const history = await target.store.changesSince({ operationSha256: null, sequence: 0 }, {
|
|
312
|
+
through: { operationSha256: third.operationSha256, sequence: third.sequence },
|
|
313
|
+
});
|
|
314
|
+
expect(history.operations.map(({ operationId }) => operationId))
|
|
315
|
+
.toEqual(["op_atomic_first", "op_atomic_second", "op_atomic_third"]);
|
|
316
|
+
expect(await target.store.verify()).toMatchObject({ operations: 3, records: 3 });
|
|
317
|
+
|
|
318
|
+
await fork.store.close();
|
|
319
|
+
await target.store.close();
|
|
320
|
+
await source.store.close();
|
|
321
|
+
});
|
|
322
|
+
|
|
118
323
|
test("refuses operation replication for a bound working profile", () => {
|
|
119
324
|
const store = new OhSqliteStore({ path: ":memory:", spaceId: "local-only" });
|
|
120
325
|
store.bind(createOhStoreBindingV1({ profile: OH_WORKING_STORE_PROFILE_V1,
|
|
121
326
|
realmId: "realm:local-only", spaceId: "local-only", v: 1 }));
|
|
122
327
|
expect(() => store.exportOperations()).toThrow(OhProfileError);
|
|
123
328
|
expect(() => store.importOperation({})).toThrow(OhProfileError);
|
|
329
|
+
expect(() => store.importOperations({
|
|
330
|
+
expectedHead: { operationSha256: null, sequence: 0 },
|
|
331
|
+
operations: [],
|
|
332
|
+
})).toThrow(OhProfileError);
|
|
124
333
|
expect(store.verifyReplay()).toMatchObject({ operations: 0, records: 0 });
|
|
125
334
|
store.close();
|
|
126
335
|
});
|
package/src/sqlite/port.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isProxy } from "node:util/types";
|
|
2
|
+
|
|
1
3
|
import { canonicalJson } from "../canonical";
|
|
2
4
|
import {
|
|
3
5
|
createOhStoreBindingV1,
|
|
@@ -11,7 +13,6 @@ import {
|
|
|
11
13
|
type OhHeadV1,
|
|
12
14
|
type OhSnapshotV1,
|
|
13
15
|
type OhSpacePurgeReceiptV1,
|
|
14
|
-
type OhStoreAuthorityV1,
|
|
15
16
|
type OhStoreBindingV1,
|
|
16
17
|
type OhStoreHostControlV1,
|
|
17
18
|
type OhStoreProfileV1,
|
|
@@ -19,8 +20,17 @@ import {
|
|
|
19
20
|
type OhStoreVerificationV1,
|
|
20
21
|
} from "../store";
|
|
21
22
|
import type { OhOperationV1 } from "../operation";
|
|
23
|
+
import {
|
|
24
|
+
createOhSyncBundleV1,
|
|
25
|
+
parseOhSyncBundleV1,
|
|
26
|
+
parseOhSyncHeadRefV1,
|
|
27
|
+
type OhSyncBundleV1,
|
|
28
|
+
} from "../sync";
|
|
22
29
|
import type { OhSqliteDatabase } from "./driver";
|
|
23
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
OhSqliteStore,
|
|
32
|
+
type OhOperationImportResultV1,
|
|
33
|
+
} from "./store";
|
|
24
34
|
|
|
25
35
|
export type OhSqliteStoreAuthorityOptionsV1 = Readonly<{
|
|
26
36
|
database?: OhSqliteDatabase;
|
|
@@ -30,6 +40,59 @@ export type OhSqliteStoreAuthorityOptionsV1 = Readonly<{
|
|
|
30
40
|
spaceId?: string;
|
|
31
41
|
}>;
|
|
32
42
|
|
|
43
|
+
export interface OhSqliteCanonicalReplicationV1 {
|
|
44
|
+
readonly binding: OhStoreBindingV1;
|
|
45
|
+
exportBundle(input: Readonly<{
|
|
46
|
+
after: OhHeadRefV1;
|
|
47
|
+
limit?: number;
|
|
48
|
+
through: OhHeadRefV1;
|
|
49
|
+
}>): Promise<Readonly<{
|
|
50
|
+
bundle: OhSyncBundleV1;
|
|
51
|
+
from: OhChangesPageV1["from"];
|
|
52
|
+
hasMore: boolean;
|
|
53
|
+
through: OhChangesPageV1["through"];
|
|
54
|
+
to: OhChangesPageV1["to"];
|
|
55
|
+
v: 1;
|
|
56
|
+
}>>;
|
|
57
|
+
head(): Promise<OhHeadV1>;
|
|
58
|
+
importBundle(input: Readonly<{
|
|
59
|
+
bundle: unknown;
|
|
60
|
+
expectedHead: OhHeadRefV1;
|
|
61
|
+
}>): Promise<OhOperationImportResultV1>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface OhSqliteStoreHostControlV1 extends OhStoreHostControlV1 {
|
|
65
|
+
readonly replication: OhSqliteCanonicalReplicationV1 | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type OhSqliteStoreAuthorityV1 = Readonly<{
|
|
69
|
+
host: OhSqliteStoreHostControlV1;
|
|
70
|
+
store: OhStoreV1;
|
|
71
|
+
}>;
|
|
72
|
+
|
|
73
|
+
function exactReplicationImportInputV1(value: unknown): Readonly<{
|
|
74
|
+
bundle: unknown;
|
|
75
|
+
expectedHead: unknown;
|
|
76
|
+
}> | null {
|
|
77
|
+
try {
|
|
78
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) || isProxy(value)) return null;
|
|
79
|
+
const prototype = Object.getPrototypeOf(value);
|
|
80
|
+
const keys = Reflect.ownKeys(value);
|
|
81
|
+
if ((prototype !== Object.prototype && prototype !== null)
|
|
82
|
+
|| keys.length !== 2 || !keys.includes("bundle") || !keys.includes("expectedHead")
|
|
83
|
+
|| keys.some((key) => typeof key !== "string")) return null;
|
|
84
|
+
const bundle = Object.getOwnPropertyDescriptor(value, "bundle");
|
|
85
|
+
const expectedHead = Object.getOwnPropertyDescriptor(value, "expectedHead");
|
|
86
|
+
if (bundle === undefined || expectedHead === undefined
|
|
87
|
+
|| !bundle.enumerable || !expectedHead.enumerable
|
|
88
|
+
|| bundle.get !== undefined || bundle.set !== undefined
|
|
89
|
+
|| expectedHead.get !== undefined || expectedHead.set !== undefined) return null;
|
|
90
|
+
return { bundle: bundle.value, expectedHead: expectedHead.value };
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
33
96
|
export class OhSqliteStorePortV1 implements OhStoreV1 {
|
|
34
97
|
readonly binding: OhStoreBindingV1;
|
|
35
98
|
readonly #authority: OhSqliteStore;
|
|
@@ -91,7 +154,7 @@ export class OhSqliteStorePortV1 implements OhStoreV1 {
|
|
|
91
154
|
*/
|
|
92
155
|
export function createOhSqliteStoreAuthorityV1(
|
|
93
156
|
options: OhSqliteStoreAuthorityOptionsV1 = {},
|
|
94
|
-
):
|
|
157
|
+
): OhSqliteStoreAuthorityV1 {
|
|
95
158
|
const profile = parseOhStoreProfileV1(options.profile ?? OH_CANONICAL_STORE_PROFILE_V1);
|
|
96
159
|
if (profile === null) throw new TypeError("Invalid SQLite store profile.");
|
|
97
160
|
const spaceId = options.spaceId ?? "default";
|
|
@@ -104,7 +167,57 @@ export function createOhSqliteStoreAuthorityV1(
|
|
|
104
167
|
});
|
|
105
168
|
const store = new OhSqliteStorePortV1(authority, binding);
|
|
106
169
|
let purge: OhSpacePurgeReceiptV1 | null = null;
|
|
107
|
-
const
|
|
170
|
+
const replication: OhSqliteCanonicalReplicationV1 | null =
|
|
171
|
+
profile.capabilities.operationReplication
|
|
172
|
+
? Object.freeze({
|
|
173
|
+
binding,
|
|
174
|
+
exportBundle: async (input: Readonly<{
|
|
175
|
+
after: OhHeadRefV1;
|
|
176
|
+
limit?: number;
|
|
177
|
+
through: OhHeadRefV1;
|
|
178
|
+
}>) => {
|
|
179
|
+
const page = authority.changesSince(input.after, {
|
|
180
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
181
|
+
through: input.through,
|
|
182
|
+
});
|
|
183
|
+
const bundle = createOhSyncBundleV1(binding.spaceId, page.operations, {
|
|
184
|
+
largestFittingPrefix: true,
|
|
185
|
+
});
|
|
186
|
+
const last = bundle.operations.at(-1);
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
bundle,
|
|
189
|
+
from: page.from,
|
|
190
|
+
hasMore: page.hasMore || bundle.operations.length < page.operations.length,
|
|
191
|
+
through: page.through,
|
|
192
|
+
to: last === undefined ? page.from : {
|
|
193
|
+
operationSha256: last.operationSha256,
|
|
194
|
+
sequence: last.sequence,
|
|
195
|
+
},
|
|
196
|
+
v: 1 as const,
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
head: async () => authority.head(),
|
|
200
|
+
importBundle: async (input: Readonly<{
|
|
201
|
+
bundle: unknown;
|
|
202
|
+
expectedHead: OhHeadRefV1;
|
|
203
|
+
}>) => {
|
|
204
|
+
const request = exactReplicationImportInputV1(input);
|
|
205
|
+
const expectedHead = request === null ? null : parseOhSyncHeadRefV1(request.expectedHead);
|
|
206
|
+
if (request === null || expectedHead === null) {
|
|
207
|
+
throw new TypeError("Invalid canonical replication request.");
|
|
208
|
+
}
|
|
209
|
+
const bundle = parseOhSyncBundleV1(request.bundle);
|
|
210
|
+
if (bundle === null || bundle.spaceId !== binding.spaceId) {
|
|
211
|
+
throw new TypeError("Invalid canonical replication bundle.");
|
|
212
|
+
}
|
|
213
|
+
return authority.importOperations({
|
|
214
|
+
expectedHead,
|
|
215
|
+
operations: bundle.operations,
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
: null;
|
|
220
|
+
const host: OhSqliteStoreHostControlV1 = Object.freeze({
|
|
108
221
|
binding,
|
|
109
222
|
purgeWorkingSpace: async (input: Readonly<{ purgedAt?: string }>) => {
|
|
110
223
|
if (profile.profileKind !== "working" || !profile.capabilities.wholeSpacePurge) {
|
|
@@ -115,6 +228,7 @@ export function createOhSqliteStoreAuthorityV1(
|
|
|
115
228
|
authority.close();
|
|
116
229
|
return purge;
|
|
117
230
|
},
|
|
231
|
+
replication,
|
|
118
232
|
});
|
|
119
233
|
return Object.freeze({ host, store });
|
|
120
234
|
}
|
package/src/sqlite/store.test.ts
CHANGED
|
@@ -3,9 +3,12 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
|
|
6
|
+
import { canonicalJson, canonicalSha256 } from "../canonical";
|
|
6
7
|
import { createKnowledgeGraphRecordV1 } from "../graph";
|
|
8
|
+
import { createOhOperationV1 } from "../operation";
|
|
7
9
|
import { createOhStoreBindingV1, OH_WORKING_STORE_PROFILE_V1 } from "../store";
|
|
8
|
-
import { OhConflictError, OhDependencyError, OhIntegrityError,
|
|
10
|
+
import { OhConflictError, OhDependencyError, OhIntegrityError, OhOperationSizeError,
|
|
11
|
+
OhSqliteStore } from "./store";
|
|
9
12
|
|
|
10
13
|
const roots: string[] = [];
|
|
11
14
|
afterEach(async () => {
|
|
@@ -64,6 +67,40 @@ describe("Oh SQLite authority", () => {
|
|
|
64
67
|
store.close();
|
|
65
68
|
});
|
|
66
69
|
|
|
70
|
+
test("uses a distinct precommit error for host-declared operation byte bounds", () => {
|
|
71
|
+
const store = new OhSqliteStore({ path: ":memory:" });
|
|
72
|
+
const empty = store.head();
|
|
73
|
+
const changes = [{ kind: "put" as const,
|
|
74
|
+
record: record("entity:bounded", "Bounded"), v: 1 as const }];
|
|
75
|
+
const first = store.commit({ actorId: "agent.test", changes, expectedHead: empty,
|
|
76
|
+
operationId: "op_bounded_first" });
|
|
77
|
+
expect(() => store.commit({ actorId: "agent.test", changes, expectedHead: empty,
|
|
78
|
+
maximumOperationBytes: 1, operationId: "op_bounded_first" }))
|
|
79
|
+
.toThrow(OhOperationSizeError);
|
|
80
|
+
expect(() => store.commit({ actorId: "agent.test", changes: [{ kind: "put",
|
|
81
|
+
record: record("entity:rejected", "Rejected"), v: 1 }], expectedHead: store.head(),
|
|
82
|
+
maximumOperationBytes: 1, operationId: "op_bounded_rejected" }))
|
|
83
|
+
.toThrow(OhOperationSizeError);
|
|
84
|
+
expect(new OhOperationSizeError(2, 1)).toBeInstanceOf(RangeError);
|
|
85
|
+
expect(store.head()).toMatchObject({ operationSha256: first.operationSha256, sequence: 1 });
|
|
86
|
+
expect(store.exportOperations().map(({ operationId }) => operationId)).toEqual(["op_bounded_first"]);
|
|
87
|
+
expect(store.snapshotRecords().map(({ key }) => key)).toEqual(["entity:bounded"]);
|
|
88
|
+
store.close();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects copied size-error fields without a native branded error", () => {
|
|
92
|
+
const copied = Object.create(RangeError.prototype) as Record<PropertyKey, unknown>;
|
|
93
|
+
Object.defineProperties(copied, {
|
|
94
|
+
[Symbol.for("@hraness/oh/OhOperationSizeError/v1")]: {
|
|
95
|
+
configurable: false, value: true, writable: false,
|
|
96
|
+
},
|
|
97
|
+
code: { configurable: false, value: "oh.operation-size.v1", writable: false },
|
|
98
|
+
maximumOperationBytes: { configurable: false, value: 1, writable: false },
|
|
99
|
+
operationBytes: { configurable: false, value: 2, writable: false },
|
|
100
|
+
});
|
|
101
|
+
expect(copied instanceof OhOperationSizeError).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
67
104
|
test("rejects orphaned and cross-space rows as idempotent operations", () => {
|
|
68
105
|
const changes = [{ kind: "put" as const, record: record("entity:orphan", "Orphan"), v: 1 as const }];
|
|
69
106
|
const source = new OhSqliteStore({ path: ":memory:", spaceId: "orphan-target" });
|
|
@@ -181,6 +218,74 @@ describe("Oh SQLite authority", () => {
|
|
|
181
218
|
store.close();
|
|
182
219
|
});
|
|
183
220
|
|
|
221
|
+
test("verifies foreign keys and exact search materializations", () => {
|
|
222
|
+
const store = new OhSqliteStore({ path: ":memory:", spaceId: "search-integrity" });
|
|
223
|
+
store.commit({ actorId: "agent.test", changes: [{ kind: "put",
|
|
224
|
+
record: record("entity:a", "A"), v: 1 }], expectedHead: store.head(), operationId: "op_search" });
|
|
225
|
+
const document = store.database.query<{ text: string }, [string, string]>(
|
|
226
|
+
"SELECT text FROM oh_search_documents WHERE space_id = ? AND record_key = ?",
|
|
227
|
+
).get(store.spaceId, "entity:a");
|
|
228
|
+
if (document === null) throw new Error("Expected materialized search document.");
|
|
229
|
+
store.database.query("UPDATE oh_search_documents SET text = ? WHERE space_id = ? AND record_key = ?")
|
|
230
|
+
.run("tampered", store.spaceId, "entity:a");
|
|
231
|
+
expect(() => store.verifyReplay()).toThrow("Materialized search documents");
|
|
232
|
+
store.database.query("UPDATE oh_search_documents SET text = ? WHERE space_id = ? AND record_key = ?")
|
|
233
|
+
.run(document.text, store.spaceId, "entity:a");
|
|
234
|
+
store.database.query(`INSERT INTO oh_search_fts(space_id, record_key, text)
|
|
235
|
+
SELECT space_id, record_key, text FROM oh_search_fts WHERE space_id = ? AND record_key = ?`)
|
|
236
|
+
.run(store.spaceId, "entity:a");
|
|
237
|
+
expect(() => store.verifyReplay()).toThrow("Materialized full-text search rows");
|
|
238
|
+
store.database.query("DELETE FROM oh_search_fts WHERE rowid = (SELECT max(rowid) FROM oh_search_fts)").run();
|
|
239
|
+
store.database.exec("PRAGMA foreign_keys = OFF");
|
|
240
|
+
store.database.query(`INSERT INTO oh_sync_state(remote_id, space_id, pulled_sequence,
|
|
241
|
+
pushed_sequence, remote_head_sha256, updated_at) VALUES (?, ?, 0, 0, NULL, ?)`)
|
|
242
|
+
.run("remote.alien", "alien-space", "2026-09-06T12:00:00.000Z");
|
|
243
|
+
store.database.exec("PRAGMA foreign_keys = ON");
|
|
244
|
+
expect(() => store.verifyReplay()).toThrow("foreign_key_check");
|
|
245
|
+
store.close();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("rejects an exact replay prefix disconnected from the current authority head", () => {
|
|
249
|
+
const store = new OhSqliteStore({ path: ":memory:", spaceId: "replay-tail-integrity" });
|
|
250
|
+
const operations = [1, 2, 3].map((index) => store.commit({ actorId: "agent.test",
|
|
251
|
+
changes: [{ kind: "put", record: record(`entity:${index}`, `Entity ${index}`), v: 1 }],
|
|
252
|
+
expectedHead: store.head(), instant: `2026-09-06T12:0${index}:00.000Z`,
|
|
253
|
+
operationId: `op_tail_${index}` }));
|
|
254
|
+
const third = operations[2]!;
|
|
255
|
+
const { operationSha256: _operationSha256, ...thirdPayload } = third;
|
|
256
|
+
const disconnected = createOhOperationV1({
|
|
257
|
+
...thirdPayload,
|
|
258
|
+
parentOperationSha256: canonicalSha256("disconnected parent"),
|
|
259
|
+
});
|
|
260
|
+
store.database.query(`UPDATE oh_operations SET operation_json = ?
|
|
261
|
+
WHERE space_id = ? AND sequence = 3`).run(canonicalJson(disconnected), store.spaceId);
|
|
262
|
+
expect(() => store.importOperations({
|
|
263
|
+
expectedHead: { operationSha256: null, sequence: 0 },
|
|
264
|
+
operations: operations.slice(0, 2),
|
|
265
|
+
})).toThrow(OhIntegrityError);
|
|
266
|
+
store.database.query(`UPDATE oh_operations SET operation_json = ?
|
|
267
|
+
WHERE space_id = ? AND sequence = 3`).run(canonicalJson(third), store.spaceId);
|
|
268
|
+
store.database.exec("PRAGMA foreign_keys = OFF");
|
|
269
|
+
store.database.query(`UPDATE oh_operation_records SET operation_sha256 = ?
|
|
270
|
+
WHERE operation_sha256 = ?`).run(disconnected.operationSha256, third.operationSha256);
|
|
271
|
+
store.database.query(`UPDATE oh_records SET operation_sha256 = ?
|
|
272
|
+
WHERE operation_sha256 = ?`).run(disconnected.operationSha256, third.operationSha256);
|
|
273
|
+
store.database.query(`UPDATE oh_sync_outbox SET operation_sha256 = ?
|
|
274
|
+
WHERE operation_sha256 = ?`).run(disconnected.operationSha256, third.operationSha256);
|
|
275
|
+
store.database.query(`UPDATE oh_operations SET operation_sha256 = ?, parent_operation_sha256 = ?,
|
|
276
|
+
operation_json = ? WHERE space_id = ? AND sequence = 3`).run(disconnected.operationSha256,
|
|
277
|
+
disconnected.parentOperationSha256, canonicalJson(disconnected), store.spaceId);
|
|
278
|
+
store.database.query("UPDATE oh_spaces SET head_operation_sha256 = ? WHERE space_id = ?")
|
|
279
|
+
.run(disconnected.operationSha256, store.spaceId);
|
|
280
|
+
store.database.exec("PRAGMA foreign_keys = ON");
|
|
281
|
+
expect(store.database.query<Record<string, unknown>, []>("PRAGMA foreign_key_check").all()).toEqual([]);
|
|
282
|
+
expect(() => store.importOperations({
|
|
283
|
+
expectedHead: { operationSha256: null, sequence: 0 },
|
|
284
|
+
operations: operations.slice(0, 2),
|
|
285
|
+
})).toThrow(OhIntegrityError);
|
|
286
|
+
store.close();
|
|
287
|
+
});
|
|
288
|
+
|
|
184
289
|
test("verifies duplicated operation columns against canonical envelopes", () => {
|
|
185
290
|
const store = new OhSqliteStore({ path: ":memory:" });
|
|
186
291
|
store.commit({ actorId: "agent.test", changes: [{ kind: "put",
|