@sixb/sqlite 0.1.16 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/ai-limit-storage.d.ts.map +1 -1
  2. package/dist/ai-usage-storage.d.ts.map +1 -1
  3. package/dist/execution-storage.d.ts +28 -0
  4. package/dist/execution-storage.d.ts.map +1 -1
  5. package/dist/file-download-grant-storage.d.ts +11 -0
  6. package/dist/file-download-grant-storage.d.ts.map +1 -0
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1362 -579
  10. package/dist/index.js.map +27 -25
  11. package/dist/migrations.d.ts.map +1 -1
  12. package/dist/objects/query-compiler.d.ts.map +1 -1
  13. package/dist/objects/reader.d.ts.map +1 -1
  14. package/dist/objects/storage.d.ts.map +1 -1
  15. package/dist/ontology-storage/index.d.ts +2 -0
  16. package/dist/ontology-storage/index.d.ts.map +1 -1
  17. package/dist/ontology-storage/materialization-session.d.ts +17 -37
  18. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  19. package/dist/ontology-storage/materialization-state.d.ts +3 -26
  20. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  21. package/dist/ontology-storage/materialization-writer.d.ts +1 -1
  22. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  23. package/dist/ontology-storage/materializations.d.ts +3 -6
  24. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  25. package/dist/ontology-storage/outbox.d.ts.map +1 -1
  26. package/dist/ontology-storage/replacement-plans.d.ts +88 -0
  27. package/dist/ontology-storage/replacement-plans.d.ts.map +1 -0
  28. package/dist/ontology-storage/shared.d.ts +8 -5
  29. package/dist/ontology-storage/shared.d.ts.map +1 -1
  30. package/dist/ontology-storage/source-cleanup.d.ts.map +1 -1
  31. package/dist/ontology-storage/source-roots.d.ts +15 -2
  32. package/dist/ontology-storage/source-roots.d.ts.map +1 -1
  33. package/dist/ontology-storage/sources.d.ts +1 -2
  34. package/dist/ontology-storage/sources.d.ts.map +1 -1
  35. package/dist/projection-run-storage.d.ts.map +1 -1
  36. package/dist/rules-storage.d.ts.map +1 -1
  37. package/dist/testing.d.ts +4 -1
  38. package/dist/testing.d.ts.map +1 -1
  39. package/package.json +3 -3
  40. package/src/ai-cost-storage.ts +10 -0
  41. package/src/ai-limit-storage.ts +13 -2
  42. package/src/ai-usage-storage.ts +9 -1
  43. package/src/execution-storage.ts +7 -2
  44. package/src/file-download-grant-storage.ts +76 -0
  45. package/src/index.ts +10 -3
  46. package/src/migrations/053-replacement-plans.sql +73 -0
  47. package/src/migrations/054-slim-ontology-outbox.sql +21 -0
  48. package/src/migrations/055-commit-touches.sql +31 -0
  49. package/src/migrations/056-audio-transcription.sql +10 -0
  50. package/src/migrations/057-reranking-model-kind.sql +16 -0
  51. package/src/migrations/058-file-download-grants.sql +21 -0
  52. package/src/migrations/059-drained-source-versions.sql +11 -0
  53. package/src/migrations.ts +115 -0
  54. package/src/objects/query-compiler.ts +5 -0
  55. package/src/objects/reader.ts +1 -0
  56. package/src/objects/storage.ts +5 -11
  57. package/src/ontology-storage/index.ts +6 -1
  58. package/src/ontology-storage/materialization-session.ts +120 -211
  59. package/src/ontology-storage/materialization-state.ts +5 -261
  60. package/src/ontology-storage/materialization-writer.ts +11 -13
  61. package/src/ontology-storage/materializations.ts +133 -210
  62. package/src/ontology-storage/outbox.ts +50 -16
  63. package/src/ontology-storage/replacement-plans.ts +1064 -0
  64. package/src/ontology-storage/shared.ts +17 -12
  65. package/src/ontology-storage/source-cleanup.ts +24 -9
  66. package/src/ontology-storage/source-roots.ts +38 -14
  67. package/src/ontology-storage/sources.ts +0 -26
  68. package/src/projection-run-storage.ts +7 -1
  69. package/src/rules-storage.ts +9 -14
  70. package/src/testing.ts +6 -4
