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