@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.
- package/dist/ai-limit-storage.d.ts.map +1 -1
- package/dist/ai-usage-storage.d.ts.map +1 -1
- package/dist/execution-storage.d.ts +28 -0
- package/dist/execution-storage.d.ts.map +1 -1
- package/dist/file-download-grant-storage.d.ts +11 -0
- package/dist/file-download-grant-storage.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1362 -579
- package/dist/index.js.map +27 -25
- package/dist/migrations.d.ts.map +1 -1
- package/dist/objects/query-compiler.d.ts.map +1 -1
- package/dist/objects/reader.d.ts.map +1 -1
- package/dist/objects/storage.d.ts.map +1 -1
- package/dist/ontology-storage/index.d.ts +2 -0
- package/dist/ontology-storage/index.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-session.d.ts +17 -37
- package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-state.d.ts +3 -26
- package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-writer.d.ts +1 -1
- package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
- package/dist/ontology-storage/materializations.d.ts +3 -6
- package/dist/ontology-storage/materializations.d.ts.map +1 -1
- package/dist/ontology-storage/outbox.d.ts.map +1 -1
- package/dist/ontology-storage/replacement-plans.d.ts +88 -0
- package/dist/ontology-storage/replacement-plans.d.ts.map +1 -0
- package/dist/ontology-storage/shared.d.ts +8 -5
- package/dist/ontology-storage/shared.d.ts.map +1 -1
- package/dist/ontology-storage/source-cleanup.d.ts.map +1 -1
- package/dist/ontology-storage/source-roots.d.ts +15 -2
- package/dist/ontology-storage/source-roots.d.ts.map +1 -1
- package/dist/ontology-storage/sources.d.ts +1 -2
- package/dist/ontology-storage/sources.d.ts.map +1 -1
- package/dist/projection-run-storage.d.ts.map +1 -1
- package/dist/rules-storage.d.ts.map +1 -1
- package/dist/testing.d.ts +4 -1
- package/dist/testing.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/ai-cost-storage.ts +10 -0
- package/src/ai-limit-storage.ts +13 -2
- package/src/ai-usage-storage.ts +9 -1
- package/src/execution-storage.ts +7 -2
- package/src/file-download-grant-storage.ts +76 -0
- package/src/index.ts +10 -3
- package/src/migrations/053-replacement-plans.sql +73 -0
- package/src/migrations/054-slim-ontology-outbox.sql +21 -0
- package/src/migrations/055-commit-touches.sql +31 -0
- package/src/migrations/056-audio-transcription.sql +10 -0
- package/src/migrations/057-reranking-model-kind.sql +16 -0
- package/src/migrations/058-file-download-grants.sql +21 -0
- package/src/migrations/059-drained-source-versions.sql +11 -0
- package/src/migrations.ts +115 -0
- package/src/objects/query-compiler.ts +5 -0
- package/src/objects/reader.ts +1 -0
- package/src/objects/storage.ts +5 -11
- package/src/ontology-storage/index.ts +6 -1
- package/src/ontology-storage/materialization-session.ts +120 -211
- package/src/ontology-storage/materialization-state.ts +5 -261
- package/src/ontology-storage/materialization-writer.ts +11 -13
- package/src/ontology-storage/materializations.ts +133 -210
- package/src/ontology-storage/outbox.ts +50 -16
- package/src/ontology-storage/replacement-plans.ts +1064 -0
- package/src/ontology-storage/shared.ts +17 -12
- package/src/ontology-storage/source-cleanup.ts +24 -9
- package/src/ontology-storage/source-roots.ts +38 -14
- package/src/ontology-storage/sources.ts +0 -26
- package/src/projection-run-storage.ts +7 -1
- package/src/rules-storage.ts +9 -14
- 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
|
+
}
|