@@ -0,0 +1,1064 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import {
3
+ type EffectiveChangeCounts,
4
+ MaterializationConflictError,
5
+ MaterializationValidationError,
6
+ type OntologyLinkRef,
7
+ type OntologyObjectRef,
8
+ objectRefKey,
9
+ } from "@sixb/core/internal/materialization"
10
+ import {
11
+ assertNonblank,
12
+ assertPageRows,
13
+ assertPositiveInteger,
14
+ assertReplacementPlanCommit,
15
+ canonicalJson,
16
+ duplicateMaterializationWork,
17
+ invalidCorrelation,
18
+ materializationWorkColumns,
19
+ prepareReplacementIdentities,
20
+ workUniquenessKey,
21
+ } from "@sixb/core/internal/ontology-storage-provider"
22
+ import type {
23
+ MaterializationObjectExistence,
24
+ OntologyCommitWrite,
25
+ OntologyReplacementPlanStorage,
26
+ OpenedReplacementPlan,
27
+ OpenReplacementPlanInput,
28
+ PurgeReplacementPlansInput,
29
+ ReplacementPlanRef,
30
+ ReplacementPlanStatePage,
31
+ ReplacementPlanStatus,
32
+ StageReplacementPlanInput,
33
+ StreamReplacementPlanStateInput,
34
+ } from "@sixb/core/storage"
35
+ import { SqliteMaterializationStateReader } from "./materialization-state"
36
+ import {
37
+ assertProjectionExecution,
38
+ isSqliteConstraintError,
39
+ parseJson,
40
+ type SqliteOntologySourceRow,
41
+ type SqliteRootOperation,
42
+ } from "./shared"
43
+ import { PUBLISHED, replacementSourceRows } from "./source-roots"
44
+
45
+ export const SQLITE_PLAN_WORK_TABLE = "ontology_replacement_plan_work"
46
+
47
+ /** The plan a session applies: its work rows carry the candidate's source version as `work_id`. */
48
+ export interface SqliteReplacementPlan {
49
+ readonly versionId: number
50
+ readonly projectionKind: "object" | "link"
51
+ }
52
+
53
+ interface PlanRow {
54
+ readonly version_id: number
55
+ readonly commit_id: string
56
+ readonly committed_at: string
57
+ readonly replaced_materialization_id: string | null
58
+ readonly replaced_last_commit_id: string | null
59
+ readonly watermark: number
60
+ }
61
+
62
+ interface IdentityRow {
63
+ readonly identity_key: string
64
+ readonly sort_key: string
65
+ readonly diff_required: number
66
+ }
67
+
68
+ const IDENTITIES = "ontology_replacement_plan_identities"
69
+ /** An identity's planning order: its key's UTF-8 bytes, as `objectRefSortKey`/`linkRefSortKey`. */
70
+ const SORT_KEY = "LOWER(HEX(CAST(identity_key AS BLOB)))"
71
+
72
+ /**
73
+ * The revision of an object identity: its effective row, its override and the latest point of each
74
+ * telemetry property. `type` and `id` are SQL expressions; `$projectId` is bound.
75
+ */
76
+ function objectRevision(type: string, id: string): string {
77
+ const series = (alias: string) =>
78
+ `${alias}.project_id = $projectId AND ${alias}.object_type_id = ${type} AND ${alias}.object_id = ${id}`
79
+ // One seek per property for its next name and one for its latest point: never the history.
80
+ return `json_array(
81
+ (SELECT json_array(version, last_commit_id) FROM objects
82
+ WHERE project_id = $projectId AND object_type_id = ${type} AND primary_id = ${id}),
83
+ (SELECT last_commit_id FROM ontology_object_overrides
84
+ WHERE project_id = $projectId AND object_type_id = ${type} AND primary_id = ${id}),
85
+ (WITH RECURSIVE properties(property_id) AS (
86
+ SELECT MIN(first.property_id) FROM timeseries AS first WHERE ${series("first")}
87
+ UNION ALL
88
+ SELECT (SELECT MIN(next.property_id) FROM timeseries AS next
89
+ WHERE ${series("next")} AND next.property_id > properties.property_id)
90
+ FROM properties WHERE properties.property_id IS NOT NULL
91
+ )
92
+ SELECT json_group_array(json((
93
+ SELECT json_array(points.property_id, points.at, points.last_commit_id)
94
+ FROM timeseries AS points
95
+ WHERE ${series("points")} AND points.property_id = properties.property_id
96
+ ORDER BY points.at DESC LIMIT 1
97
+ ))) FROM properties WHERE properties.property_id IS NOT NULL)
98
+ )`
99
+ }
100
+
101
+ /**
102
+ * The revision of a link endpoint, which a link plan reads only for its existence. One this plan
103
+ * decides exists as planned, which follows its whole object revision; any other exists as it is.
104
+ * `$versionId` is bound.
105
+ */
106
+ function endpointRevision(type: string, id: string): string {
107
+ return `CASE WHEN EXISTS (
108
+ SELECT 1 FROM ${IDENTITIES} AS endpoint
109
+ WHERE endpoint.version_id = $versionId AND endpoint.entity_kind = 'object'
110
+ AND endpoint.identity_key = json_array(${type}, ${id})
111
+ ) THEN ${objectRevision(type, id)}
112
+ ELSE json(CASE WHEN EXISTS (
113
+ SELECT 1 FROM objects
114
+ WHERE project_id = $projectId AND object_type_id = ${type} AND primary_id = ${id}
115
+ ) THEN 'true' ELSE 'false' END)
116
+ END`
117
+ }
118
+
119
+ /**
120
+ * The revision of a link identity, from its qualified key column `key`: its effective row, its
121
+ * edge and slot overrides, the live source root asserting it, and the existence of both
122
+ * endpoints. A live root that asserts a link is keyed by the link or by its source object.
123
+ */
124
+ function linkRevision(key: string): string {
125
+ const part = (index: number) => `json_extract(${key}, '$[${index}]')`
126
+ return `json_array(
127
+ (SELECT last_commit_id FROM links
128
+ WHERE project_id = $projectId AND source_type_id = ${part(0)} AND source_id = ${part(1)}
129
+ AND link_id = ${part(2)} AND target_type_id = ${part(3)} AND target_id = ${part(4)}),
130
+ (SELECT overrides.last_commit_id FROM ontology_link_overrides AS overrides
131
+ WHERE overrides.project_id = $projectId AND overrides.identity_kind = 'edge'
132
+ AND overrides.identity_key = ${key}),
133
+ (SELECT overrides.last_commit_id FROM ontology_link_overrides AS overrides
134
+ WHERE overrides.project_id = $projectId AND overrides.identity_kind = 'slot'
135
+ AND overrides.identity_key = json_array(${part(0)}, ${part(1)}, ${part(2)})),
136
+ (SELECT MAX(roots.id) FROM ontology_source_roots AS roots
137
+ CROSS JOIN ontology_sources AS versions ON versions.version_id = roots.version_id
138
+ AND versions.status IN ${PUBLISHED}
139
+ WHERE roots.project_id = $projectId AND roots.retired_at IS NULL AND roots.deleted = 0
140
+ AND roots.root_key IN (
141
+ '["link",' || substr(${key}, 2),
142
+ '["object",' || substr(json_array(${part(0)}, ${part(1)}), 2)
143
+ )
144
+ AND EXISTS (
145
+ -- \`+\` keeps the lookup on the root's few rows, not on every row of a hub source.
146
+ SELECT 1 FROM ontology_source_rows AS rows
147
+ WHERE rows.root_id = roots.id AND +rows.entity_kind = 'link'
148
+ AND +rows.source_type_id = ${part(0)} AND +rows.source_primary_id = ${part(1)}
149
+ AND +rows.link_id = ${part(2)} AND +rows.target_type_id = ${part(3)}
150
+ AND +rows.target_primary_id = ${part(4)}
151
+ )),
152
+ ${endpointRevision(part(0), part(1))},
153
+ ${endpointRevision(part(3), part(4))}
154
+ )`
155
+ }
156
+
157
+ /** The revision of the identity rows aliased `identities`. */
158
+ export function identityRevision(kind: "object" | "link"): string {
159
+ const key = "identities.identity_key"
160
+ return kind === "object"
161
+ ? objectRevision(`json_extract(${key}, '$[0]')`, `json_extract(${key}, '$[1]')`)
162
+ : linkRevision(key)
163
+ }
164
+
165
+ /** The keys of the links of `relation` (columns as `links`), and of both their endpoints. */
166
+ function linkKeys(relation: string): string {
167
+ return `SELECT json_array(source_type_id, source_id, link_id, target_type_id, target_id)
168
+ AS identity_key, json_array(source_type_id, source_id) AS source_key,
169
+ json_array(target_type_id, target_id) AS target_key
170
+ FROM ${relation}`
171
+ }
172
+
173
+ /** The run of link expansion an identity added now waits for: the next one. */
174
+ const NEXT_EXPANSION = `(
175
+ SELECT expansions FROM ontology_replacement_plans WHERE version_id = $versionId
176
+ )`
177
+
178
+ /**
179
+ * Inserts the links of `incident_links` as identities that need a diff, or makes an existing
180
+ * member need one; the next expansion starts from each of them.
181
+ */
182
+ const INSERT_INCIDENT_LINKS = `
183
+ INSERT INTO ${IDENTITIES} (
184
+ version_id, entity_kind, identity_key, sort_key, diff_required, expand_at, source_key,
185
+ target_key
186
+ )
187
+ SELECT $versionId, 'link', identity_key, ${SORT_KEY}, 1, ${NEXT_EXPANSION}, source_key,
188
+ target_key
189
+ FROM (${linkKeys("incident_links")}) WHERE true
190
+ ON CONFLICT (version_id, entity_kind, identity_key) DO UPDATE
191
+ SET diff_required = 1, read_revision = NULL, planned_revision = NULL, classified = 0,
192
+ change = NULL, expand_at = excluded.expand_at
193
+ WHERE ${IDENTITIES}.diff_required = 0`
194
+
195
+ /** Inserts the links of `members` as identities planned without a diff. */
196
+ const INSERT_MEMBER_LINKS = `
197
+ INSERT INTO ${IDENTITIES} (
198
+ version_id, entity_kind, identity_key, sort_key, diff_required, source_key, target_key
199
+ )
200
+ SELECT $versionId, 'link', identity_key, ${SORT_KEY}, 0, source_key, target_key
201
+ FROM (${linkKeys("members")}) WHERE true
202
+ ON CONFLICT (version_id, entity_kind, identity_key) DO NOTHING`
203
+
204
+ /** Live source rows of `$projectId`, as `rows`, joined from `rows` to their root and version. */
205
+ const LIVE_ROWS = `CROSS JOIN ontology_source_roots AS roots ON roots.id = rows.root_id
206
+ AND roots.project_id = $projectId AND roots.retired_at IS NULL AND roots.deleted = 0
207
+ CROSS JOIN ontology_sources AS versions ON versions.version_id = roots.version_id
208
+ AND versions.status IN ${PUBLISHED}`
209
+
210
+ /**
211
+ * The statements of `expandLinks`, `$versionId` and `$projectId` bound. They start from the
212
+ * identities planned or added since the previous expansion: purge keeps every touch a ready plan
213
+ * needs, so what commits add later always comes from their touches. Incident objects drive the
214
+ * loop and each side seeks its own index: without statistics, SQLite would search every link and
215
+ * override of the project once per object.
216
+ */
217
+ export const EXPAND_LINK_STATEMENTS = {
218
+ incident: `WITH incident_objects AS MATERIALIZED (
219
+ SELECT json_extract(work.payload, '$.ref.objectTypeId') AS object_type_id,
220
+ json_extract(work.payload, '$.ref.primaryId') AS primary_id
221
+ FROM ${IDENTITIES} AS objects
222
+ CROSS JOIN ${SQLITE_PLAN_WORK_TABLE} AS work ON work.work_id = $versionId
223
+ AND work.unique_key = 'incident-object:' || objects.identity_key
224
+ WHERE objects.version_id = $versionId AND objects.entity_kind = 'object'
225
+ AND objects.expand_at = ${NEXT_EXPANSION}
226
+ ), incident_links AS (
227
+ SELECT links.source_type_id, links.source_id, links.link_id,
228
+ links.target_type_id, links.target_id
229
+ FROM incident_objects CROSS JOIN links
230
+ ON links.project_id = $projectId
231
+ AND links.source_type_id = incident_objects.object_type_id
232
+ AND links.source_id = incident_objects.primary_id
233
+ UNION
234
+ SELECT links.source_type_id, links.source_id, links.link_id,
235
+ links.target_type_id, links.target_id
236
+ FROM incident_objects CROSS JOIN links INDEXED BY idx_links_target
237
+ ON links.project_id = $projectId
238
+ AND links.target_type_id = incident_objects.object_type_id
239
+ AND links.target_id = incident_objects.primary_id
240
+ UNION
241
+ SELECT overrides.source_type_id, overrides.source_primary_id, overrides.link_id,
242
+ overrides.target_type_id, overrides.target_primary_id
243
+ FROM incident_objects CROSS JOIN ontology_link_overrides AS overrides
244
+ ON overrides.project_id = $projectId AND overrides.identity_kind IN ('edge', 'slot')
245
+ AND overrides.source_type_id = incident_objects.object_type_id
246
+ AND overrides.source_primary_id = incident_objects.primary_id
247
+ UNION
248
+ SELECT overrides.source_type_id, overrides.source_primary_id, overrides.link_id,
249
+ overrides.target_type_id, overrides.target_primary_id
250
+ FROM incident_objects
251
+ CROSS JOIN ontology_link_overrides AS overrides INDEXED BY idx_ontology_link_overrides_target
252
+ ON overrides.project_id = $projectId
253
+ AND overrides.target_type_id = incident_objects.object_type_id
254
+ AND overrides.target_primary_id = incident_objects.primary_id
255
+ UNION
256
+ SELECT rows.source_type_id, rows.source_primary_id, rows.link_id,
257
+ rows.target_type_id, rows.target_primary_id
258
+ FROM incident_objects CROSS JOIN ontology_source_rows AS rows
259
+ ON rows.entity_kind = 'link'
260
+ AND rows.source_type_id = incident_objects.object_type_id
261
+ AND rows.source_primary_id = incident_objects.primary_id
262
+ ${LIVE_ROWS}
263
+ UNION
264
+ SELECT rows.source_type_id, rows.source_primary_id, rows.link_id,
265
+ rows.target_type_id, rows.target_primary_id
266
+ FROM incident_objects CROSS JOIN ontology_source_rows AS rows
267
+ ON rows.entity_kind = 'link'
268
+ AND rows.target_type_id = incident_objects.object_type_id
269
+ AND rows.target_primary_id = incident_objects.primary_id
270
+ ${LIVE_ROWS}
271
+ )
272
+ ${INSERT_INCIDENT_LINKS}`,
273
+ members: `WITH scopes AS (
274
+ SELECT DISTINCT json_extract(identity_key, '$[0]') AS source_type_id,
275
+ json_extract(identity_key, '$[1]') AS source_id,
276
+ json_extract(identity_key, '$[2]') AS link_id
277
+ FROM ${IDENTITIES}
278
+ WHERE version_id = $versionId AND entity_kind = 'link' AND diff_required = 1
279
+ AND expand_at = ${NEXT_EXPANSION}
280
+ ), members AS (
281
+ SELECT links.source_type_id, links.source_id, links.link_id,
282
+ links.target_type_id, links.target_id
283
+ FROM scopes CROSS JOIN links
284
+ ON links.project_id = $projectId
285
+ AND links.source_type_id = scopes.source_type_id
286
+ AND links.source_id = scopes.source_id AND links.link_id = scopes.link_id
287
+ UNION
288
+ SELECT overrides.source_type_id, overrides.source_primary_id, overrides.link_id,
289
+ overrides.target_type_id, overrides.target_primary_id
290
+ FROM scopes CROSS JOIN ontology_link_overrides AS overrides
291
+ ON overrides.project_id = $projectId AND overrides.identity_kind = 'slot'
292
+ AND overrides.source_type_id = scopes.source_type_id
293
+ AND overrides.source_primary_id = scopes.source_id
294
+ AND overrides.link_id = scopes.link_id
295
+ )
296
+ ${INSERT_MEMBER_LINKS}`,
297
+ }
298
+
299
+ /**
300
+ * What the commits since the watermark of plan `$versionId` touched in project `$projectId`.
301
+ * `+project_id` keeps the lookup on the rowid range instead of the project's whole history.
302
+ */
303
+ const TOUCHED = `touched AS MATERIALIZED (
304
+ SELECT DISTINCT touches.entity_kind, touches.identity_key
305
+ FROM ontology_commits AS commits
306
+ CROSS JOIN ontology_commit_touches AS touches
307
+ ON touches.project_id = commits.project_id AND touches.commit_id = commits.id
308
+ WHERE commits.rowid > (
309
+ SELECT watermark FROM ontology_replacement_plans WHERE version_id = $versionId
310
+ ) AND +commits.project_id = $projectId
311
+ )`
312
+
313
+ /**
314
+ * The statements of `refresh`, `$versionId` and `$projectId` bound: each unplans the identities of
315
+ * one kind whose revision reads something the commits since the watermark touched, and changed.
316
+ * An object reads its own row, override and telemetry; a link its row and edge override, its
317
+ * scope's slot override, its live source assertion and its endpoints. A scope's links are found
318
+ * from its source object.
319
+ */
320
+ export const TOUCHED_REFRESH_STATEMENTS = {
321
+ object: `WITH ${TOUCHED}
322
+ UPDATE ${IDENTITIES} AS identities
323
+ SET read_revision = NULL, planned_revision = NULL, classified = 0, change = NULL
324
+ WHERE identities.version_id = $versionId AND identities.entity_kind = 'object'
325
+ AND identities.identity_key IN (SELECT identity_key FROM touched WHERE entity_kind = 'object')
326
+ AND identities.planned_revision IS NOT ${identityRevision("object")}`,
327
+ link: `WITH ${TOUCHED}, candidates AS (
328
+ SELECT identity_key FROM touched WHERE entity_kind = 'link'
329
+ UNION
330
+ SELECT links.identity_key FROM touched CROSS JOIN ${IDENTITIES} AS links
331
+ ON links.version_id = $versionId
332
+ AND links.source_key = json_array(
333
+ json_extract(touched.identity_key, '$[0]'), json_extract(touched.identity_key, '$[1]')
334
+ )
335
+ WHERE touched.entity_kind IN ('object', 'scope')
336
+ UNION
337
+ SELECT links.identity_key FROM touched CROSS JOIN ${IDENTITIES} AS links
338
+ ON links.version_id = $versionId AND links.target_key = touched.identity_key
339
+ WHERE touched.entity_kind = 'object'
340
+ )
341
+ UPDATE ${IDENTITIES} AS identities
342
+ SET read_revision = NULL, planned_revision = NULL, classified = 0, change = NULL
343
+ WHERE identities.version_id = $versionId AND identities.entity_kind = 'link'
344
+ AND identities.identity_key IN (SELECT identity_key FROM candidates)
345
+ AND identities.planned_revision IS NOT ${identityRevision("link")}`,
346
+ }
347
+
348
+ const TOUCHED_LINKS = `touched_links AS (
349
+ SELECT identity_key, json_extract(identity_key, '$[0]') AS source_type_id,
350
+ json_extract(identity_key, '$[1]') AS source_id, json_extract(identity_key, '$[2]') AS link_id,
351
+ json_extract(identity_key, '$[3]') AS target_type_id,
352
+ json_extract(identity_key, '$[4]') AS target_id
353
+ FROM touched WHERE entity_kind = 'link'
354
+ ), slot_targets AS (
355
+ SELECT overrides.source_type_id, overrides.source_primary_id AS source_id, overrides.link_id,
356
+ overrides.target_type_id, overrides.target_primary_id AS target_id
357
+ FROM touched CROSS JOIN ontology_link_overrides AS overrides
358
+ ON overrides.project_id = $projectId AND overrides.identity_kind = 'slot'
359
+ AND overrides.identity_key = touched.identity_key
360
+ WHERE touched.entity_kind = 'scope'
361
+ )`
362
+
363
+ const LINK_COLUMNS = "source_type_id, source_id, link_id, target_type_id, target_id"
364
+
365
+ const EFFECTIVE_TOUCHED_LINK = `EXISTS (
366
+ SELECT 1 FROM links AS stored
367
+ WHERE stored.project_id = $projectId AND stored.source_type_id = touched_links.source_type_id
368
+ AND stored.source_id = touched_links.source_id AND stored.link_id = touched_links.link_id
369
+ AND stored.target_type_id = touched_links.target_type_id
370
+ AND stored.target_id = touched_links.target_id
371
+ )`
372
+
373
+ /**
374
+ * The statements of `expandTouched`, `$versionId` and `$projectId` bound: the links `expandLinks`
375
+ * would find, among those the commits since the watermark touched.
376
+ */
377
+ export const EXPAND_TOUCHED_STATEMENTS = {
378
+ incident: `WITH ${TOUCHED}, ${TOUCHED_LINKS}, incident_links AS (
379
+ SELECT candidates.* FROM (
380
+ SELECT ${LINK_COLUMNS} FROM touched_links
381
+ WHERE ${EFFECTIVE_TOUCHED_LINK} OR EXISTS (
382
+ SELECT 1 FROM ontology_link_overrides AS overrides
383
+ WHERE overrides.project_id = $projectId AND overrides.identity_kind = 'edge'
384
+ AND overrides.identity_key = touched_links.identity_key
385
+ ) OR EXISTS (
386
+ SELECT 1 FROM ontology_source_rows AS rows ${LIVE_ROWS}
387
+ WHERE rows.entity_kind = 'link' AND rows.source_type_id = touched_links.source_type_id
388
+ AND rows.source_primary_id = touched_links.source_id
389
+ AND rows.link_id = touched_links.link_id
390
+ AND rows.target_type_id = touched_links.target_type_id
391
+ AND rows.target_primary_id = touched_links.target_id
392
+ )
393
+ UNION
394
+ SELECT ${LINK_COLUMNS} FROM slot_targets
395
+ ) AS candidates
396
+ WHERE EXISTS (
397
+ SELECT 1 FROM ${SQLITE_PLAN_WORK_TABLE} AS work
398
+ WHERE work.work_id = $versionId AND work.unique_key IN (
399
+ 'incident-object:' || json_array(candidates.source_type_id, candidates.source_id),
400
+ 'incident-object:' || json_array(candidates.target_type_id, candidates.target_id)
401
+ )
402
+ )
403
+ )
404
+ ${INSERT_INCIDENT_LINKS}`,
405
+ members: `WITH ${TOUCHED}, ${TOUCHED_LINKS}, members AS (
406
+ SELECT candidates.* FROM (
407
+ SELECT ${LINK_COLUMNS} FROM touched_links WHERE ${EFFECTIVE_TOUCHED_LINK}
408
+ UNION
409
+ SELECT ${LINK_COLUMNS} FROM slot_targets
410
+ ) AS candidates
411
+ WHERE EXISTS (
412
+ SELECT 1 FROM ${IDENTITIES} AS changed
413
+ WHERE changed.version_id = $versionId
414
+ AND changed.source_key = json_array(candidates.source_type_id, candidates.source_id)
415
+ AND changed.diff_required = 1
416
+ AND json_extract(changed.identity_key, '$[2]') = candidates.link_id
417
+ )
418
+ )
419
+ ${INSERT_MEMBER_LINKS}`,
420
+ }
421
+
422
+ /**
423
+ * Deletes up to `$limit` touches of `$projectId` that every ready plan's watermark has seen, or
424
+ * all its touches when no plan is ready.
425
+ */
426
+ const PURGE_TOUCHES_STATEMENT = `DELETE FROM ontology_commit_touches WHERE rowid IN (
427
+ SELECT touches.rowid FROM ontology_commit_touches AS touches
428
+ CROSS JOIN ontology_commits AS commits
429
+ ON commits.project_id = touches.project_id AND commits.id = touches.commit_id
430
+ WHERE touches.project_id = $projectId AND commits.rowid <= COALESCE(
431
+ (SELECT MIN(plans.watermark) FROM ontology_replacement_plans AS plans
432
+ JOIN ontology_sources AS versions USING (version_id)
433
+ WHERE plans.project_id = $projectId AND versions.status = 'ready'),
434
+ (SELECT COALESCE(MAX(rowid), 0) FROM ontology_commits)
435
+ )
436
+ LIMIT $limit
437
+ )`
438
+
439
+ export class SqliteOntologyReplacementPlanStorage implements OntologyReplacementPlanStorage {
440
+ constructor(
441
+ private readonly db: Database,
442
+ private readonly runRootOperation: SqliteRootOperation
443
+ ) {}
444
+
445
+ async open(input: OpenReplacementPlanInput): Promise<OpenedReplacementPlan> {
446
+ return this.runRootOperation(() => {
447
+ const { commit } = input
448
+ assertReplacementPlanCommit(commit)
449
+ const candidate = this.requireCandidate(input)
450
+ const active = this.db
451
+ .query(
452
+ `SELECT materialization_id, last_commit_id FROM ontology_sources
453
+ WHERE project_id = ? AND source_id = ? AND status = 'active'`
454
+ )
455
+ .get(input.projectId, input.source.projectionId) as {
456
+ readonly materialization_id: string
457
+ readonly last_commit_id: string | null
458
+ } | null
459
+ const replacedMaterializationId = active?.materialization_id ?? null
460
+ const replacedLastCommitId = active?.last_commit_id ?? null
461
+ const existing = planRow(this.db, candidate.version_id)
462
+ // A plan decides the entities of the source it replaces: once that moved, it starts over.
463
+ if (
464
+ existing?.replaced_materialization_id === replacedMaterializationId &&
465
+ existing.replaced_last_commit_id === replacedLastCommitId
466
+ ) {
467
+ return { committedAt: existing.committed_at }
468
+ }
469
+ if (existing) deletePlan(this.db, candidate.version_id)
470
+ this.db
471
+ .query(
472
+ `INSERT INTO ontology_replacement_plans (
473
+ version_id, project_id, commit_id, committed_at, replaced_materialization_id,
474
+ replaced_last_commit_id, watermark
475
+ ) VALUES (?, ?, ?, ?, ?, ?, (SELECT COALESCE(MAX(rowid), 0) FROM ontology_commits))`
476
+ )
477
+ .run(
478
+ candidate.version_id,
479
+ input.projectId,
480
+ commit.id,
481
+ commit.committedAt,
482
+ replacedMaterializationId,
483
+ replacedLastCommitId
484
+ )
485
+ const rows = replacementSourceRows(this.db, {
486
+ projectId: input.projectId,
487
+ sourceId: input.source.projectionId,
488
+ materializationId: input.materializationId,
489
+ incremental: candidate.base_materialization_id !== null,
490
+ })
491
+ // Every entity of the candidate and of the roots it replaces needs a diff.
492
+ this.db
493
+ .query(
494
+ `INSERT INTO ${IDENTITIES} (
495
+ version_id, entity_kind, identity_key, sort_key, diff_required, expand_at,
496
+ source_key, target_key
497
+ )
498
+ SELECT ?, entity_kind, identity_key, ${SORT_KEY}, 1,
499
+ CASE entity_kind WHEN 'link' THEN 0 END, source_key, target_key
500
+ FROM (
501
+ SELECT entity_kind, CASE entity_kind
502
+ WHEN 'object' THEN json_array(object_type_id, primary_id)
503
+ ELSE json_array(
504
+ source_type_id, source_primary_id, link_id, target_type_id, target_primary_id
505
+ )
506
+ END AS identity_key,
507
+ CASE entity_kind
508
+ WHEN 'link' THEN json_array(source_type_id, source_primary_id)
509
+ END AS source_key,
510
+ CASE entity_kind
511
+ WHEN 'link' THEN json_array(target_type_id, target_primary_id)
512
+ END AS target_key
513
+ FROM (${rows.sql}) AS rows
514
+ )
515
+ GROUP BY entity_kind, identity_key`
516
+ )
517
+ .run(candidate.version_id, ...rows.values)
518
+ return { committedAt: commit.committedAt }
519
+ })
520
+ }
521
+
522
+ async *streamState(
523
+ input: StreamReplacementPlanStateInput
524
+ ): AsyncIterable<ReplacementPlanStatePage> {
525
+ assertPageRows(input.pageRows)
526
+ await this.runRootOperation(() => {
527
+ const { candidate } = this.requirePlan(input)
528
+ if (input.entityKind === "object" && candidate.projection_kind !== "object") {
529
+ throw new MaterializationConflictError(
530
+ "source-materialization",
531
+ "Link projection replacement cannot stream object state."
532
+ )
533
+ }
534
+ if (input.entityKind === "link") {
535
+ if (unplannedCount(this.db, candidate.version_id, "object") > 0) {
536
+ throw new MaterializationConflictError(
537
+ "effective-state",
538
+ "Object projection replacement must plan every object before its links."
539
+ )
540
+ }
541
+ this.expandLinks(candidate.version_id, input.projectId)
542
+ }
543
+ })
544
+ let after = ""
545
+ while (true) {
546
+ const page = await this.runRootOperation(() => {
547
+ const { candidate } = this.requirePlan(input)
548
+ const rows = this.db
549
+ .query(
550
+ `SELECT identity_key, sort_key, diff_required FROM ${IDENTITIES}
551
+ WHERE version_id = ? AND entity_kind = ? AND planned_revision IS NULL AND sort_key > ?
552
+ ORDER BY sort_key
553
+ LIMIT ?`
554
+ )
555
+ .all(candidate.version_id, input.entityKind, after, input.pageRows) as IdentityRow[]
556
+ if (rows.length === 0) return null
557
+ after = rows[rows.length - 1]!.sort_key
558
+ const state = this.readPage(input, candidate, rows)
559
+ this.db
560
+ .query(
561
+ `UPDATE ${IDENTITIES} AS identities SET read_revision = ${identityRevision(input.entityKind)}
562
+ WHERE identities.version_id = $versionId AND identities.entity_kind = $entityKind
563
+ AND identities.identity_key IN (SELECT value FROM json_each($keys))`
564
+ )
565
+ .run({
566
+ $projectId: input.projectId,
567
+ $versionId: candidate.version_id,
568
+ $entityKind: input.entityKind,
569
+ $keys: JSON.stringify(rows.map((row) => row.identity_key)),
570
+ })
571
+ return state
572
+ })
573
+ if (!page) return
574
+ yield page
575
+ }
576
+ }
577
+
578
+ async stage(input: StageReplacementPlanInput): Promise<void> {
579
+ await this.runRootOperation(() => {
580
+ const { plan, candidate } = this.requirePlan(input)
581
+ const identities = prepareReplacementIdentities(input, {
582
+ commitId: plan.commit_id,
583
+ committedAt: plan.committed_at,
584
+ })
585
+ const versionId = candidate.version_id
586
+ this.db.run("SAVEPOINT sixb_replacement_plan_stage")
587
+ let recordKey: string | undefined
588
+ try {
589
+ // Only an identity streamed and still to plan has the revision its work is planned
590
+ // from, and holds no work: unplanning one deletes its work and forgets what it read.
591
+ const known = this.db.query(
592
+ `SELECT read_revision, planned_revision FROM ${IDENTITIES}
593
+ WHERE version_id = ? AND entity_kind = ? AND identity_key = ?`
594
+ )
595
+ const insert = this.db.query(
596
+ `INSERT INTO ${SQLITE_PLAN_WORK_TABLE} (
597
+ work_id, entity_kind, identity_key, record_key, unique_key, kind, lane,
598
+ major_order, minor_order, sort_one, sort_two,
599
+ classification_entity_kind, classification_identity_key,
600
+ cardinality_view, cardinality_occupied, cardinality_source_type_id,
601
+ cardinality_source_primary_id, cardinality_link_id,
602
+ cardinality_target_type_id, cardinality_target_primary_id, payload
603
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, json(?))`
604
+ )
605
+ // A planned object may flip its existence: the next link expansion starts from it.
606
+ const planned = this.db.query(
607
+ `UPDATE ${IDENTITIES}
608
+ SET planned_revision = read_revision, classified = ?, change = ?,
609
+ expand_at = CASE entity_kind
610
+ WHEN 'object' THEN (
611
+ SELECT expansions FROM ontology_replacement_plans WHERE version_id = ?
612
+ )
613
+ ELSE expand_at
614
+ END
615
+ WHERE version_id = ? AND entity_kind = ? AND identity_key = ?`
616
+ )
617
+ for (const identity of identities) {
618
+ const row = known.get(versionId, identity.kind, identity.key) as {
619
+ readonly read_revision: string | null
620
+ readonly planned_revision: string | null
621
+ } | null
622
+ if (row?.read_revision == null || row.planned_revision !== null) {
623
+ throw new MaterializationValidationError(
624
+ `Replacement identity ${identity.entityKey} is not streamed and still to plan.`
625
+ )
626
+ }
627
+ for (const record of identity.records) {
628
+ recordKey = record.recordKey
629
+ const columns = materializationWorkColumns(record)
630
+ const classification = record.kind === "classification" ? record : null
631
+ const cardinality = record.kind === "cardinality" ? record : null
632
+ insert.run(
633
+ versionId,
634
+ identity.kind,
635
+ identity.key,
636
+ record.recordKey,
637
+ workUniquenessKey(record),
638
+ record.kind,
639
+ columns.lane,
640
+ columns.majorOrder,
641
+ columns.minorOrder,
642
+ columns.sortOne,
643
+ columns.sortTwo,
644
+ classification?.entityKind ?? null,
645
+ classification?.identityKey ?? null,
646
+ cardinality?.view ?? null,
647
+ cardinality ? Number(cardinality.occupied) : null,
648
+ cardinality?.ref.source.objectTypeId ?? null,
649
+ cardinality?.ref.source.primaryId ?? null,
650
+ cardinality?.ref.linkId ?? null,
651
+ cardinality?.ref.target.objectTypeId ?? null,
652
+ cardinality?.ref.target.primaryId ?? null,
653
+ canonicalJson(record)
654
+ )
655
+ }
656
+ planned.run(
657
+ Number(identity.classified),
658
+ identity.change,
659
+ versionId,
660
+ versionId,
661
+ identity.kind,
662
+ identity.key
663
+ )
664
+ }
665
+ this.db.run("RELEASE SAVEPOINT sixb_replacement_plan_stage")
666
+ } catch (error) {
667
+ this.db.run("ROLLBACK TO SAVEPOINT sixb_replacement_plan_stage")
668
+ this.db.run("RELEASE SAVEPOINT sixb_replacement_plan_stage")
669
+ if (isSqliteConstraintError(error)) throw duplicateMaterializationWork(recordKey)
670
+ throw error
671
+ }
672
+ })
673
+ }
674
+
675
+ async refresh(input: ReplacementPlanRef): Promise<ReplacementPlanStatus> {
676
+ return this.runRootOperation(() => {
677
+ const { plan, candidate } = this.requirePlan(input)
678
+ const versionId = candidate.version_id
679
+ const pending = unplannedCount(this.db, versionId)
680
+ if (pending > 0) return { fresh: false, unplanned: pending }
681
+ if (!committedSince(this.db, plan, input.projectId)) {
682
+ return { fresh: true, counts: replacementPlanCounts(this.db, versionId) }
683
+ }
684
+ // Purge keeps every touch past the watermark of a ready plan: only touched identities can
685
+ // have changed.
686
+ const params = { $projectId: input.projectId, $versionId: versionId }
687
+ let unplanned = 0
688
+ for (const kind of ["object", "link"] as const) {
689
+ const stale = this.db.query(TOUCHED_REFRESH_STATEMENTS[kind]).run(params).changes
690
+ if (stale === 0) continue
691
+ dropUnplannedWork(this.db, versionId, kind)
692
+ unplanned += stale
693
+ }
694
+ unplanned += this.expandTouched(versionId, input.projectId)
695
+ this.db
696
+ .query(
697
+ `UPDATE ontology_replacement_plans
698
+ SET watermark = (SELECT COALESCE(MAX(rowid), 0) FROM ontology_commits)
699
+ WHERE version_id = ?`
700
+ )
701
+ .run(versionId)
702
+ if (unplanned > 0) return { fresh: false, unplanned }
703
+ return { fresh: true, counts: replacementPlanCounts(this.db, versionId) }
704
+ })
705
+ }
706
+
707
+ async purge(input: PurgeReplacementPlansInput): Promise<number> {
708
+ return this.runRootOperation(() => {
709
+ assertNonblank(input.projectId, "Replacement plan purge project id")
710
+ assertPositiveInteger(input.limit, "Replacement plan purge limit")
711
+ const spentPlan = this.db.query(
712
+ `SELECT plans.version_id FROM ontology_replacement_plans AS plans
713
+ JOIN ontology_sources AS versions USING (version_id)
714
+ WHERE plans.project_id = ? AND versions.status <> 'ready'
715
+ ORDER BY plans.version_id
716
+ LIMIT 1`
717
+ )
718
+ let deleted = 0
719
+ // A plan's row goes last, so a plan whose rows outlast the budget is found again next time.
720
+ while (deleted < input.limit) {
721
+ const spent = spentPlan.get(input.projectId) as { readonly version_id: number } | null
722
+ if (!spent) break
723
+ for (const [table, column] of [
724
+ [SQLITE_PLAN_WORK_TABLE, "work_id"],
725
+ [IDENTITIES, "version_id"],
726
+ ] as const) {
727
+ deleted += this.db
728
+ .query(
729
+ `DELETE FROM ${table} WHERE rowid IN (
730
+ SELECT rowid FROM ${table} WHERE ${column} = ? LIMIT ?
731
+ )`
732
+ )
733
+ .run(spent.version_id, input.limit - deleted).changes
734
+ if (deleted >= input.limit) return deleted
735
+ }
736
+ this.db
737
+ .query(`DELETE FROM ontology_replacement_plans WHERE version_id = ?`)
738
+ .run(spent.version_id)
739
+ deleted += 1
740
+ }
741
+ if (deleted >= input.limit) return deleted
742
+ // Every write holds the store's lock: a plan that opens later has seen all these commits.
743
+ return (
744
+ deleted +
745
+ this.db
746
+ .query(PURGE_TOUCHES_STATEMENT)
747
+ .run({ $projectId: input.projectId, $limit: input.limit - deleted }).changes
748
+ )
749
+ })
750
+ }
751
+
752
+ private readPage(
753
+ input: StreamReplacementPlanStateInput,
754
+ candidate: SqliteOntologySourceRow,
755
+ rows: readonly IdentityRow[]
756
+ ): ReplacementPlanStatePage {
757
+ const reader = new SqliteMaterializationStateReader(this.db, input.projectId)
758
+ if (input.entityKind === "object") {
759
+ const refs = rows.map((row) => {
760
+ const [objectTypeId, primaryId] = parseJson<string[]>(row.identity_key)
761
+ return { objectTypeId: objectTypeId!, primaryId: primaryId! }
762
+ })
763
+ return {
764
+ objects: reader.replacementObjectStates(
765
+ input.source.projectionId,
766
+ input.materializationId,
767
+ refs
768
+ ),
769
+ links: [],
770
+ endpoints: [],
771
+ }
772
+ }
773
+ const identities = rows.map((row) => {
774
+ const parts = parseJson<string[]>(row.identity_key)
775
+ const ref: OntologyLinkRef = {
776
+ source: { objectTypeId: parts[0]!, primaryId: parts[1]! },
777
+ linkId: parts[2]!,
778
+ target: { objectTypeId: parts[3]!, primaryId: parts[4]! },
779
+ }
780
+ return {
781
+ ref,
782
+ sortKey: row.sort_key,
783
+ diffRequired: row.diff_required === 1,
784
+ }
785
+ })
786
+ const previous = this.db
787
+ .query(
788
+ `SELECT materialization_id FROM ontology_sources
789
+ WHERE project_id = ? AND source_id = ? AND status = 'active'`
790
+ )
791
+ .get(input.projectId, input.source.projectionId) as {
792
+ readonly materialization_id: string
793
+ } | null
794
+ const links = reader.replacementLinkStates(
795
+ input.source.projectionId,
796
+ input.materializationId,
797
+ [input.materializationId, ...(previous ? [previous.materialization_id] : [])],
798
+ identities,
799
+ candidate.base_materialization_id !== null
800
+ )
801
+ return {
802
+ objects: [],
803
+ links,
804
+ endpoints: this.endpoints(
805
+ candidate.version_id,
806
+ input.projectId,
807
+ identities.flatMap(({ ref }) => [ref.source, ref.target])
808
+ ),
809
+ }
810
+ }
811
+
812
+ /** Planned existence where the plan decides an endpoint, effective existence elsewhere. */
813
+ private endpoints(
814
+ versionId: number,
815
+ projectId: string,
816
+ refs: readonly OntologyObjectRef[]
817
+ ): MaterializationObjectExistence[] {
818
+ const unique = [...new Map(refs.map((ref) => [objectRefKey(ref), ref] as const)).values()]
819
+ const rows = this.db
820
+ .query(
821
+ `SELECT requested.value AS identity_key, (
822
+ SELECT json_extract(work.payload, '$.exists') FROM ${SQLITE_PLAN_WORK_TABLE} AS work
823
+ WHERE work.work_id = ? AND work.unique_key = 'object-existence:' || requested.value
824
+ ) AS planned, EXISTS (
825
+ SELECT 1 FROM objects
826
+ WHERE project_id = ? AND object_type_id = json_extract(requested.value, '$[0]')
827
+ AND primary_id = json_extract(requested.value, '$[1]')
828
+ ) AS effective
829
+ FROM json_each(?) AS requested`
830
+ )
831
+ .all(versionId, projectId, JSON.stringify(unique.map((ref) => objectRefKey(ref)))) as {
832
+ readonly identity_key: string
833
+ readonly planned: number | null
834
+ readonly effective: number
835
+ }[]
836
+ const existence = new Map(
837
+ rows.map((row) => [row.identity_key, (row.planned ?? row.effective) === 1] as const)
838
+ )
839
+ return unique.map((ref) => ({ ref, exists: existence.get(objectRefKey(ref)) ?? false }))
840
+ }
841
+
842
+ /**
843
+ * Adds the links the plan must decide besides its own: those incident to an object it flips,
844
+ * and every member of a scope it changes, from the identities planned or added since the
845
+ * previous expansion. Returns how many identities this left to plan, counting an existing member
846
+ * that now needs a diff. What commits add later is found from their touches by `expandTouched`.
847
+ */
848
+ private expandLinks(versionId: number, projectId: string): number {
849
+ const added = this.expand(versionId, projectId, EXPAND_LINK_STATEMENTS)
850
+ this.db
851
+ .query(
852
+ `UPDATE ontology_replacement_plans SET expansions = expansions + 1 WHERE version_id = ?`
853
+ )
854
+ .run(versionId)
855
+ return added
856
+ }
857
+
858
+ /**
859
+ * Adds the links commits brought within the plan's reach, from what they touched: a link that
860
+ * now exists incident to an object the plan flips, or in a scope a planned link changes.
861
+ */
862
+ private expandTouched(versionId: number, projectId: string): number {
863
+ return this.expand(versionId, projectId, EXPAND_TOUCHED_STATEMENTS)
864
+ }
865
+
866
+ private expand(
867
+ versionId: number,
868
+ projectId: string,
869
+ statements: { readonly incident: string; readonly members: string }
870
+ ): number {
871
+ const run = (statement: string) =>
872
+ this.db.query(statement).run({ $projectId: projectId, $versionId: versionId }).changes
873
+ const diffLinks = run(statements.incident)
874
+ // An upgraded member planned without a diff keeps no work from before.
875
+ if (diffLinks > 0) dropUnplannedWork(this.db, versionId, "link")
876
+ return diffLinks + run(statements.members)
877
+ }
878
+
879
+ private requireCandidate(input: ReplacementPlanRef): SqliteOntologySourceRow {
880
+ const candidate = sourceRow(
881
+ this.db,
882
+ input.projectId,
883
+ input.source.projectionId,
884
+ input.materializationId
885
+ )
886
+ if (
887
+ !candidate ||
888
+ candidate.status !== "ready" ||
889
+ candidate.projection_run_id !== input.execution.projectionRunId ||
890
+ candidate.execution_token !== input.execution.executionToken
891
+ ) {
892
+ throw new MaterializationConflictError(
893
+ "source-materialization",
894
+ `Candidate source materialization '${input.materializationId}' is missing, not ready, or owned by another execution.`
895
+ )
896
+ }
897
+ assertProjectionExecution(this.db, {
898
+ projectId: input.projectId,
899
+ sourceId: input.source.projectionId,
900
+ projectionRunId: input.execution.projectionRunId,
901
+ executionToken: input.execution.executionToken,
902
+ })
903
+ return candidate
904
+ }
905
+
906
+ private requirePlan(input: ReplacementPlanRef): {
907
+ readonly plan: PlanRow
908
+ readonly candidate: SqliteOntologySourceRow
909
+ } {
910
+ const candidate = this.requireCandidate(input)
911
+ const plan = planRow(this.db, candidate.version_id)
912
+ if (!plan) {
913
+ throw new MaterializationConflictError(
914
+ "source-materialization",
915
+ `Candidate source materialization '${input.materializationId}' has no open plan.`
916
+ )
917
+ }
918
+ return { plan, candidate }
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Every identity the plan decides with a diff is classified, and nothing else is. Staging records
924
+ * whether an identity's work holds its classification, so this reads identities only.
925
+ */
926
+ export function assertReplacementPlanCoverage(db: Database, plan: SqliteReplacementPlan): void {
927
+ const mismatch = db
928
+ .query(
929
+ `SELECT 1 FROM ${IDENTITIES}
930
+ WHERE version_id = ?
931
+ AND classified <> (diff_required AND (entity_kind = 'link' OR ? = 1))
932
+ LIMIT 1`
933
+ )
934
+ .get(plan.versionId, plan.projectionKind === "object" ? 1 : 0)
935
+ if (mismatch) {
936
+ invalidCorrelation("Projection replacement classification coverage does not match its plan.")
937
+ }
938
+ }
939
+
940
+ /** The change counts of a plan, from what its identities planned. */
941
+ export function replacementPlanCounts(db: Database, versionId: number): EffectiveChangeCounts {
942
+ const rows = db
943
+ .query(
944
+ `SELECT entity_kind, COALESCE(change, 'unchanged') AS change, COUNT(*) AS count
945
+ FROM ${IDENTITIES}
946
+ WHERE version_id = ? AND classified = 1
947
+ GROUP BY entity_kind, COALESCE(change, 'unchanged')`
948
+ )
949
+ .all(versionId) as {
950
+ readonly entity_kind: "object" | "link"
951
+ readonly change: "created" | "updated" | "deleted" | "unchanged"
952
+ readonly count: number
953
+ }[]
954
+ const count = (kind: "object" | "link", change: string) =>
955
+ rows.find((row) => row.entity_kind === kind && row.change === change)?.count ?? 0
956
+ return {
957
+ objectsCreated: count("object", "created"),
958
+ objectsUpdated: count("object", "updated"),
959
+ objectsDeleted: count("object", "deleted"),
960
+ objectsUnchanged: count("object", "unchanged"),
961
+ linksCreated: count("link", "created"),
962
+ linksUpdated: count("link", "updated"),
963
+ linksDeleted: count("link", "deleted"),
964
+ linksUnchanged: count("link", "unchanged"),
965
+ }
966
+ }
967
+
968
+ function unplannedCount(db: Database, versionId: number, kind?: "object" | "link"): number {
969
+ const row = db
970
+ .query(
971
+ `SELECT COUNT(*) AS count FROM ${IDENTITIES}
972
+ WHERE version_id = ? AND planned_revision IS NULL AND (? IS NULL OR entity_kind = ?)`
973
+ )
974
+ .get(versionId, kind ?? null, kind ?? null) as { readonly count: number }
975
+ return row.count
976
+ }
977
+
978
+ /**
979
+ * Whether a commit of the project landed after the plan's watermark. Commit rows are never deleted
980
+ * and every write holds the store's lock, so their rowids grow in commit order.
981
+ */
982
+ function committedSince(db: Database, plan: PlanRow, projectId: string): boolean {
983
+ // `+project_id` keeps the lookup on the rowid range instead of the project's whole history.
984
+ return (
985
+ db
986
+ .query(`SELECT 1 FROM ontology_commits WHERE rowid > ? AND +project_id = ? LIMIT 1`)
987
+ .get(plan.watermark, projectId) !== null
988
+ )
989
+ }
990
+
991
+ /** Deletes the work of the identities of `kind` left to plan: an identity to plan holds none. */
992
+ function dropUnplannedWork(db: Database, versionId: number, kind: "object" | "link"): void {
993
+ db.query(
994
+ `DELETE FROM ${SQLITE_PLAN_WORK_TABLE}
995
+ WHERE work_id = ? AND entity_kind = ? AND identity_key IN (
996
+ SELECT identity_key FROM ${IDENTITIES}
997
+ WHERE version_id = ? AND entity_kind = ? AND planned_revision IS NULL
998
+ )`
999
+ ).run(versionId, kind, versionId, kind)
1000
+ }
1001
+
1002
+ function deletePlan(db: Database, versionId: number): void {
1003
+ db.query(`DELETE FROM ${SQLITE_PLAN_WORK_TABLE} WHERE work_id = ?`).run(versionId)
1004
+ db.query(`DELETE FROM ${IDENTITIES} WHERE version_id = ?`).run(versionId)
1005
+ db.query(`DELETE FROM ontology_replacement_plans WHERE version_id = ?`).run(versionId)
1006
+ }
1007
+
1008
+ function planRow(db: Database, versionId: number): PlanRow | null {
1009
+ return db
1010
+ .query(`SELECT * FROM ontology_replacement_plans WHERE version_id = ?`)
1011
+ .get(versionId) as PlanRow | null
1012
+ }
1013
+
1014
+ function sourceRow(
1015
+ db: Database,
1016
+ projectId: string,
1017
+ sourceId: string,
1018
+ materializationId: string
1019
+ ): SqliteOntologySourceRow | null {
1020
+ return db
1021
+ .query(
1022
+ `SELECT * FROM ontology_sources
1023
+ WHERE project_id = ? AND source_id = ? AND materialization_id = ?`
1024
+ )
1025
+ .get(projectId, sourceId, materializationId) as SqliteOntologySourceRow | null
1026
+ }
1027
+
1028
+ /** The fresh, fully planned plan a session applies, with exactly its commit. */
1029
+ export function boundReplacementPlan(
1030
+ db: Database,
1031
+ header: {
1032
+ readonly commit: OntologyCommitWrite
1033
+ readonly plan?: {
1034
+ readonly source: { readonly projectionId: string }
1035
+ readonly materializationId: string
1036
+ }
1037
+ }
1038
+ ): SqliteReplacementPlan {
1039
+ const ref = header.plan
1040
+ const candidate = ref
1041
+ ? sourceRow(db, header.commit.projectId, ref.source.projectionId, ref.materializationId)
1042
+ : null
1043
+ const plan = candidate ? planRow(db, candidate.version_id) : null
1044
+ if (!ref || !candidate || !plan || header.commit.intent.kind !== "projection") {
1045
+ throw new MaterializationConflictError(
1046
+ "source-materialization",
1047
+ "A plan-bound session needs the open plan of a projection candidate."
1048
+ )
1049
+ }
1050
+ if (plan.commit_id !== header.commit.id || plan.committed_at !== header.commit.committedAt) {
1051
+ throw new MaterializationValidationError(
1052
+ "A plan-bound session must begin with the commit its plan carries."
1053
+ )
1054
+ }
1055
+ if (
1056
+ committedSince(db, plan, header.commit.projectId) ||
1057
+ unplannedCount(db, candidate.version_id) > 0
1058
+ ) {
1059
+ throw new MaterializationValidationError(
1060
+ "A replacement plan applies only fully planned and just refreshed."
1061
+ )
1062
+ }
1063
+ return { versionId: candidate.version_id, projectionKind: candidate.projection_kind }
1064
+ }