@hraness/oh 0.2.3
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/LICENSE +21 -0
- package/README.md +598 -0
- package/dist/canonical.d.ts +32 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +3419 -0
- package/dist/contract.d.ts +33 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/graph.d.ts +67 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1988 -0
- package/dist/libsql.d.ts +57 -0
- package/dist/libsql.d.ts.map +1 -0
- package/dist/libsql.js +2662 -0
- package/dist/memory.d.ts +366 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +3650 -0
- package/dist/ontology.d.ts +242 -0
- package/dist/ontology.d.ts.map +1 -0
- package/dist/operation.d.ts +24 -0
- package/dist/operation.d.ts.map +1 -0
- package/dist/projection-public.d.ts +59 -0
- package/dist/projection-public.d.ts.map +1 -0
- package/dist/projection-public.js +1682 -0
- package/dist/projection-suss.d.ts +17 -0
- package/dist/projection-suss.d.ts.map +1 -0
- package/dist/projection-suss.js +1721 -0
- package/dist/projection.d.ts +315 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/schema.d.ts +45 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/sdk.d.ts +51 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +3072 -0
- package/dist/search.d.ts +34 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/semantic.d.ts +83 -0
- package/dist/semantic.d.ts.map +1 -0
- package/dist/semantic.js +706 -0
- package/dist/sqlite/driver.d.ts +6 -0
- package/dist/sqlite/driver.d.ts.map +1 -0
- package/dist/sqlite/index.d.ts +5 -0
- package/dist/sqlite/index.d.ts.map +1 -0
- package/dist/sqlite/index.js +2840 -0
- package/dist/sqlite/migrations.d.ts +10 -0
- package/dist/sqlite/migrations.d.ts.map +1 -0
- package/dist/sqlite/port.d.ts +40 -0
- package/dist/sqlite/port.d.ts.map +1 -0
- package/dist/sqlite/runtime.d.ts +23 -0
- package/dist/sqlite/runtime.d.ts.map +1 -0
- package/dist/sqlite/store.d.ts +85 -0
- package/dist/sqlite/store.d.ts.map +1 -0
- package/dist/store.d.ts +236 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +996 -0
- package/dist/sync.d.ts +57 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +1328 -0
- package/package.json +121 -0
- package/skills/oh/SKILL.md +206 -0
- package/skills/oh/agents/openai.yaml +4 -0
- package/spec/README.md +74 -0
- package/spec/manifest.json +41 -0
- package/spec/v1/canonical-json.md +59 -0
- package/spec/v1/contract.json +28 -0
- package/spec/v1/contract.schema.json +58 -0
- package/spec/v1/embedding-profile.json +11 -0
- package/spec/v1/embedding.md +56 -0
- package/spec/v1/graph.md +87 -0
- package/spec/v1/memory.md +193 -0
- package/spec/v1/migration.md +92 -0
- package/spec/v1/ontology.json +55 -0
- package/spec/v1/ontology.md +80 -0
- package/spec/v1/operation.schema.json +138 -0
- package/spec/v1/projection-identity.schema.json +58 -0
- package/spec/v1/projection-query.schema.json +60 -0
- package/spec/v1/projection-result.schema.json +452 -0
- package/spec/v1/projection-rule-pack.schema.json +182 -0
- package/spec/v1/projection.md +165 -0
- package/spec/v1/record.schema.json +95 -0
- package/spec/v1/schema-evolution.md +51 -0
- package/spec/v1/schema-revision.schema.json +178 -0
- package/spec/v1/storage.md +88 -0
- package/spec/v1/store.md +131 -0
- package/spec/v1/sync-bundle.schema.json +51 -0
- package/spec/v1/sync.md +67 -0
- package/src/canonical.test.ts +46 -0
- package/src/canonical.ts +203 -0
- package/src/cli.test.ts +103 -0
- package/src/cli.ts +308 -0
- package/src/contract.ts +87 -0
- package/src/contracts.test.ts +147 -0
- package/src/graph.ts +248 -0
- package/src/index.ts +8 -0
- package/src/libsql.test.ts +657 -0
- package/src/libsql.ts +1687 -0
- package/src/memory.test.ts +783 -0
- package/src/memory.ts +1684 -0
- package/src/ontology.ts +573 -0
- package/src/operation.ts +80 -0
- package/src/projection-public.ts +53 -0
- package/src/projection-suss.ts +129 -0
- package/src/projection.test.ts +418 -0
- package/src/projection.ts +1457 -0
- package/src/schema.ts +156 -0
- package/src/sdk.ts +96 -0
- package/src/search.ts +66 -0
- package/src/semantic.test.ts +480 -0
- package/src/semantic.ts +333 -0
- package/src/sqlite/driver.ts +47 -0
- package/src/sqlite/index.ts +4 -0
- package/src/sqlite/migrations.test.ts +44 -0
- package/src/sqlite/migrations.ts +178 -0
- package/src/sqlite/port.test.ts +127 -0
- package/src/sqlite/port.ts +120 -0
- package/src/sqlite/runtime.test.ts +68 -0
- package/src/sqlite/runtime.ts +53 -0
- package/src/sqlite/store.test.ts +295 -0
- package/src/sqlite/store.ts +988 -0
- package/src/store.test.ts +121 -0
- package/src/store.ts +701 -0
- package/src/sync.test.ts +117 -0
- package/src/sync.ts +227 -0
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
boundedText,
|
|
6
|
+
canonicalJson,
|
|
7
|
+
canonicalNow,
|
|
8
|
+
canonicalSha256,
|
|
9
|
+
parseSha256Hex,
|
|
10
|
+
safeCode,
|
|
11
|
+
type JsonValue,
|
|
12
|
+
type Sha256Hex,
|
|
13
|
+
} from "../canonical";
|
|
14
|
+
import { OH_CONTRACT_MANIFEST_V1 } from "../contract";
|
|
15
|
+
import {
|
|
16
|
+
canonicalKnowledgeGraphChangesV1,
|
|
17
|
+
knowledgeGraphRecordRefV1,
|
|
18
|
+
OH_GRAPH_LIMITS_V1,
|
|
19
|
+
parseKnowledgeGraphRecordV1,
|
|
20
|
+
type KnowledgeGraphChangeV1,
|
|
21
|
+
type KnowledgeGraphRecordKindV1,
|
|
22
|
+
type KnowledgeGraphRecordV1,
|
|
23
|
+
} from "../graph";
|
|
24
|
+
import { OH_CONTRACT_ID_V1 } from "../ontology";
|
|
25
|
+
import {
|
|
26
|
+
createOhOperationV1,
|
|
27
|
+
graphRevisionSha256V1,
|
|
28
|
+
parseOhOperationV1,
|
|
29
|
+
type OhOperationV1,
|
|
30
|
+
} from "../operation";
|
|
31
|
+
import {
|
|
32
|
+
createOhDependencyClosureV1,
|
|
33
|
+
createOhSpacePurgeReceiptV1,
|
|
34
|
+
emptyOhHeadV1,
|
|
35
|
+
OhConflictError,
|
|
36
|
+
OhDependencyError,
|
|
37
|
+
OhIntegrityError,
|
|
38
|
+
OhProfileError,
|
|
39
|
+
OhPurgedSpaceError,
|
|
40
|
+
parseOhHeadRefV1,
|
|
41
|
+
parseOhSpacePurgeReceiptV1,
|
|
42
|
+
parseOhStoreBindingV1,
|
|
43
|
+
replayOhOperationsV1,
|
|
44
|
+
type OhChangesPageV1,
|
|
45
|
+
type OhCommitInputV1,
|
|
46
|
+
type OhDependencyClosureV1,
|
|
47
|
+
type OhHeadRefV1,
|
|
48
|
+
type OhHeadV1,
|
|
49
|
+
type OhSnapshotV1,
|
|
50
|
+
type OhSpacePurgeReceiptV1,
|
|
51
|
+
type OhStoreBindingV1,
|
|
52
|
+
} from "../store";
|
|
53
|
+
import { openOhSqliteDatabase, withImmediateTransaction, withReadTransaction,
|
|
54
|
+
type OhSqliteDatabase } from "./driver";
|
|
55
|
+
import { applyOhSqliteMigrations, OH_SQLITE_SCHEMA_VERSION } from "./migrations";
|
|
56
|
+
|
|
57
|
+
const EMPTY_RECORDS_SHA256 = canonicalSha256([]);
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
OhConflictError,
|
|
61
|
+
OhDependencyError,
|
|
62
|
+
OhIntegrityError,
|
|
63
|
+
OhProfileError,
|
|
64
|
+
OhPurgedSpaceError,
|
|
65
|
+
};
|
|
66
|
+
export type { OhCommitInputV1, OhHeadV1 };
|
|
67
|
+
|
|
68
|
+
export type OhRecordListOptions = Readonly<{
|
|
69
|
+
kind?: KnowledgeGraphRecordKindV1;
|
|
70
|
+
limit?: number;
|
|
71
|
+
}>;
|
|
72
|
+
|
|
73
|
+
export type OhKeywordSearchResultV1 = Readonly<{
|
|
74
|
+
key: string;
|
|
75
|
+
kind: KnowledgeGraphRecordKindV1;
|
|
76
|
+
recordSha256: Sha256Hex;
|
|
77
|
+
score: number;
|
|
78
|
+
snippet: string;
|
|
79
|
+
v: 1;
|
|
80
|
+
}>;
|
|
81
|
+
|
|
82
|
+
export type OhReplayVerificationV1 = Readonly<{
|
|
83
|
+
head: OhHeadV1;
|
|
84
|
+
operations: number;
|
|
85
|
+
records: number;
|
|
86
|
+
sqliteIntegrity: "ok";
|
|
87
|
+
v: 1;
|
|
88
|
+
}>;
|
|
89
|
+
|
|
90
|
+
type SpaceRow = {
|
|
91
|
+
generation: number;
|
|
92
|
+
graph_revision_sha256: string | null;
|
|
93
|
+
head_operation_sha256: string | null;
|
|
94
|
+
records_sha256: string;
|
|
95
|
+
sequence: number;
|
|
96
|
+
};
|
|
97
|
+
type RecordRow = { record_json: string };
|
|
98
|
+
type CurrentRecordRow = {
|
|
99
|
+
kind: string;
|
|
100
|
+
operation_sha256: string;
|
|
101
|
+
record_json: string;
|
|
102
|
+
record_key: string;
|
|
103
|
+
record_sha256: string;
|
|
104
|
+
sequence: number;
|
|
105
|
+
};
|
|
106
|
+
type OperationRow = {
|
|
107
|
+
graph_revision_sha256: string;
|
|
108
|
+
instant: string;
|
|
109
|
+
operation_id: string;
|
|
110
|
+
operation_json: string;
|
|
111
|
+
operation_sha256: string;
|
|
112
|
+
parent_operation_sha256: string | null;
|
|
113
|
+
records_sha256: string;
|
|
114
|
+
sequence: number;
|
|
115
|
+
space_id: string;
|
|
116
|
+
};
|
|
117
|
+
type BindingRow = {
|
|
118
|
+
binding_json: string;
|
|
119
|
+
binding_sha256: string;
|
|
120
|
+
profile_id: string;
|
|
121
|
+
profile_kind: string;
|
|
122
|
+
profile_sha256: string;
|
|
123
|
+
realm_id: string;
|
|
124
|
+
space_id: string;
|
|
125
|
+
};
|
|
126
|
+
type PurgeRow = {
|
|
127
|
+
binding_sha256: string;
|
|
128
|
+
prior_operation_sha256: string | null;
|
|
129
|
+
prior_sequence: number;
|
|
130
|
+
purged_at: string;
|
|
131
|
+
receipt_json: string;
|
|
132
|
+
receipt_sha256: string;
|
|
133
|
+
space_id: string;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const OPERATION_COLUMNS = `operation_sha256, space_id, sequence, operation_id,
|
|
137
|
+
parent_operation_sha256, graph_revision_sha256, records_sha256, operation_json, instant`;
|
|
138
|
+
const BINDING_COLUMNS = `space_id, realm_id, profile_id, profile_kind, profile_sha256,
|
|
139
|
+
binding_sha256, binding_json`;
|
|
140
|
+
const PURGE_COLUMNS = `space_id, binding_sha256, prior_operation_sha256, prior_sequence,
|
|
141
|
+
purged_at, receipt_sha256, receipt_json`;
|
|
142
|
+
|
|
143
|
+
function parseStoredOperationRow(
|
|
144
|
+
row: OperationRow,
|
|
145
|
+
expected: Readonly<{ operationId?: string; operationSha256?: string; spaceId?: string }> = {},
|
|
146
|
+
): OhOperationV1 {
|
|
147
|
+
let value: unknown;
|
|
148
|
+
try { value = JSON.parse(row.operation_json); } catch { throw new OhIntegrityError("A stored operation is not JSON."); }
|
|
149
|
+
const operation = parseOhOperationV1(value);
|
|
150
|
+
if (operation === null || canonicalJson(operation) !== row.operation_json
|
|
151
|
+
|| row.operation_sha256 !== operation.operationSha256
|
|
152
|
+
|| row.space_id !== operation.spaceId
|
|
153
|
+
|| row.sequence !== operation.sequence
|
|
154
|
+
|| row.operation_id !== operation.operationId
|
|
155
|
+
|| row.parent_operation_sha256 !== operation.parentOperationSha256
|
|
156
|
+
|| row.graph_revision_sha256 !== operation.graphRevisionSha256
|
|
157
|
+
|| row.records_sha256 !== operation.recordsSha256
|
|
158
|
+
|| row.instant !== operation.instant
|
|
159
|
+
|| (expected.spaceId !== undefined && operation.spaceId !== expected.spaceId)
|
|
160
|
+
|| (expected.operationId !== undefined && operation.operationId !== expected.operationId)
|
|
161
|
+
|| (expected.operationSha256 !== undefined && operation.operationSha256 !== expected.operationSha256)) {
|
|
162
|
+
throw new OhIntegrityError("Stored operation columns do not match their canonical envelope.");
|
|
163
|
+
}
|
|
164
|
+
return operation;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseStoredBindingRow(row: BindingRow, expectedSpaceId: string): OhStoreBindingV1 {
|
|
168
|
+
let value: unknown;
|
|
169
|
+
try { value = JSON.parse(row.binding_json); } catch { throw new OhIntegrityError("A store binding is not JSON."); }
|
|
170
|
+
const binding = parseOhStoreBindingV1(value);
|
|
171
|
+
if (binding === null || canonicalJson(binding) !== row.binding_json
|
|
172
|
+
|| binding.spaceId !== expectedSpaceId
|
|
173
|
+
|| row.space_id !== binding.spaceId
|
|
174
|
+
|| row.realm_id !== binding.realmId
|
|
175
|
+
|| row.profile_id !== binding.profile.profileId
|
|
176
|
+
|| row.profile_kind !== binding.profile.profileKind
|
|
177
|
+
|| row.profile_sha256 !== binding.profile.profileSha256
|
|
178
|
+
|| row.binding_sha256 !== binding.bindingSha256) {
|
|
179
|
+
throw new OhIntegrityError("Stored binding columns do not match their canonical envelope.");
|
|
180
|
+
}
|
|
181
|
+
return binding;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseStoredPurgeRow(row: PurgeRow, expectedSpaceId: string): OhSpacePurgeReceiptV1 {
|
|
185
|
+
let value: unknown;
|
|
186
|
+
try { value = JSON.parse(row.receipt_json); } catch { throw new OhIntegrityError("A purge receipt is not JSON."); }
|
|
187
|
+
const receipt = parseOhSpacePurgeReceiptV1(value);
|
|
188
|
+
if (receipt === null || canonicalJson(receipt) !== row.receipt_json
|
|
189
|
+
|| receipt.spaceId !== expectedSpaceId
|
|
190
|
+
|| row.space_id !== receipt.spaceId
|
|
191
|
+
|| row.binding_sha256 !== receipt.bindingSha256
|
|
192
|
+
|| row.prior_operation_sha256 !== receipt.priorHead.operationSha256
|
|
193
|
+
|| row.prior_sequence !== receipt.priorHead.sequence
|
|
194
|
+
|| row.purged_at !== receipt.purgedAt
|
|
195
|
+
|| row.receipt_sha256 !== receipt.receiptSha256) {
|
|
196
|
+
throw new OhIntegrityError("Stored purge columns do not match their canonical receipt.");
|
|
197
|
+
}
|
|
198
|
+
return receipt;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseHead(row: SpaceRow): OhHeadV1 {
|
|
202
|
+
const operationSha256 = row.head_operation_sha256 === null ? null : parseSha256Hex(row.head_operation_sha256);
|
|
203
|
+
const graphRevisionSha256 = row.graph_revision_sha256 === null ? null : parseSha256Hex(row.graph_revision_sha256);
|
|
204
|
+
const recordsSha256 = parseSha256Hex(row.records_sha256);
|
|
205
|
+
if ((row.head_operation_sha256 !== null && operationSha256 === null)
|
|
206
|
+
|| (row.graph_revision_sha256 !== null && graphRevisionSha256 === null) || recordsSha256 === null) {
|
|
207
|
+
throw new OhIntegrityError("The stored space head contains an invalid digest.");
|
|
208
|
+
}
|
|
209
|
+
return { generation: row.generation, graphRevisionSha256, operationSha256,
|
|
210
|
+
recordsSha256, sequence: row.sequence, v: 1 };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function extractSearchText(value: JsonValue, maximumBytes = 1024 * 1024): string {
|
|
214
|
+
const parts: string[] = [];
|
|
215
|
+
let bytes = 0;
|
|
216
|
+
const visit = (candidate: JsonValue, depth: number): void => {
|
|
217
|
+
if (depth > 32 || bytes >= maximumBytes) return;
|
|
218
|
+
if (typeof candidate === "string") {
|
|
219
|
+
const remaining = maximumBytes - bytes;
|
|
220
|
+
const text = Buffer.from(candidate, "utf8").subarray(0, remaining).toString("utf8");
|
|
221
|
+
if (text.length > 0) { parts.push(text); bytes += Buffer.byteLength(text, "utf8") + 1; }
|
|
222
|
+
} else if (typeof candidate === "number" || typeof candidate === "boolean") {
|
|
223
|
+
const text = String(candidate); parts.push(text); bytes += text.length + 1;
|
|
224
|
+
} else if (Array.isArray(candidate)) {
|
|
225
|
+
for (const item of candidate) visit(item, depth + 1);
|
|
226
|
+
} else if (candidate !== null) {
|
|
227
|
+
for (const [key, item] of Object.entries(candidate)) {
|
|
228
|
+
parts.push(key); bytes += key.length + 1;
|
|
229
|
+
visit(item, depth + 1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
visit(value, 0);
|
|
234
|
+
return parts.join(" ").normalize("NFC");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function normalizeLimit(value: number | undefined, fallback = 50, maximum = 1000): number {
|
|
238
|
+
if (value === undefined) return fallback;
|
|
239
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
240
|
+
throw new RangeError(`limit must be an integer from 1 through ${maximum}.`);
|
|
241
|
+
}
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function ftsQuery(value: string): string | null {
|
|
246
|
+
const normalized = boundedText(value.normalize("NFC"), 4096);
|
|
247
|
+
if (normalized === null) return null;
|
|
248
|
+
const tokens = normalized.toLocaleLowerCase("en-US").match(/[\p{L}\p{N}][\p{L}\p{N}_-]{0,63}/gu)?.slice(0, 16) ?? [];
|
|
249
|
+
return tokens.length === 0 ? null : tokens.map((token) => `"${token.replaceAll('"', '""')}"`).join(" OR ");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export class OhSqliteStore {
|
|
253
|
+
readonly database: OhSqliteDatabase;
|
|
254
|
+
readonly spaceId: string;
|
|
255
|
+
#closed = false;
|
|
256
|
+
|
|
257
|
+
constructor(options: Readonly<{ database?: OhSqliteDatabase; path?: string; spaceId?: string }> = {}) {
|
|
258
|
+
const spaceId = safeCode(options.spaceId ?? "default");
|
|
259
|
+
if (spaceId === null) throw new TypeError("Invalid space ID.");
|
|
260
|
+
if (options.database !== undefined && options.path !== undefined) {
|
|
261
|
+
throw new TypeError("Pass either a database or a path, not both.");
|
|
262
|
+
}
|
|
263
|
+
const path = options.path ?? "oh.sqlite";
|
|
264
|
+
if (options.database === undefined && path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
265
|
+
this.database = options.database ?? openOhSqliteDatabase(path);
|
|
266
|
+
this.spaceId = spaceId;
|
|
267
|
+
applyOhSqliteMigrations(this.database);
|
|
268
|
+
this.#registerContract();
|
|
269
|
+
this.ensureSpace();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#assertOpen(): void {
|
|
273
|
+
if (this.#closed) throw new Error("The Oh store is closed.");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#assertOperationReplication(): void {
|
|
277
|
+
const binding = this.binding();
|
|
278
|
+
if (binding !== null && !binding.profile.capabilities.operationReplication) {
|
|
279
|
+
throw new OhProfileError("This bound store profile forbids operation replication.");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#registerContract(): void {
|
|
284
|
+
const manifestJson = canonicalJson(OH_CONTRACT_MANIFEST_V1);
|
|
285
|
+
this.database.query(`INSERT INTO oh_contracts(contract_id, contract_sha256, manifest_json, created_at)
|
|
286
|
+
VALUES (?, ?, ?, ?) ON CONFLICT(contract_id) DO NOTHING`)
|
|
287
|
+
.run(OH_CONTRACT_ID_V1, OH_CONTRACT_MANIFEST_V1.contractSha256, manifestJson, canonicalNow());
|
|
288
|
+
const existing = this.database.query<{ contract_sha256: string; manifest_json: string }, [string]>(
|
|
289
|
+
"SELECT contract_sha256, manifest_json FROM oh_contracts WHERE contract_id = ?",
|
|
290
|
+
).get(OH_CONTRACT_ID_V1);
|
|
291
|
+
if (existing === null || existing.contract_sha256 !== OH_CONTRACT_MANIFEST_V1.contractSha256
|
|
292
|
+
|| existing.manifest_json !== manifestJson) {
|
|
293
|
+
throw new OhIntegrityError("The stored contract manifest differs from this runtime.");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
ensureSpace(): OhHeadV1 {
|
|
298
|
+
this.#assertOpen();
|
|
299
|
+
return withImmediateTransaction(this.database, () => {
|
|
300
|
+
const purged = this.database.query<PurgeRow, [string]>(
|
|
301
|
+
`SELECT ${PURGE_COLUMNS} FROM oh_space_purges WHERE space_id = ?`,
|
|
302
|
+
).get(this.spaceId);
|
|
303
|
+
if (purged !== null) {
|
|
304
|
+
throw new OhPurgedSpaceError(parseStoredPurgeRow(purged, this.spaceId));
|
|
305
|
+
}
|
|
306
|
+
const now = canonicalNow();
|
|
307
|
+
this.database.query(`INSERT INTO oh_spaces(
|
|
308
|
+
space_id, contract_id, generation, head_operation_sha256, graph_revision_sha256,
|
|
309
|
+
records_sha256, sequence, created_at, updated_at
|
|
310
|
+
) VALUES (?, ?, 0, NULL, NULL, ?, 0, ?, ?) ON CONFLICT(space_id) DO NOTHING`)
|
|
311
|
+
.run(this.spaceId, OH_CONTRACT_ID_V1, EMPTY_RECORDS_SHA256, now, now);
|
|
312
|
+
return this.head();
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
bind(bindingValue: OhStoreBindingV1): OhStoreBindingV1 {
|
|
317
|
+
this.#assertOpen();
|
|
318
|
+
const binding = parseOhStoreBindingV1(bindingValue);
|
|
319
|
+
if (binding === null || binding.spaceId !== this.spaceId) {
|
|
320
|
+
throw new OhProfileError("The store binding does not identify this space.");
|
|
321
|
+
}
|
|
322
|
+
const bindingJson = canonicalJson(binding);
|
|
323
|
+
this.database.query(`INSERT INTO oh_space_bindings(
|
|
324
|
+
space_id, realm_id, profile_id, profile_kind, profile_sha256,
|
|
325
|
+
binding_sha256, binding_json, created_at
|
|
326
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(space_id) DO NOTHING`).run(
|
|
327
|
+
this.spaceId, binding.realmId, binding.profile.profileId, binding.profile.profileKind,
|
|
328
|
+
binding.profile.profileSha256, binding.bindingSha256, bindingJson, canonicalNow(),
|
|
329
|
+
);
|
|
330
|
+
const row = this.database.query<BindingRow, [string]>(
|
|
331
|
+
`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`,
|
|
332
|
+
).get(this.spaceId);
|
|
333
|
+
if (row === null) throw new OhIntegrityError("The persisted store binding disappeared.");
|
|
334
|
+
const persisted = parseStoredBindingRow(row, this.spaceId);
|
|
335
|
+
if (canonicalJson(persisted) !== bindingJson) {
|
|
336
|
+
throw new OhProfileError("The space is already bound to a different realm or profile.");
|
|
337
|
+
}
|
|
338
|
+
return binding;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
binding(): OhStoreBindingV1 | null {
|
|
342
|
+
this.#assertOpen();
|
|
343
|
+
const row = this.database.query<BindingRow, [string]>(
|
|
344
|
+
`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`,
|
|
345
|
+
).get(this.spaceId);
|
|
346
|
+
if (row === null) return null;
|
|
347
|
+
return parseStoredBindingRow(row, this.spaceId);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
head(): OhHeadV1 {
|
|
351
|
+
this.#assertOpen();
|
|
352
|
+
const row = this.database.query<SpaceRow, [string]>(`SELECT generation, graph_revision_sha256,
|
|
353
|
+
head_operation_sha256, records_sha256, sequence FROM oh_spaces WHERE space_id = ?`).get(this.spaceId);
|
|
354
|
+
if (row === null) throw new OhIntegrityError("The requested space does not exist.");
|
|
355
|
+
return parseHead(row);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#loadRecords(): Map<string, KnowledgeGraphRecordV1> {
|
|
359
|
+
const rows = this.database.query<RecordRow, [string]>(
|
|
360
|
+
"SELECT record_json FROM oh_records WHERE space_id = ? ORDER BY record_key",
|
|
361
|
+
).all(this.spaceId);
|
|
362
|
+
const records = new Map<string, KnowledgeGraphRecordV1>();
|
|
363
|
+
for (const row of rows) {
|
|
364
|
+
let value: unknown;
|
|
365
|
+
try { value = JSON.parse(row.record_json); } catch { throw new OhIntegrityError("A stored record is not JSON."); }
|
|
366
|
+
if (canonicalJson(value) !== row.record_json) throw new OhIntegrityError("A stored record is not canonical JSON.");
|
|
367
|
+
const record = parseKnowledgeGraphRecordV1(value);
|
|
368
|
+
if (record === null || records.has(record.key)) throw new OhIntegrityError("A stored graph record is invalid.");
|
|
369
|
+
records.set(record.key, record);
|
|
370
|
+
}
|
|
371
|
+
return records;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#transition(
|
|
375
|
+
head: OhHeadV1,
|
|
376
|
+
changes: readonly KnowledgeGraphChangeV1[],
|
|
377
|
+
operationId: string,
|
|
378
|
+
): Readonly<{ graphRevisionSha256: Sha256Hex; records: Map<string, KnowledgeGraphRecordV1>; recordsSha256: Sha256Hex }> {
|
|
379
|
+
const records = this.#loadRecords();
|
|
380
|
+
for (const change of changes) {
|
|
381
|
+
if (change.kind === "put") {
|
|
382
|
+
records.set(change.record.key, change.record);
|
|
383
|
+
} else {
|
|
384
|
+
const prior = records.get(change.key);
|
|
385
|
+
if (prior === undefined || prior.recordSha256 !== change.priorSha256) {
|
|
386
|
+
throw new OhConflictError(`The prior digest for ${change.key} does not match the current record.`);
|
|
387
|
+
}
|
|
388
|
+
records.delete(change.key);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (records.size > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) {
|
|
392
|
+
throw new RangeError("Graph transition exceeds its record snapshot limit.");
|
|
393
|
+
}
|
|
394
|
+
for (const record of records.values()) {
|
|
395
|
+
for (const dependency of record.dependencies) {
|
|
396
|
+
if (!records.has(dependency)) throw new OhDependencyError(`Missing dependency ${dependency} for ${record.key}.`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const refs = [...records.values()].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0)
|
|
400
|
+
.map(knowledgeGraphRecordRefV1);
|
|
401
|
+
const recordsSha256 = canonicalSha256(refs);
|
|
402
|
+
const graphRevisionSha256 = graphRevisionSha256V1({ changes, operationId,
|
|
403
|
+
parentGraphRevisionSha256: head.graphRevisionSha256, recordsSha256, revision: head.sequence + 1 });
|
|
404
|
+
return { graphRevisionSha256, records, recordsSha256 };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#assertCurrentHeadAuthority(head: OhHeadV1): void {
|
|
408
|
+
const summary = this.database.query<{ count: number; maximum: number | null; minimum: number | null }, [string]>(
|
|
409
|
+
`SELECT count(*) AS count, min(sequence) AS minimum, max(sequence) AS maximum
|
|
410
|
+
FROM oh_operations WHERE space_id = ?`,
|
|
411
|
+
).get(this.spaceId);
|
|
412
|
+
if (summary === null || summary.count !== head.sequence
|
|
413
|
+
|| (head.sequence === 0 && (summary.minimum !== null || summary.maximum !== null))
|
|
414
|
+
|| (head.sequence > 0 && (summary.minimum !== 1 || summary.maximum !== head.sequence))) {
|
|
415
|
+
throw new OhIntegrityError("The operation history does not exactly cover the current space head.");
|
|
416
|
+
}
|
|
417
|
+
if (head.sequence === 0) return;
|
|
418
|
+
if (head.operationSha256 === null) throw new OhIntegrityError("A nonempty space head has no operation digest.");
|
|
419
|
+
const row = this.database.query<OperationRow, [string, number]>(
|
|
420
|
+
`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence = ?`,
|
|
421
|
+
).get(this.spaceId, head.sequence);
|
|
422
|
+
if (row === null) throw new OhIntegrityError("The current space head operation is missing.");
|
|
423
|
+
const operation = parseStoredOperationRow(row, { spaceId: this.spaceId,
|
|
424
|
+
operationSha256: head.operationSha256 });
|
|
425
|
+
if (operation.sequence !== head.sequence
|
|
426
|
+
|| operation.graphRevisionSha256 !== head.graphRevisionSha256
|
|
427
|
+
|| operation.recordsSha256 !== head.recordsSha256) {
|
|
428
|
+
throw new OhIntegrityError("The current space head differs from its canonical operation.");
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#assertOperationReachable(operation: OhOperationV1, head: OhHeadV1): void {
|
|
433
|
+
if (operation.sequence < 1 || operation.sequence > head.sequence) {
|
|
434
|
+
throw new OhIntegrityError("A stored idempotent operation is not reachable from the current head.");
|
|
435
|
+
}
|
|
436
|
+
const rows = this.database.query<{
|
|
437
|
+
operation_sha256: string; parent_operation_sha256: string | null; sequence: number;
|
|
438
|
+
}, [string, number, number]>(`SELECT operation_sha256, parent_operation_sha256, sequence
|
|
439
|
+
FROM oh_operations WHERE space_id = ? AND sequence >= ? AND sequence <= ? ORDER BY sequence`)
|
|
440
|
+
.all(this.spaceId, operation.sequence, head.sequence);
|
|
441
|
+
if (rows.length !== head.sequence - operation.sequence + 1) {
|
|
442
|
+
throw new OhIntegrityError("A stored idempotent operation has an incomplete path to the current head.");
|
|
443
|
+
}
|
|
444
|
+
let priorSha256: string | null = operation.parentOperationSha256;
|
|
445
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
446
|
+
const row = rows[index];
|
|
447
|
+
if (row === undefined || row.sequence !== operation.sequence + index
|
|
448
|
+
|| row.parent_operation_sha256 !== priorSha256
|
|
449
|
+
|| (index === 0 && row.operation_sha256 !== operation.operationSha256)) {
|
|
450
|
+
throw new OhIntegrityError("A stored idempotent operation is not on the current authority chain.");
|
|
451
|
+
}
|
|
452
|
+
priorSha256 = row.operation_sha256;
|
|
453
|
+
}
|
|
454
|
+
if (priorSha256 !== head.operationSha256) {
|
|
455
|
+
throw new OhIntegrityError("A stored idempotent operation does not reach the current head digest.");
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#persist(operation: OhOperationV1): void {
|
|
460
|
+
this.database.query(`INSERT INTO oh_operations(operation_sha256, space_id, sequence,
|
|
461
|
+
operation_id, parent_operation_sha256, graph_revision_sha256, records_sha256,
|
|
462
|
+
operation_json, instant) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
463
|
+
operation.operationSha256, this.spaceId, operation.sequence, operation.operationId,
|
|
464
|
+
operation.parentOperationSha256, operation.graphRevisionSha256, operation.recordsSha256,
|
|
465
|
+
canonicalJson(operation), operation.instant,
|
|
466
|
+
);
|
|
467
|
+
const changedKeys = operation.changes.map((change) => change.kind === "put" ? change.record.key : change.key);
|
|
468
|
+
const deleteDependencies = this.database.query("DELETE FROM oh_dependencies WHERE space_id = ? AND record_key = ?");
|
|
469
|
+
for (const key of changedKeys) deleteDependencies.run(this.spaceId, key);
|
|
470
|
+
|
|
471
|
+
const insertOperationRecord = this.database.query(`INSERT INTO oh_operation_records(
|
|
472
|
+
operation_sha256, ordinal, record_key, change_kind, record_sha256) VALUES (?, ?, ?, ?, ?)`);
|
|
473
|
+
const upsertRecord = this.database.query(`INSERT INTO oh_records(space_id, record_key, kind,
|
|
474
|
+
record_sha256, record_json, operation_sha256, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
475
|
+
ON CONFLICT(space_id, record_key) DO UPDATE SET kind = excluded.kind,
|
|
476
|
+
record_sha256 = excluded.record_sha256, record_json = excluded.record_json,
|
|
477
|
+
operation_sha256 = excluded.operation_sha256, sequence = excluded.sequence`);
|
|
478
|
+
const deleteRecord = this.database.query("DELETE FROM oh_records WHERE space_id = ? AND record_key = ?");
|
|
479
|
+
const deleteSearch = this.database.query("DELETE FROM oh_search_documents WHERE space_id = ? AND record_key = ?");
|
|
480
|
+
const deleteFts = this.database.query("DELETE FROM oh_search_fts WHERE space_id = ? AND record_key = ?");
|
|
481
|
+
const upsertSearch = this.database.query(`INSERT INTO oh_search_documents(space_id, record_key,
|
|
482
|
+
record_sha256, text) VALUES (?, ?, ?, ?) ON CONFLICT(space_id, record_key) DO UPDATE SET
|
|
483
|
+
record_sha256 = excluded.record_sha256, text = excluded.text`);
|
|
484
|
+
const insertFts = this.database.query("INSERT INTO oh_search_fts(space_id, record_key, text) VALUES (?, ?, ?)");
|
|
485
|
+
for (const [ordinal, change] of operation.changes.entries()) {
|
|
486
|
+
const key = change.kind === "put" ? change.record.key : change.key;
|
|
487
|
+
const digest = change.kind === "put" ? change.record.recordSha256 : change.priorSha256;
|
|
488
|
+
insertOperationRecord.run(operation.operationSha256, ordinal, key, change.kind, digest);
|
|
489
|
+
deleteFts.run(this.spaceId, key);
|
|
490
|
+
deleteSearch.run(this.spaceId, key);
|
|
491
|
+
if (change.kind === "put") {
|
|
492
|
+
const recordJson = canonicalJson(change.record);
|
|
493
|
+
upsertRecord.run(this.spaceId, key, change.record.kind, change.record.recordSha256,
|
|
494
|
+
recordJson, operation.operationSha256, operation.sequence);
|
|
495
|
+
const text = `${key} ${change.record.kind} ${extractSearchText(change.record.value)}`;
|
|
496
|
+
upsertSearch.run(this.spaceId, key, change.record.recordSha256, text);
|
|
497
|
+
insertFts.run(this.spaceId, key, text);
|
|
498
|
+
} else deleteRecord.run(this.spaceId, key);
|
|
499
|
+
}
|
|
500
|
+
const insertDependency = this.database.query(
|
|
501
|
+
"INSERT INTO oh_dependencies(space_id, record_key, dependency_key) VALUES (?, ?, ?)",
|
|
502
|
+
);
|
|
503
|
+
for (const change of operation.changes) {
|
|
504
|
+
if (change.kind === "put") {
|
|
505
|
+
for (const dependency of change.record.dependencies) insertDependency.run(this.spaceId, change.record.key, dependency);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
this.database.query("INSERT INTO oh_sync_outbox(space_id, sequence, operation_sha256) VALUES (?, ?, ?)")
|
|
509
|
+
.run(this.spaceId, operation.sequence, operation.operationSha256);
|
|
510
|
+
const updated = this.database.query(`UPDATE oh_spaces SET generation = ?, head_operation_sha256 = ?,
|
|
511
|
+
graph_revision_sha256 = ?, records_sha256 = ?, sequence = ?, updated_at = ?
|
|
512
|
+
WHERE space_id = ? AND generation = ? AND head_operation_sha256 IS ?`).run(
|
|
513
|
+
operation.sequence, operation.operationSha256, operation.graphRevisionSha256,
|
|
514
|
+
operation.recordsSha256, operation.sequence, operation.instant, this.spaceId,
|
|
515
|
+
operation.sequence - 1, operation.parentOperationSha256,
|
|
516
|
+
);
|
|
517
|
+
if (updated.changes !== 1) throw new OhConflictError("The space head changed while committing.");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
commit(input: OhCommitInputV1): OhOperationV1 {
|
|
521
|
+
this.#assertOpen();
|
|
522
|
+
const actorId = safeCode(input.actorId);
|
|
523
|
+
const operationId = safeCode(input.operationId);
|
|
524
|
+
if (actorId === null || operationId === null) throw new TypeError("Invalid actor or operation ID.");
|
|
525
|
+
const changes = canonicalKnowledgeGraphChangesV1(input.changes);
|
|
526
|
+
if (changes.length === 0 || changes.length > 8192) throw new TypeError("A commit needs 1 through 8192 changes.");
|
|
527
|
+
return withImmediateTransaction(this.database, () => {
|
|
528
|
+
const head = this.head();
|
|
529
|
+
this.#assertCurrentHeadAuthority(head);
|
|
530
|
+
const duplicate = this.database.query<OperationRow, [string, string]>(
|
|
531
|
+
`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND operation_id = ?`,
|
|
532
|
+
).get(this.spaceId, operationId);
|
|
533
|
+
if (duplicate !== null) {
|
|
534
|
+
const existing = parseStoredOperationRow(duplicate, { operationId, spaceId: this.spaceId });
|
|
535
|
+
this.#assertOperationReachable(existing, head);
|
|
536
|
+
if (existing.actorId !== actorId || canonicalJson(existing.changes) !== canonicalJson(changes)) {
|
|
537
|
+
throw new OhConflictError("The operation ID is already bound to different content.");
|
|
538
|
+
}
|
|
539
|
+
return existing;
|
|
540
|
+
}
|
|
541
|
+
if (head.generation !== input.expectedHead.generation
|
|
542
|
+
|| head.operationSha256 !== input.expectedHead.operationSha256) {
|
|
543
|
+
throw new OhConflictError("The expected head does not match the current space head.");
|
|
544
|
+
}
|
|
545
|
+
const transition = this.#transition(head, changes, operationId);
|
|
546
|
+
const operation = createOhOperationV1({ actorId, changes, contractId: OH_CONTRACT_ID_V1,
|
|
547
|
+
graphRevisionSha256: transition.graphRevisionSha256, instant: input.instant ?? canonicalNow(),
|
|
548
|
+
operationId, parentOperationSha256: head.operationSha256,
|
|
549
|
+
recordsSha256: transition.recordsSha256, sequence: head.sequence + 1, spaceId: this.spaceId, v: 1 });
|
|
550
|
+
this.#persist(operation);
|
|
551
|
+
return operation;
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
importOperation(value: unknown): Readonly<{ imported: boolean; operation: OhOperationV1 }> {
|
|
556
|
+
this.#assertOpen();
|
|
557
|
+
this.#assertOperationReplication();
|
|
558
|
+
const operation = parseOhOperationV1(value);
|
|
559
|
+
if (operation === null || operation.spaceId !== this.spaceId) throw new OhIntegrityError("Invalid imported operation.");
|
|
560
|
+
return withImmediateTransaction(this.database, () => {
|
|
561
|
+
const head = this.head();
|
|
562
|
+
this.#assertCurrentHeadAuthority(head);
|
|
563
|
+
const duplicate = this.database.query<OperationRow, [string]>(
|
|
564
|
+
`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE operation_sha256 = ?`,
|
|
565
|
+
).get(operation.operationSha256);
|
|
566
|
+
if (duplicate !== null) {
|
|
567
|
+
const existing = parseStoredOperationRow(duplicate, { operationSha256: operation.operationSha256,
|
|
568
|
+
spaceId: this.spaceId });
|
|
569
|
+
this.#assertOperationReachable(existing, head);
|
|
570
|
+
if (canonicalJson(existing) !== canonicalJson(operation)) {
|
|
571
|
+
throw new OhIntegrityError("An operation digest is bound to different bytes.");
|
|
572
|
+
}
|
|
573
|
+
return { imported: false, operation };
|
|
574
|
+
}
|
|
575
|
+
if (operation.sequence !== head.sequence + 1 || operation.parentOperationSha256 !== head.operationSha256) {
|
|
576
|
+
throw new OhConflictError("The imported operation does not extend the local head.");
|
|
577
|
+
}
|
|
578
|
+
const transition = this.#transition(head, operation.changes, operation.operationId);
|
|
579
|
+
if (transition.recordsSha256 !== operation.recordsSha256
|
|
580
|
+
|| transition.graphRevisionSha256 !== operation.graphRevisionSha256) {
|
|
581
|
+
throw new OhIntegrityError("The imported operation does not reproduce its declared graph head.");
|
|
582
|
+
}
|
|
583
|
+
this.#persist(operation);
|
|
584
|
+
return { imported: true, operation };
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
exportOperations(afterSequence = 0, limit = 1000): readonly OhOperationV1[] {
|
|
589
|
+
this.#assertOpen();
|
|
590
|
+
this.#assertOperationReplication();
|
|
591
|
+
if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) throw new RangeError("afterSequence must be nonnegative.");
|
|
592
|
+
const boundedLimit = normalizeLimit(limit);
|
|
593
|
+
const rows = this.database.query<OperationRow, [string, number, number]>(
|
|
594
|
+
`SELECT ${OPERATION_COLUMNS} FROM oh_operations
|
|
595
|
+
WHERE space_id = ? AND sequence > ? ORDER BY sequence LIMIT ?`,
|
|
596
|
+
).all(this.spaceId, afterSequence, boundedLimit);
|
|
597
|
+
return rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId }));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
#headAt(reference: OhHeadRefV1): OhHeadV1 {
|
|
601
|
+
const parsed = parseOhHeadRefV1(reference);
|
|
602
|
+
if (parsed === null) throw new TypeError("Invalid Oh head reference.");
|
|
603
|
+
if (parsed.sequence === 0) return emptyOhHeadV1();
|
|
604
|
+
const row = this.database.query<OperationRow, [string, number]>(
|
|
605
|
+
`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? AND sequence = ?`,
|
|
606
|
+
).get(this.spaceId, parsed.sequence);
|
|
607
|
+
if (row === null) throw new OhConflictError("The requested head is not present in this space.");
|
|
608
|
+
const operation = parseStoredOperationRow(row, { spaceId: this.spaceId });
|
|
609
|
+
if (operation.spaceId !== this.spaceId || operation.sequence !== parsed.sequence
|
|
610
|
+
|| operation.operationSha256 !== parsed.operationSha256) {
|
|
611
|
+
throw new OhConflictError("The requested sequence identifies a different operation head.");
|
|
612
|
+
}
|
|
613
|
+
return { generation: operation.sequence, graphRevisionSha256: operation.graphRevisionSha256,
|
|
614
|
+
operationSha256: operation.operationSha256, recordsSha256: operation.recordsSha256,
|
|
615
|
+
sequence: operation.sequence, v: 1 };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
snapshotAtHead(options: Readonly<{
|
|
619
|
+
head?: OhHeadRefV1;
|
|
620
|
+
maximumRecords?: number;
|
|
621
|
+
}> = {}): OhSnapshotV1 {
|
|
622
|
+
this.#assertOpen();
|
|
623
|
+
const maximumRecords = options.maximumRecords ?? OH_GRAPH_LIMITS_V1.recordsPerSnapshot;
|
|
624
|
+
if (!Number.isSafeInteger(maximumRecords) || maximumRecords < 1
|
|
625
|
+
|| maximumRecords > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) {
|
|
626
|
+
throw new RangeError(`maximumRecords must be an integer from 1 through ${OH_GRAPH_LIMITS_V1.recordsPerSnapshot}.`);
|
|
627
|
+
}
|
|
628
|
+
return withReadTransaction(this.database, () => {
|
|
629
|
+
const current = this.head();
|
|
630
|
+
const target = options.head === undefined ? current : this.#headAt(options.head);
|
|
631
|
+
if (target.sequence > current.sequence) throw new OhConflictError("The requested head is ahead of this space.");
|
|
632
|
+
const rows = this.database.query<OperationRow, [string, number]>(
|
|
633
|
+
`SELECT ${OPERATION_COLUMNS} FROM oh_operations
|
|
634
|
+
WHERE space_id = ? AND sequence <= ? ORDER BY sequence`,
|
|
635
|
+
).all(this.spaceId, target.sequence);
|
|
636
|
+
const operations = rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId }));
|
|
637
|
+
const snapshot = replayOhOperationsV1(this.spaceId, operations, maximumRecords);
|
|
638
|
+
if (snapshot.head.operationSha256 !== target.operationSha256
|
|
639
|
+
|| snapshot.head.recordsSha256 !== target.recordsSha256) {
|
|
640
|
+
throw new OhIntegrityError("Operation replay does not reproduce the requested head.");
|
|
641
|
+
}
|
|
642
|
+
return snapshot;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
changesSince(
|
|
647
|
+
fromValue: OhHeadRefV1,
|
|
648
|
+
options: Readonly<{ limit?: number; through?: OhHeadRefV1 }> = {},
|
|
649
|
+
): OhChangesPageV1 {
|
|
650
|
+
this.#assertOpen();
|
|
651
|
+
const from = parseOhHeadRefV1(fromValue);
|
|
652
|
+
if (from === null) throw new TypeError("Invalid change-feed cursor.");
|
|
653
|
+
const limit = normalizeLimit(options.limit, 100, 1000);
|
|
654
|
+
return withReadTransaction(this.database, () => {
|
|
655
|
+
const current = this.head();
|
|
656
|
+
const fromHead = this.#headAt(from);
|
|
657
|
+
const through = options.through === undefined ? current : this.#headAt(options.through);
|
|
658
|
+
if (fromHead.sequence > through.sequence || through.sequence > current.sequence) {
|
|
659
|
+
throw new OhConflictError("The change-feed bounds do not identify one local history prefix.");
|
|
660
|
+
}
|
|
661
|
+
const rows = this.database.query<OperationRow, [string, number, number, number]>(
|
|
662
|
+
`SELECT ${OPERATION_COLUMNS} FROM oh_operations
|
|
663
|
+
WHERE space_id = ? AND sequence > ? AND sequence <= ?
|
|
664
|
+
ORDER BY sequence LIMIT ?`,
|
|
665
|
+
).all(this.spaceId, fromHead.sequence, through.sequence, limit + 1);
|
|
666
|
+
const parsed = rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId }));
|
|
667
|
+
if (parsed.length > limit + 1) throw new OhIntegrityError("The change feed exceeded its requested page bound.");
|
|
668
|
+
const hasMore = parsed.length > limit;
|
|
669
|
+
const operations = parsed.slice(0, limit);
|
|
670
|
+
let prior: OhHeadRefV1 = fromHead;
|
|
671
|
+
for (const operation of parsed) {
|
|
672
|
+
if (operation.sequence !== prior.sequence + 1
|
|
673
|
+
|| operation.parentOperationSha256 !== prior.operationSha256) {
|
|
674
|
+
throw new OhIntegrityError("The change feed contains a gap or fork.");
|
|
675
|
+
}
|
|
676
|
+
prior = { operationSha256: operation.operationSha256, sequence: operation.sequence };
|
|
677
|
+
}
|
|
678
|
+
if (!hasMore && (prior.sequence !== through.sequence
|
|
679
|
+
|| prior.operationSha256 !== through.operationSha256)) {
|
|
680
|
+
throw new OhIntegrityError("The change feed does not reach its pinned through head.");
|
|
681
|
+
}
|
|
682
|
+
const last = operations.at(-1);
|
|
683
|
+
const to = last === undefined
|
|
684
|
+
? { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence }
|
|
685
|
+
: { operationSha256: last.operationSha256, sequence: last.sequence };
|
|
686
|
+
return { from: { operationSha256: fromHead.operationSha256, sequence: fromHead.sequence },
|
|
687
|
+
hasMore, operations, through, to, v: 1 };
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
exportDependencyClosure(input: Readonly<{
|
|
692
|
+
binding: OhStoreBindingV1;
|
|
693
|
+
head?: OhHeadRefV1;
|
|
694
|
+
maximumRecords?: number;
|
|
695
|
+
roots: readonly string[];
|
|
696
|
+
}>): OhDependencyClosureV1 {
|
|
697
|
+
const binding = parseOhStoreBindingV1(input.binding);
|
|
698
|
+
if (binding === null || binding.spaceId !== this.spaceId
|
|
699
|
+
|| canonicalJson(this.binding()) !== canonicalJson(binding)) {
|
|
700
|
+
throw new OhProfileError("Dependency closure export requires the exact persisted store binding.");
|
|
701
|
+
}
|
|
702
|
+
if (!binding.profile.capabilities.dependencyClosureExport) {
|
|
703
|
+
throw new OhProfileError("This store profile does not permit dependency-closure export.");
|
|
704
|
+
}
|
|
705
|
+
const snapshot = this.snapshotAtHead({ ...(input.head === undefined ? {} : { head: input.head }),
|
|
706
|
+
...(input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords }) });
|
|
707
|
+
return createOhDependencyClosureV1({ binding,
|
|
708
|
+
...(input.maximumRecords === undefined ? {} : { maximumRecords: input.maximumRecords }),
|
|
709
|
+
roots: input.roots, snapshot });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
get(key: string): KnowledgeGraphRecordV1 | null {
|
|
713
|
+
this.#assertOpen();
|
|
714
|
+
const parsedKey = safeCode(key, 512);
|
|
715
|
+
if (parsedKey === null) throw new TypeError("Invalid record key.");
|
|
716
|
+
const row = this.database.query<RecordRow, [string, string]>(
|
|
717
|
+
"SELECT record_json FROM oh_records WHERE space_id = ? AND record_key = ?",
|
|
718
|
+
).get(this.spaceId, parsedKey);
|
|
719
|
+
if (row === null) return null;
|
|
720
|
+
const record = parseKnowledgeGraphRecordV1(JSON.parse(row.record_json));
|
|
721
|
+
if (record === null || canonicalJson(record) !== row.record_json) throw new OhIntegrityError("The stored record is invalid.");
|
|
722
|
+
return record;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
list(options: OhRecordListOptions = {}): readonly KnowledgeGraphRecordV1[] {
|
|
726
|
+
this.#assertOpen();
|
|
727
|
+
const limit = normalizeLimit(options.limit);
|
|
728
|
+
const rows = options.kind === undefined
|
|
729
|
+
? this.database.query<RecordRow, [string, number]>(
|
|
730
|
+
"SELECT record_json FROM oh_records WHERE space_id = ? ORDER BY record_key LIMIT ?",
|
|
731
|
+
).all(this.spaceId, limit)
|
|
732
|
+
: this.database.query<RecordRow, [string, string, number]>(
|
|
733
|
+
"SELECT record_json FROM oh_records WHERE space_id = ? AND kind = ? ORDER BY record_key LIMIT ?",
|
|
734
|
+
).all(this.spaceId, options.kind, limit);
|
|
735
|
+
return rows.map((row) => {
|
|
736
|
+
const record = parseKnowledgeGraphRecordV1(JSON.parse(row.record_json));
|
|
737
|
+
if (record === null || canonicalJson(record) !== row.record_json) {
|
|
738
|
+
throw new OhIntegrityError("The stored record is invalid.");
|
|
739
|
+
}
|
|
740
|
+
return record;
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
snapshotRecords(maximum: number = OH_GRAPH_LIMITS_V1.recordsPerSnapshot): readonly KnowledgeGraphRecordV1[] {
|
|
745
|
+
this.#assertOpen();
|
|
746
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1 || maximum > OH_GRAPH_LIMITS_V1.recordsPerSnapshot) {
|
|
747
|
+
throw new RangeError(`maximum must be an integer from 1 through ${OH_GRAPH_LIMITS_V1.recordsPerSnapshot}.`);
|
|
748
|
+
}
|
|
749
|
+
const count = this.database.query<{ count: number }, [string]>(
|
|
750
|
+
"SELECT count(*) AS count FROM oh_records WHERE space_id = ?",
|
|
751
|
+
).get(this.spaceId)?.count ?? 0;
|
|
752
|
+
if (count > maximum) throw new RangeError(`The graph contains ${count} records, above the requested snapshot bound.`);
|
|
753
|
+
return this.database.query<RecordRow, [string]>(
|
|
754
|
+
"SELECT record_json FROM oh_records WHERE space_id = ? ORDER BY record_key",
|
|
755
|
+
).all(this.spaceId).map((row) => {
|
|
756
|
+
const record = parseKnowledgeGraphRecordV1(JSON.parse(row.record_json));
|
|
757
|
+
if (record === null || canonicalJson(record) !== row.record_json) {
|
|
758
|
+
throw new OhIntegrityError("The stored record is invalid.");
|
|
759
|
+
}
|
|
760
|
+
return record;
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
log(limit = 50): readonly OhOperationV1[] {
|
|
765
|
+
this.#assertOpen();
|
|
766
|
+
const rows = this.database.query<OperationRow, [string, number]>(
|
|
767
|
+
`SELECT ${OPERATION_COLUMNS} FROM oh_operations
|
|
768
|
+
WHERE space_id = ? ORDER BY sequence DESC LIMIT ?`,
|
|
769
|
+
).all(this.spaceId, normalizeLimit(limit));
|
|
770
|
+
return rows.map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId }));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
searchKeyword(query: string, limit = 20): readonly OhKeywordSearchResultV1[] {
|
|
774
|
+
this.#assertOpen();
|
|
775
|
+
const match = ftsQuery(query);
|
|
776
|
+
if (match === null) return [];
|
|
777
|
+
type SearchRow = { kind: string; rank: number; record_key: string; record_sha256: string; snippet: string };
|
|
778
|
+
const rows = this.database.query<SearchRow, [string, string, number]>(`SELECT r.record_key,
|
|
779
|
+
r.kind, r.record_sha256, bm25(oh_search_fts) AS rank,
|
|
780
|
+
snippet(oh_search_fts, 2, '', '', ' … ', 24) AS snippet
|
|
781
|
+
FROM oh_search_fts JOIN oh_records r
|
|
782
|
+
ON r.space_id = oh_search_fts.space_id AND r.record_key = oh_search_fts.record_key
|
|
783
|
+
WHERE oh_search_fts MATCH ? AND oh_search_fts.space_id = ?
|
|
784
|
+
ORDER BY rank, r.record_key LIMIT ?`).all(match, this.spaceId, normalizeLimit(limit, 20, 100));
|
|
785
|
+
return rows.map((row) => {
|
|
786
|
+
const digest = parseSha256Hex(row.record_sha256);
|
|
787
|
+
if (digest === null) throw new OhIntegrityError("Search returned an invalid record digest.");
|
|
788
|
+
return { key: row.record_key, kind: row.kind as KnowledgeGraphRecordKindV1,
|
|
789
|
+
recordSha256: digest, score: 1 / (1 + Math.max(0, row.rank)), snippet: row.snippet, v: 1 };
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
syncState(remoteId: string): Readonly<{ pulledSequence: number; pushedSequence: number; remoteHeadSha256: Sha256Hex | null }> {
|
|
794
|
+
this.#assertOpen();
|
|
795
|
+
const id = safeCode(remoteId);
|
|
796
|
+
if (id === null) throw new TypeError("Invalid remote ID.");
|
|
797
|
+
const row = this.database.query<{ pulled_sequence: number; pushed_sequence: number; remote_head_sha256: string | null }, [string, string]>(
|
|
798
|
+
"SELECT pulled_sequence, pushed_sequence, remote_head_sha256 FROM oh_sync_state WHERE remote_id = ? AND space_id = ?",
|
|
799
|
+
).get(id, this.spaceId);
|
|
800
|
+
const digest = row?.remote_head_sha256 === null || row === null ? null : parseSha256Hex(row.remote_head_sha256);
|
|
801
|
+
if (row !== null && row.remote_head_sha256 !== null && digest === null) throw new OhIntegrityError("Invalid remote head digest.");
|
|
802
|
+
return { pulledSequence: row?.pulled_sequence ?? 0, pushedSequence: row?.pushed_sequence ?? 0, remoteHeadSha256: digest };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
updateSyncState(remoteId: string, state: Readonly<{ pulledSequence: number; pushedSequence: number; remoteHeadSha256: Sha256Hex | null }>): void {
|
|
806
|
+
this.#assertOpen();
|
|
807
|
+
const id = safeCode(remoteId);
|
|
808
|
+
if (id === null || !Number.isSafeInteger(state.pulledSequence) || state.pulledSequence < 0
|
|
809
|
+
|| !Number.isSafeInteger(state.pushedSequence) || state.pushedSequence < 0) throw new TypeError("Invalid sync state.");
|
|
810
|
+
this.database.query(`INSERT INTO oh_sync_state(remote_id, space_id, pulled_sequence,
|
|
811
|
+
pushed_sequence, remote_head_sha256, updated_at) VALUES (?, ?, ?, ?, ?, ?)
|
|
812
|
+
ON CONFLICT(remote_id, space_id) DO UPDATE SET pulled_sequence = excluded.pulled_sequence,
|
|
813
|
+
pushed_sequence = excluded.pushed_sequence, remote_head_sha256 = excluded.remote_head_sha256,
|
|
814
|
+
updated_at = excluded.updated_at`).run(id, this.spaceId, state.pulledSequence,
|
|
815
|
+
state.pushedSequence, state.remoteHeadSha256, canonicalNow());
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
verifyReplay(): OhReplayVerificationV1 {
|
|
819
|
+
this.#assertOpen();
|
|
820
|
+
const integrity = this.database.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get();
|
|
821
|
+
if (integrity?.integrity_check !== "ok") throw new OhIntegrityError("SQLite integrity_check failed.");
|
|
822
|
+
const storedCount = this.database.query<{ count: number }, [string]>(
|
|
823
|
+
"SELECT count(*) AS count FROM oh_operations WHERE space_id = ?",
|
|
824
|
+
).get(this.spaceId)?.count ?? 0;
|
|
825
|
+
const operations: readonly OhOperationV1[] = this.database.query<OperationRow, [string]>(
|
|
826
|
+
`SELECT ${OPERATION_COLUMNS} FROM oh_operations WHERE space_id = ? ORDER BY sequence`,
|
|
827
|
+
).all(this.spaceId).map((row) => parseStoredOperationRow(row, { spaceId: this.spaceId }));
|
|
828
|
+
if (operations.length !== storedCount) throw new OhIntegrityError("Operation count changed during verification.");
|
|
829
|
+
return this.#verifyOperations(operations);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
#verifyOperations(operations: readonly OhOperationV1[]): OhReplayVerificationV1 {
|
|
833
|
+
const records = new Map<string, KnowledgeGraphRecordV1>();
|
|
834
|
+
const materializedBy = new Map<string, Readonly<{ operationSha256: Sha256Hex; sequence: number }>>();
|
|
835
|
+
const operationIds = new Set<string>();
|
|
836
|
+
let head: OhHeadV1 = { generation: 0, graphRevisionSha256: null, operationSha256: null,
|
|
837
|
+
recordsSha256: EMPTY_RECORDS_SHA256, sequence: 0, v: 1 };
|
|
838
|
+
for (const operation of operations) {
|
|
839
|
+
if (operation.spaceId !== this.spaceId || operation.sequence !== head.sequence + 1
|
|
840
|
+
|| operation.parentOperationSha256 !== head.operationSha256
|
|
841
|
+
|| operationIds.has(operation.operationId)) throw new OhIntegrityError("Operation replay chain is broken.");
|
|
842
|
+
operationIds.add(operation.operationId);
|
|
843
|
+
for (const change of operation.changes) {
|
|
844
|
+
if (change.kind === "put") {
|
|
845
|
+
records.set(change.record.key, change.record);
|
|
846
|
+
materializedBy.set(change.record.key, { operationSha256: operation.operationSha256,
|
|
847
|
+
sequence: operation.sequence });
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
const prior = records.get(change.key);
|
|
851
|
+
if (prior?.recordSha256 !== change.priorSha256) throw new OhIntegrityError("Replay tombstone does not match its prior record.");
|
|
852
|
+
records.delete(change.key);
|
|
853
|
+
materializedBy.delete(change.key);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
for (const record of records.values()) {
|
|
857
|
+
if (record.dependencies.some((dependency) => !records.has(dependency))) throw new OhIntegrityError("Replay has a missing dependency.");
|
|
858
|
+
}
|
|
859
|
+
const refs = [...records.values()].sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0)
|
|
860
|
+
.map(knowledgeGraphRecordRefV1);
|
|
861
|
+
const recordsSha256 = canonicalSha256(refs);
|
|
862
|
+
const graphRevisionSha256 = graphRevisionSha256V1({ changes: operation.changes,
|
|
863
|
+
operationId: operation.operationId, parentGraphRevisionSha256: head.graphRevisionSha256,
|
|
864
|
+
recordsSha256, revision: operation.sequence });
|
|
865
|
+
if (recordsSha256 !== operation.recordsSha256 || graphRevisionSha256 !== operation.graphRevisionSha256) {
|
|
866
|
+
throw new OhIntegrityError("Replay does not reproduce an operation head.");
|
|
867
|
+
}
|
|
868
|
+
head = { generation: operation.sequence, graphRevisionSha256, operationSha256: operation.operationSha256,
|
|
869
|
+
recordsSha256, sequence: operation.sequence, v: 1 };
|
|
870
|
+
}
|
|
871
|
+
const storedRows = this.database.query<CurrentRecordRow, [string]>(
|
|
872
|
+
`SELECT record_key, kind, record_sha256, record_json, operation_sha256, sequence
|
|
873
|
+
FROM oh_records WHERE space_id = ? ORDER BY record_key`,
|
|
874
|
+
).all(this.spaceId);
|
|
875
|
+
if (storedRows.length !== records.size || storedRows.some((row) => {
|
|
876
|
+
const record = records.get(row.record_key);
|
|
877
|
+
const provenance = materializedBy.get(row.record_key);
|
|
878
|
+
return record === undefined || record.kind !== row.kind || record.recordSha256 !== row.record_sha256
|
|
879
|
+
|| canonicalJson(record) !== row.record_json || provenance === undefined
|
|
880
|
+
|| provenance.operationSha256 !== row.operation_sha256 || provenance.sequence !== row.sequence;
|
|
881
|
+
})) throw new OhIntegrityError("Materialized records do not match operation replay.");
|
|
882
|
+
const storedDependencies = this.database.query<{
|
|
883
|
+
dependency_key: string; record_key: string;
|
|
884
|
+
}, [string]>(`SELECT record_key, dependency_key FROM oh_dependencies
|
|
885
|
+
WHERE space_id = ? ORDER BY record_key, dependency_key`).all(this.spaceId);
|
|
886
|
+
const expectedDependencies = [...records.values()]
|
|
887
|
+
.sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0)
|
|
888
|
+
.flatMap((record) => record.dependencies.map((dependency) => ({ dependency_key: dependency, record_key: record.key })));
|
|
889
|
+
if (canonicalJson(storedDependencies) !== canonicalJson(expectedDependencies)) {
|
|
890
|
+
throw new OhIntegrityError("Materialized dependencies do not match operation replay.");
|
|
891
|
+
}
|
|
892
|
+
const storedOperationRecords = this.database.query<{
|
|
893
|
+
change_kind: string; operation_sha256: string; ordinal: number; record_key: string; record_sha256: string | null;
|
|
894
|
+
}, [string]>(`SELECT materialized.operation_sha256, materialized.ordinal, materialized.record_key,
|
|
895
|
+
materialized.change_kind, materialized.record_sha256
|
|
896
|
+
FROM oh_operation_records AS materialized
|
|
897
|
+
JOIN oh_operations AS operation ON operation.operation_sha256 = materialized.operation_sha256
|
|
898
|
+
WHERE operation.space_id = ? ORDER BY operation.sequence, materialized.ordinal`).all(this.spaceId);
|
|
899
|
+
const expectedOperationRecords = operations.flatMap((operation) => operation.changes.map((change, ordinal) => ({
|
|
900
|
+
change_kind: change.kind,
|
|
901
|
+
operation_sha256: operation.operationSha256,
|
|
902
|
+
ordinal,
|
|
903
|
+
record_key: change.kind === "put" ? change.record.key : change.key,
|
|
904
|
+
record_sha256: change.kind === "put" ? change.record.recordSha256 : change.priorSha256,
|
|
905
|
+
})));
|
|
906
|
+
if (canonicalJson(storedOperationRecords) !== canonicalJson(expectedOperationRecords)) {
|
|
907
|
+
throw new OhIntegrityError("Materialized operation records do not match operation replay.");
|
|
908
|
+
}
|
|
909
|
+
if (canonicalJson(head) !== canonicalJson(this.head())) throw new OhIntegrityError("The stored head does not match operation replay.");
|
|
910
|
+
return { head, operations: operations.length, records: records.size, sqliteIntegrity: "ok", v: 1 };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
contract(): Readonly<{ manifest: typeof OH_CONTRACT_MANIFEST_V1; sqliteSchemaVersion: number }> {
|
|
914
|
+
return { manifest: OH_CONTRACT_MANIFEST_V1, sqliteSchemaVersion: OH_SQLITE_SCHEMA_VERSION };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Host control-plane primitive. Do not expose this method through agent tools. */
|
|
918
|
+
purgeWorkingSpace(bindingValue: OhStoreBindingV1, purgedAt: string = canonicalNow()): OhSpacePurgeReceiptV1 {
|
|
919
|
+
this.#assertOpen();
|
|
920
|
+
const binding = parseOhStoreBindingV1(bindingValue);
|
|
921
|
+
if (binding === null || binding.spaceId !== this.spaceId
|
|
922
|
+
|| binding.profile.profileKind !== "working"
|
|
923
|
+
|| !binding.profile.capabilities.wholeSpacePurge) {
|
|
924
|
+
throw new OhProfileError("Whole-space purge requires a bound working profile.");
|
|
925
|
+
}
|
|
926
|
+
return withImmediateTransaction(this.database, () => {
|
|
927
|
+
const row = this.database.query<BindingRow, [string]>(
|
|
928
|
+
`SELECT ${BINDING_COLUMNS} FROM oh_space_bindings WHERE space_id = ?`,
|
|
929
|
+
).get(this.spaceId);
|
|
930
|
+
if (row === null) {
|
|
931
|
+
throw new OhProfileError("Whole-space purge requires the exact persisted store binding.");
|
|
932
|
+
}
|
|
933
|
+
const persistedBinding = parseStoredBindingRow(row, this.spaceId);
|
|
934
|
+
if (canonicalJson(persistedBinding) !== canonicalJson(binding)) {
|
|
935
|
+
throw new OhProfileError("Whole-space purge requires the exact persisted store binding.");
|
|
936
|
+
}
|
|
937
|
+
const receipt = createOhSpacePurgeReceiptV1({ binding, priorHead: this.head(), purgedAt });
|
|
938
|
+
this.database.query(`INSERT INTO oh_space_purges(space_id, binding_sha256,
|
|
939
|
+
prior_operation_sha256, prior_sequence, purged_at, receipt_sha256, receipt_json)
|
|
940
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(this.spaceId, binding.bindingSha256,
|
|
941
|
+
receipt.priorHead.operationSha256, receipt.priorHead.sequence, receipt.purgedAt,
|
|
942
|
+
receipt.receiptSha256, canonicalJson(receipt));
|
|
943
|
+
this.database.query("DELETE FROM oh_search_fts WHERE space_id = ?").run(this.spaceId);
|
|
944
|
+
this.database.query("DELETE FROM oh_search_documents WHERE space_id = ?").run(this.spaceId);
|
|
945
|
+
this.database.query("DELETE FROM oh_dependencies WHERE space_id = ?").run(this.spaceId);
|
|
946
|
+
this.database.query(`DELETE FROM oh_operation_records WHERE operation_sha256 IN
|
|
947
|
+
(SELECT operation_sha256 FROM oh_operations WHERE space_id = ?)`).run(this.spaceId);
|
|
948
|
+
this.database.query("DELETE FROM oh_sync_outbox WHERE space_id = ?").run(this.spaceId);
|
|
949
|
+
this.database.query("DELETE FROM oh_sync_state WHERE space_id = ?").run(this.spaceId);
|
|
950
|
+
this.database.query("DELETE FROM oh_records WHERE space_id = ?").run(this.spaceId);
|
|
951
|
+
this.database.query("DELETE FROM oh_operations WHERE space_id = ?").run(this.spaceId);
|
|
952
|
+
this.database.query("DELETE FROM oh_space_bindings WHERE space_id = ?").run(this.spaceId);
|
|
953
|
+
this.database.query("DELETE FROM oh_spaces WHERE space_id = ?").run(this.spaceId);
|
|
954
|
+
const directTables = ["oh_spaces", "oh_space_bindings", "oh_operations", "oh_records",
|
|
955
|
+
"oh_dependencies", "oh_search_documents", "oh_sync_outbox", "oh_sync_state"] as const;
|
|
956
|
+
for (const table of directTables) {
|
|
957
|
+
const count = this.database.query<{ count: number }, [string]>(
|
|
958
|
+
`SELECT count(*) AS count FROM ${table} WHERE space_id = ?`,
|
|
959
|
+
).get(this.spaceId)?.count;
|
|
960
|
+
if (count !== 0) throw new OhIntegrityError(`Space purge left rows in ${table}.`);
|
|
961
|
+
}
|
|
962
|
+
const operationRecords = this.database.query<{ count: number }, [string]>(`SELECT count(*) AS count
|
|
963
|
+
FROM oh_operation_records AS materialized JOIN oh_operations AS operation
|
|
964
|
+
ON operation.operation_sha256 = materialized.operation_sha256
|
|
965
|
+
WHERE operation.space_id = ?`).get(this.spaceId)?.count;
|
|
966
|
+
const searchRows = this.database.query<{ count: number }, [string]>(
|
|
967
|
+
"SELECT count(*) AS count FROM oh_search_fts WHERE space_id = ?",
|
|
968
|
+
).get(this.spaceId)?.count;
|
|
969
|
+
if (operationRecords !== 0 || searchRows !== 0) {
|
|
970
|
+
throw new OhIntegrityError("Space purge left derived private payload rows.");
|
|
971
|
+
}
|
|
972
|
+
const receiptRow = this.database.query<PurgeRow, [string]>(
|
|
973
|
+
`SELECT ${PURGE_COLUMNS} FROM oh_space_purges WHERE space_id = ?`,
|
|
974
|
+
).get(this.spaceId);
|
|
975
|
+
if (receiptRow === null
|
|
976
|
+
|| canonicalJson(parseStoredPurgeRow(receiptRow, this.spaceId)) !== canonicalJson(receipt)) {
|
|
977
|
+
throw new OhIntegrityError("The stored purge receipt differs from the requested purge.");
|
|
978
|
+
}
|
|
979
|
+
return receipt;
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
close(): void {
|
|
984
|
+
if (this.#closed) return;
|
|
985
|
+
this.database.close(false);
|
|
986
|
+
this.#closed = true;
|
|
987
|
+
}
|
|
988
|
+
}
|