@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.
- package/README.md +125 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3202 -1474
- package/dist/index.js.map +32 -25
- package/dist/migrations.d.ts.map +1 -1
- package/dist/objects/aggregate-admission.d.ts +10 -0
- package/dist/objects/aggregate-admission.d.ts.map +1 -0
- package/dist/objects/preparation-lock.d.ts +6 -0
- package/dist/objects/preparation-lock.d.ts.map +1 -0
- package/dist/objects/prepare-queries.d.ts +5 -0
- package/dist/objects/prepare-queries.d.ts.map +1 -0
- package/dist/objects/query-compiler.d.ts +19 -0
- package/dist/objects/query-compiler.d.ts.map +1 -1
- package/dist/objects/query-indexes.d.ts +43 -0
- package/dist/objects/query-indexes.d.ts.map +1 -0
- package/dist/objects/reader.d.ts +7 -1
- package/dist/objects/reader.d.ts.map +1 -1
- package/dist/objects/storage.d.ts +1 -1
- package/dist/objects/storage.d.ts.map +1 -1
- package/dist/objects/text-count-index.d.ts +24 -0
- package/dist/objects/text-count-index.d.ts.map +1 -0
- 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 +18 -38
- 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 +51 -9
- package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
- package/dist/ontology-storage/materializations.d.ts +5 -7
- 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 +36 -0
- package/dist/ontology-storage/replacement-plans.d.ts.map +1 -0
- package/dist/ontology-storage/shared.d.ts +11 -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 +14 -1
- 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/pg-ai-cost-storage.d.ts.map +1 -1
- package/dist/pg-ai-limit-storage.d.ts.map +1 -1
- package/dist/pg-ai-usage-storage.d.ts +2 -0
- package/dist/pg-ai-usage-storage.d.ts.map +1 -1
- package/dist/pg-execution-storage.d.ts +28 -0
- package/dist/pg-execution-storage.d.ts.map +1 -1
- package/dist/pg-file-download-grant-storage.d.ts +11 -0
- package/dist/pg-file-download-grant-storage.d.ts.map +1 -0
- package/dist/pg-projection-run-storage.d.ts.map +1 -1
- package/dist/pg-rules-storage.d.ts.map +1 -1
- package/dist/transactions.d.ts +6 -2
- package/dist/transactions.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +53 -3
- package/src/migrations/053-replacement-plans.sql +72 -0
- package/src/migrations/054-slim-ontology-outbox.sql +108 -0
- package/src/migrations/055-object-query-preparation.sql +11 -0
- package/src/migrations/056-commit-touches.sql +41 -0
- package/src/migrations/057-audio-transcription.sql +10 -0
- package/src/migrations/058-reranking-model-kind.sql +11 -0
- package/src/migrations/059-file-download-grants.sql +16 -0
- package/src/migrations/060-drained-source-versions.sql +11 -0
- package/src/migrations.ts +20 -0
- package/src/objects/aggregate-admission.ts +21 -0
- package/src/objects/preparation-lock.ts +15 -0
- package/src/objects/prepare-queries.ts +181 -0
- package/src/objects/query-compiler.ts +405 -115
- package/src/objects/query-indexes.ts +348 -0
- package/src/objects/reader.ts +140 -51
- package/src/objects/storage.ts +15 -3
- package/src/objects/text-count-index.ts +455 -0
- package/src/ontology-storage/index.ts +3 -0
- package/src/ontology-storage/materialization-session.ts +116 -275
- package/src/ontology-storage/materialization-state.ts +8 -259
- package/src/ontology-storage/materialization-writer.ts +618 -508
- package/src/ontology-storage/materializations.ts +106 -289
- package/src/ontology-storage/outbox.ts +46 -15
- package/src/ontology-storage/replacement-plans.ts +1110 -0
- package/src/ontology-storage/shared.ts +27 -15
- package/src/ontology-storage/source-cleanup.ts +22 -10
- package/src/ontology-storage/source-roots.ts +35 -8
- package/src/ontology-storage/sources.ts +0 -30
- package/src/pg-ai-cost-storage.ts +12 -0
- package/src/pg-ai-limit-storage.ts +13 -2
- package/src/pg-ai-usage-storage.ts +8 -0
- package/src/pg-execution-storage.ts +8 -3
- package/src/pg-file-download-grant-storage.ts +76 -0
- package/src/pg-projection-run-storage.ts +6 -1
- package/src/pg-rules-storage.ts +7 -3
- 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
|
+
}
|