@sixb/pg 0.0.1

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 (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/agents/agent-storage.d.ts +20 -0
  4. package/dist/agents/agent-storage.d.ts.map +1 -0
  5. package/dist/agents/index.d.ts +6 -0
  6. package/dist/agents/index.d.ts.map +1 -0
  7. package/dist/agents/messages.d.ts +13 -0
  8. package/dist/agents/messages.d.ts.map +1 -0
  9. package/dist/agents/rows.d.ts +77 -0
  10. package/dist/agents/rows.d.ts.map +1 -0
  11. package/dist/agents/runs.d.ts +25 -0
  12. package/dist/agents/runs.d.ts.map +1 -0
  13. package/dist/agents/threads.d.ts +13 -0
  14. package/dist/agents/threads.d.ts.map +1 -0
  15. package/dist/auth-storage/access-tokens.d.ts +33 -0
  16. package/dist/auth-storage/access-tokens.d.ts.map +1 -0
  17. package/dist/auth-storage/auth-storage.d.ts +41 -0
  18. package/dist/auth-storage/auth-storage.d.ts.map +1 -0
  19. package/dist/auth-storage/group-memberships.d.ts +21 -0
  20. package/dist/auth-storage/group-memberships.d.ts.map +1 -0
  21. package/dist/auth-storage/identities.d.ts +17 -0
  22. package/dist/auth-storage/identities.d.ts.map +1 -0
  23. package/dist/auth-storage/index.d.ts +3 -0
  24. package/dist/auth-storage/index.d.ts.map +1 -0
  25. package/dist/auth-storage/invitations.d.ts +29 -0
  26. package/dist/auth-storage/invitations.d.ts.map +1 -0
  27. package/dist/auth-storage/magic-links.d.ts +28 -0
  28. package/dist/auth-storage/magic-links.d.ts.map +1 -0
  29. package/dist/auth-storage/oidc-attempts.d.ts +18 -0
  30. package/dist/auth-storage/oidc-attempts.d.ts.map +1 -0
  31. package/dist/auth-storage/rows.d.ts +138 -0
  32. package/dist/auth-storage/rows.d.ts.map +1 -0
  33. package/dist/auth-storage/service-account-group-memberships.d.ts +17 -0
  34. package/dist/auth-storage/service-account-group-memberships.d.ts.map +1 -0
  35. package/dist/auth-storage/service-accounts.d.ts +14 -0
  36. package/dist/auth-storage/service-accounts.d.ts.map +1 -0
  37. package/dist/auth-storage/sessions.d.ts +55 -0
  38. package/dist/auth-storage/sessions.d.ts.map +1 -0
  39. package/dist/auth-storage/shared.d.ts +159 -0
  40. package/dist/auth-storage/shared.d.ts.map +1 -0
  41. package/dist/auth-storage/users.d.ts +19 -0
  42. package/dist/auth-storage/users.d.ts.map +1 -0
  43. package/dist/index.d.ts +127 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +11957 -0
  46. package/dist/index.js.map +55 -0
  47. package/dist/latest-run-query.d.ts +24 -0
  48. package/dist/latest-run-query.d.ts.map +1 -0
  49. package/dist/migrations.d.ts +20 -0
  50. package/dist/migrations.d.ts.map +1 -0
  51. package/dist/ontology-storage/commits.d.ts +11 -0
  52. package/dist/ontology-storage/commits.d.ts.map +1 -0
  53. package/dist/ontology-storage/index.d.ts +22 -0
  54. package/dist/ontology-storage/index.d.ts.map +1 -0
  55. package/dist/ontology-storage/materialization-session.d.ts +63 -0
  56. package/dist/ontology-storage/materialization-session.d.ts.map +1 -0
  57. package/dist/ontology-storage/materialization-state.d.ts +69 -0
  58. package/dist/ontology-storage/materialization-state.d.ts.map +1 -0
  59. package/dist/ontology-storage/materialization-writer.d.ts +15 -0
  60. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -0
  61. package/dist/ontology-storage/materializations.d.ts +33 -0
  62. package/dist/ontology-storage/materializations.d.ts.map +1 -0
  63. package/dist/ontology-storage/outbox.d.ts +12 -0
  64. package/dist/ontology-storage/outbox.d.ts.map +1 -0
  65. package/dist/ontology-storage/shared.d.ts +95 -0
  66. package/dist/ontology-storage/shared.d.ts.map +1 -0
  67. package/dist/ontology-storage/sources.d.ts +25 -0
  68. package/dist/ontology-storage/sources.d.ts.map +1 -0
  69. package/dist/pg-action-run-storage.d.ts +21 -0
  70. package/dist/pg-action-run-storage.d.ts.map +1 -0
  71. package/dist/pg-client.d.ts +58 -0
  72. package/dist/pg-client.d.ts.map +1 -0
  73. package/dist/pg-object-query-compiler.d.ts +55 -0
  74. package/dist/pg-object-query-compiler.d.ts.map +1 -0
  75. package/dist/pg-object-storage.d.ts +90 -0
  76. package/dist/pg-object-storage.d.ts.map +1 -0
  77. package/dist/pg-pipeline-run-storage.d.ts +18 -0
  78. package/dist/pg-pipeline-run-storage.d.ts.map +1 -0
  79. package/dist/pg-projection-run-storage.d.ts +19 -0
  80. package/dist/pg-projection-run-storage.d.ts.map +1 -0
  81. package/dist/pg-rules-storage.d.ts +25 -0
  82. package/dist/pg-rules-storage.d.ts.map +1 -0
  83. package/dist/pg-sync-run-storage.d.ts +15 -0
  84. package/dist/pg-sync-run-storage.d.ts.map +1 -0
  85. package/dist/pg-timeseries-storage.d.ts +32 -0
  86. package/dist/pg-timeseries-storage.d.ts.map +1 -0
  87. package/dist/pg-webhook-delivery-storage.d.ts +17 -0
  88. package/dist/pg-webhook-delivery-storage.d.ts.map +1 -0
  89. package/dist/pg-webhook-run-storage.d.ts +14 -0
  90. package/dist/pg-webhook-run-storage.d.ts.map +1 -0
  91. package/dist/pg-workflow-intervention-storage.d.ts +16 -0
  92. package/dist/pg-workflow-intervention-storage.d.ts.map +1 -0
  93. package/dist/pg-workflow-run-storage.d.ts +49 -0
  94. package/dist/pg-workflow-run-storage.d.ts.map +1 -0
  95. package/dist/run-list-query.d.ts +30 -0
  96. package/dist/run-list-query.d.ts.map +1 -0
  97. package/dist/storage-errors.d.ts +16 -0
  98. package/dist/storage-errors.d.ts.map +1 -0
  99. package/dist/transactions.d.ts +10 -0
  100. package/dist/transactions.d.ts.map +1 -0
  101. package/dist/tsconfig.json +5 -0
  102. package/package.json +60 -0
  103. package/src/agents/agent-storage.ts +26 -0
  104. package/src/agents/index.ts +5 -0
  105. package/src/agents/messages.ts +144 -0
  106. package/src/agents/rows.ts +227 -0
  107. package/src/agents/runs.ts +330 -0
  108. package/src/agents/threads.ts +116 -0
  109. package/src/auth-storage/access-tokens.ts +254 -0
  110. package/src/auth-storage/auth-storage.ts +798 -0
  111. package/src/auth-storage/group-memberships.ts +47 -0
  112. package/src/auth-storage/identities.ts +85 -0
  113. package/src/auth-storage/index.ts +2 -0
  114. package/src/auth-storage/invitations.ts +280 -0
  115. package/src/auth-storage/magic-links.ts +140 -0
  116. package/src/auth-storage/oidc-attempts.ts +93 -0
  117. package/src/auth-storage/rows.ts +352 -0
  118. package/src/auth-storage/service-account-group-memberships.ts +150 -0
  119. package/src/auth-storage/service-accounts.ts +154 -0
  120. package/src/auth-storage/sessions.ts +193 -0
  121. package/src/auth-storage/shared.ts +799 -0
  122. package/src/auth-storage/users.ts +196 -0
  123. package/src/index.ts +367 -0
  124. package/src/latest-run-query.ts +42 -0
  125. package/src/migrations/001-initial-schema.sql +1195 -0
  126. package/src/migrations.ts +280 -0
  127. package/src/ontology-storage/commits.ts +115 -0
  128. package/src/ontology-storage/index.ts +35 -0
  129. package/src/ontology-storage/materialization-session.ts +426 -0
  130. package/src/ontology-storage/materialization-state.ts +1109 -0
  131. package/src/ontology-storage/materialization-writer.ts +509 -0
  132. package/src/ontology-storage/materializations.ts +939 -0
  133. package/src/ontology-storage/outbox.ts +216 -0
  134. package/src/ontology-storage/shared.ts +293 -0
  135. package/src/ontology-storage/sources.ts +658 -0
  136. package/src/pg-action-run-storage.ts +657 -0
  137. package/src/pg-client.ts +124 -0
  138. package/src/pg-object-query-compiler.ts +1545 -0
  139. package/src/pg-object-storage.ts +638 -0
  140. package/src/pg-pipeline-run-storage.ts +457 -0
  141. package/src/pg-projection-run-storage.ts +526 -0
  142. package/src/pg-rules-storage.ts +308 -0
  143. package/src/pg-sync-run-storage.ts +263 -0
  144. package/src/pg-timeseries-storage.ts +180 -0
  145. package/src/pg-webhook-delivery-storage.ts +180 -0
  146. package/src/pg-webhook-run-storage.ts +185 -0
  147. package/src/pg-workflow-intervention-storage.ts +339 -0
  148. package/src/pg-workflow-run-storage.ts +1065 -0
  149. package/src/run-list-query.ts +108 -0
  150. package/src/sql.d.ts +4 -0
  151. package/src/storage-errors.ts +72 -0
  152. package/src/transactions.ts +58 -0
@@ -0,0 +1,658 @@
1
+ import { MaterializationValidationError } from "@sixb/core/internal/materialization"
2
+ import {
3
+ assertSourceBeginInput as assertBeginInput,
4
+ assertSourceExecutionIdentity as assertExecutionIdentity,
5
+ assertSourceProject as assertProjectAndSource,
6
+ assertSourceCandidateOwner,
7
+ assertSourceWriteIdentity as assertWriteIdentity,
8
+ sourceEntityColumns as entityColumns,
9
+ isExactStagingManifest,
10
+ reconcileSourceStageRows,
11
+ type SourceStageRow,
12
+ sourceConflict,
13
+ sourceMaterializationIdentity,
14
+ sourceStageRow,
15
+ sourceStageRows,
16
+ utf8SortKey,
17
+ } from "@sixb/core/internal/ontology-storage-provider"
18
+ import type {
19
+ AbandonSourceMaterializationCandidateInput,
20
+ AbandonSourceMaterializationInput,
21
+ AssertSourceMaterializationExecutionInput,
22
+ BeginSourceMaterializationInput,
23
+ CleanupTerminalSourceMaterializationsInput,
24
+ CleanupTerminalSourceMaterializationsResult,
25
+ GetActiveOntologySourceInput,
26
+ MarkSourceMaterializationReadyInput,
27
+ OntologySourceRecord,
28
+ OntologySourceStorage,
29
+ ReclaimSourceMaterializationInput,
30
+ StageSourceRowsInput,
31
+ StageSourceRowsResult,
32
+ SummarizeTerminalSourceMaterializationsInput,
33
+ TerminalSourceMaterializationSummary,
34
+ } from "@sixb/core/storage"
35
+ import type { SQLClient } from "../pg-client"
36
+ import { lockAdvisoryKeys } from "../transactions"
37
+ import {
38
+ assertNonblank,
39
+ assertNonnegativeInteger,
40
+ assertPositiveInteger,
41
+ assertProjectionExecution,
42
+ assertTimestamp,
43
+ jsonParameter,
44
+ ontologyLockKey,
45
+ type PgOntologySourceAssertionRow,
46
+ type PgOntologySourceRow,
47
+ type PgRootOperation,
48
+ sourceAssertion,
49
+ sourceRecord,
50
+ toIsoString,
51
+ } from "./shared"
52
+
53
+ export class PgOntologySourceStorage implements OntologySourceStorage {
54
+ constructor(private readonly runRootOperation: PgRootOperation) {}
55
+
56
+ async beginMaterialization(
57
+ input: BeginSourceMaterializationInput
58
+ ): Promise<OntologySourceRecord> {
59
+ return this.runRootOperation(async (sql) => {
60
+ assertBeginInput(input)
61
+ await this.assertExecution(sql, input, sourceMaterializationIdentity(input))
62
+ await lockAdvisoryKeys(sql, [
63
+ ontologyLockKey("source-candidate", input.projectId, input.execution.projectionRunId),
64
+ ontologyLockKey(
65
+ "source-materialization",
66
+ input.projectId,
67
+ input.source.projectionId,
68
+ input.materializationId
69
+ ),
70
+ ])
71
+
72
+ const existing = await this.getManifest(
73
+ sql,
74
+ input.projectId,
75
+ input.source.projectionId,
76
+ input.materializationId,
77
+ true
78
+ )
79
+ if (existing) {
80
+ if (isExactStagingManifest(sourceRecord(existing), input)) return sourceRecord(existing)
81
+ throw sourceConflict(
82
+ `Source materialization '${input.materializationId}' already exists with different identity or state.`
83
+ )
84
+ }
85
+
86
+ const candidate = await this.getRunCandidate(sql, input)
87
+ if (candidate) {
88
+ throw sourceConflict(
89
+ `Projection run '${input.execution.projectionRunId}' already has a nonterminal source materialization; reclaim it before beginning another.`
90
+ )
91
+ }
92
+
93
+ const rows = await sql<PgOntologySourceRow[]>`
94
+ INSERT INTO ontology_sources (
95
+ project_id, source_id, materialization_id, projection_run_id,
96
+ projection_kind, protocol, status, execution_token,
97
+ dataset_id, dataset_version_id, dataset_version_created_at,
98
+ projection_revision, ownership_hash, ontology_revision,
99
+ root_count, assertion_count, created_at, ready_at, activated_at,
100
+ terminal_at, last_commit_id, updated_at
101
+ ) VALUES (
102
+ ${input.projectId}, ${input.source.projectionId}, ${input.materializationId},
103
+ ${input.execution.projectionRunId}, ${input.projectionKind}, 'replacement',
104
+ 'staging', ${input.execution.executionToken}, ${input.datasetVersion.datasetId},
105
+ ${input.datasetVersion.versionId}, ${input.datasetVersion.createdAt},
106
+ ${input.projectionRevision}, ${input.ownershipHash}, ${input.ontologyRevision},
107
+ NULL, NULL, ${input.createdAt}, NULL, NULL, NULL, NULL, ${input.createdAt}
108
+ )
109
+ ON CONFLICT DO NOTHING
110
+ RETURNING *
111
+ `
112
+ if (rows[0]) return sourceRecord(rows[0])
113
+
114
+ const racedManifest = await this.getManifest(
115
+ sql,
116
+ input.projectId,
117
+ input.source.projectionId,
118
+ input.materializationId,
119
+ true
120
+ )
121
+ if (racedManifest && isExactStagingManifest(sourceRecord(racedManifest), input)) {
122
+ return sourceRecord(racedManifest)
123
+ }
124
+ if (await this.getRunCandidate(sql, input)) {
125
+ throw sourceConflict(
126
+ `Projection run '${input.execution.projectionRunId}' already has a nonterminal source materialization; reclaim it before beginning another.`
127
+ )
128
+ }
129
+ throw sourceConflict(
130
+ `Source materialization '${input.materializationId}' already exists with different identity or state.`
131
+ )
132
+ })
133
+ }
134
+
135
+ async stageRows(input: StageSourceRowsInput): Promise<StageSourceRowsResult> {
136
+ return this.runRootOperation(async (sql) => {
137
+ assertWriteIdentity(input)
138
+ await this.assertExecution(sql, input)
139
+ const manifest = await this.requireManifest(
140
+ sql,
141
+ input.projectId,
142
+ input.source.projectionId,
143
+ input.materializationId,
144
+ true
145
+ )
146
+ assertSourceCandidateOwner(sourceRecord(manifest), input.execution)
147
+ if (manifest.status !== "staging") {
148
+ throw sourceConflict(
149
+ `Source materialization '${input.materializationId}' is '${manifest.status}' and cannot accept rows.`
150
+ )
151
+ }
152
+
153
+ const rows = sourceStageRows(manifest.projection_kind, input.rows)
154
+ const existing = await this.findStageRows(sql, input, rows)
155
+ const { pending, unchanged } = reconcileSourceStageRows(rows, existing)
156
+ await this.insertStageRows(sql, input, pending)
157
+ return { inserted: pending.length, unchanged }
158
+ })
159
+ }
160
+
161
+ async markReady(input: MarkSourceMaterializationReadyInput): Promise<OntologySourceRecord> {
162
+ return this.runRootOperation(async (sql) => {
163
+ assertWriteIdentity(input)
164
+ assertTimestamp(input.readyAt, "Source readyAt", true)
165
+ assertNonnegativeInteger(input.rootCount, "Source root count")
166
+ assertNonnegativeInteger(input.assertionCount, "Source assertion count")
167
+ await this.assertExecution(sql, input)
168
+ const manifest = await this.requireManifest(
169
+ sql,
170
+ input.projectId,
171
+ input.source.projectionId,
172
+ input.materializationId,
173
+ true
174
+ )
175
+ assertSourceCandidateOwner(sourceRecord(manifest), input.execution)
176
+ if (manifest.status === "ready") {
177
+ if (
178
+ numberOrNull(manifest.root_count) === input.rootCount &&
179
+ numberOrNull(manifest.assertion_count) === input.assertionCount &&
180
+ toIsoString(manifest.ready_at!) === input.readyAt
181
+ ) {
182
+ return sourceRecord(manifest)
183
+ }
184
+ throw sourceConflict(
185
+ `Source materialization '${input.materializationId}' was marked ready with different counts or time.`
186
+ )
187
+ }
188
+ if (manifest.status !== "staging") {
189
+ throw sourceConflict(
190
+ `Source materialization '${input.materializationId}' cannot transition from '${manifest.status}' to 'ready'.`
191
+ )
192
+ }
193
+ if (input.readyAt < toIsoString(manifest.created_at)) {
194
+ throw new MaterializationValidationError("Source readyAt cannot precede source createdAt.")
195
+ }
196
+ await this.assertReadyState(sql, manifest, input.rootCount, input.assertionCount)
197
+ const rows = await sql<PgOntologySourceRow[]>`
198
+ UPDATE ontology_sources
199
+ SET status = 'ready', root_count = ${input.rootCount},
200
+ assertion_count = ${input.assertionCount}, ready_at = ${input.readyAt},
201
+ updated_at = ${input.readyAt}
202
+ WHERE project_id = ${input.projectId}
203
+ AND source_id = ${input.source.projectionId}
204
+ AND materialization_id = ${input.materializationId}
205
+ AND status = 'staging'
206
+ AND execution_token = ${input.execution.executionToken}
207
+ RETURNING *
208
+ `
209
+ if (!rows[0]) {
210
+ throw sourceConflict(`Source materialization '${input.materializationId}' changed.`)
211
+ }
212
+ return sourceRecord(rows[0])
213
+ })
214
+ }
215
+
216
+ async getActive(input: GetActiveOntologySourceInput): Promise<OntologySourceRecord | null> {
217
+ return this.runRootOperation(async (sql) => {
218
+ assertProjectAndSource(input)
219
+ const [row] = await sql<PgOntologySourceRow[]>`
220
+ SELECT * FROM ontology_sources
221
+ WHERE project_id = ${input.projectId}
222
+ AND source_id = ${input.source.projectionId}
223
+ AND status = 'active'
224
+ `
225
+ return row ? sourceRecord(row) : null
226
+ })
227
+ }
228
+
229
+ async abandon(input: AbandonSourceMaterializationCandidateInput): Promise<OntologySourceRecord>
230
+ async abandon(input: ReclaimSourceMaterializationInput): Promise<OntologySourceRecord | null>
231
+ async abandon(input: AbandonSourceMaterializationInput): Promise<OntologySourceRecord | null> {
232
+ return this.runRootOperation(async (sql) => {
233
+ assertProjectAndSource(input)
234
+ assertExecutionIdentity(input.execution.projectionRunId, input.execution.executionToken)
235
+ assertTimestamp(input.abandonedAt, "Source abandonedAt", true)
236
+ await this.assertExecution(sql, input)
237
+ return input.kind === "candidate"
238
+ ? this.abandonCandidate(sql, input)
239
+ : this.abandonForReclaim(sql, input)
240
+ })
241
+ }
242
+
243
+ async cleanupTerminal(
244
+ input: CleanupTerminalSourceMaterializationsInput
245
+ ): Promise<CleanupTerminalSourceMaterializationsResult> {
246
+ return this.runRootOperation(async (sql) => {
247
+ assertNonblank(input.projectId, "Terminal source cleanup project id")
248
+ assertTimestamp(input.terminalBefore, "Terminal source cleanup cutoff", true)
249
+ assertPositiveInteger(input.limit, "Terminal source cleanup limit")
250
+ const manifests = await sql<PgOntologySourceRow[]>`
251
+ SELECT * FROM ontology_sources
252
+ WHERE project_id = ${input.projectId}
253
+ AND status IN ('superseded', 'abandoned')
254
+ AND terminal_at < ${input.terminalBefore}
255
+ ORDER BY terminal_at, source_id, materialization_id
256
+ LIMIT ${input.limit}
257
+ FOR UPDATE SKIP LOCKED
258
+ `
259
+ let remaining = input.limit
260
+ let rowsDeleted = 0
261
+ let materializationsDeleted = 0
262
+ for (const manifest of manifests) {
263
+ if (remaining === 0) break
264
+ const deletedRows = await sql<{ readonly entity_sort_key: string }[]>`
265
+ WITH selected AS (
266
+ SELECT rows.ctid
267
+ FROM ontology_source_rows AS rows
268
+ WHERE rows.project_id = ${manifest.project_id}
269
+ AND rows.source_id = ${manifest.source_id}
270
+ AND rows.materialization_id = ${manifest.materialization_id}
271
+ ORDER BY rows.entity_sort_key
272
+ LIMIT ${remaining}
273
+ FOR UPDATE SKIP LOCKED
274
+ )
275
+ DELETE FROM ontology_source_rows AS rows
276
+ USING selected
277
+ WHERE rows.ctid = selected.ctid
278
+ RETURNING rows.entity_sort_key
279
+ `
280
+ rowsDeleted += deletedRows.length
281
+ remaining -= deletedRows.length
282
+ if (remaining === 0) break
283
+ const removed = await sql<{ readonly materialization_id: string }[]>`
284
+ DELETE FROM ontology_sources AS sources
285
+ WHERE sources.project_id = ${manifest.project_id}
286
+ AND sources.source_id = ${manifest.source_id}
287
+ AND sources.materialization_id = ${manifest.materialization_id}
288
+ AND sources.status IN ('superseded', 'abandoned')
289
+ AND sources.terminal_at = ${manifest.terminal_at}
290
+ AND sources.terminal_at < ${input.terminalBefore}
291
+ AND NOT EXISTS (
292
+ SELECT 1 FROM ontology_source_rows AS rows
293
+ WHERE rows.project_id = sources.project_id
294
+ AND rows.source_id = sources.source_id
295
+ AND rows.materialization_id = sources.materialization_id
296
+ )
297
+ RETURNING sources.materialization_id
298
+ `
299
+ materializationsDeleted += removed.length
300
+ remaining -= removed.length
301
+ }
302
+ return { rowsDeleted, materializationsDeleted }
303
+ })
304
+ }
305
+
306
+ async summarizeTerminal(
307
+ input: SummarizeTerminalSourceMaterializationsInput
308
+ ): Promise<TerminalSourceMaterializationSummary> {
309
+ return this.runRootOperation(async (sql) => {
310
+ assertNonblank(input.projectId, "Terminal source summary project id")
311
+ const [summary] = await sql<
312
+ {
313
+ readonly terminal_count: number
314
+ readonly oldest_terminal_at: Date | string | null
315
+ }[]
316
+ >`
317
+ SELECT COUNT(*)::int AS terminal_count, MIN(terminal_at) AS oldest_terminal_at
318
+ FROM ontology_sources
319
+ WHERE project_id = ${input.projectId}
320
+ AND status IN ('superseded', 'abandoned')
321
+ `
322
+ return {
323
+ count: summary?.terminal_count ?? 0,
324
+ oldestTerminalAt: toOptionalIsoString(summary?.oldest_terminal_at),
325
+ }
326
+ })
327
+ }
328
+
329
+ private async abandonCandidate(
330
+ sql: SQLClient,
331
+ input: AbandonSourceMaterializationCandidateInput
332
+ ): Promise<OntologySourceRecord> {
333
+ const manifest = await this.requireManifest(
334
+ sql,
335
+ input.projectId,
336
+ input.source.projectionId,
337
+ input.materializationId,
338
+ true
339
+ )
340
+ if (manifest.status === "abandoned") {
341
+ if (
342
+ manifest.projection_run_id === input.execution.projectionRunId &&
343
+ manifest.terminal_at !== null &&
344
+ toIsoString(manifest.terminal_at) === input.abandonedAt
345
+ ) {
346
+ return sourceRecord(manifest)
347
+ }
348
+ throw sourceConflict(
349
+ `Source materialization '${input.materializationId}' was abandoned by another execution or at another time.`
350
+ )
351
+ }
352
+ assertSourceCandidateOwner(sourceRecord(manifest), input.execution)
353
+ return this.transitionToAbandoned(sql, manifest, input.abandonedAt)
354
+ }
355
+
356
+ private async abandonForReclaim(
357
+ sql: SQLClient,
358
+ input: ReclaimSourceMaterializationInput
359
+ ): Promise<OntologySourceRecord | null> {
360
+ const candidates = await sql<PgOntologySourceRow[]>`
361
+ SELECT * FROM ontology_sources
362
+ WHERE project_id = ${input.projectId}
363
+ AND source_id = ${input.source.projectionId}
364
+ AND projection_run_id = ${input.execution.projectionRunId}
365
+ AND status IN ('staging', 'ready')
366
+ FOR UPDATE
367
+ `
368
+ if (
369
+ candidates.some((candidate) => candidate.execution_token === input.execution.executionToken)
370
+ ) {
371
+ throw sourceConflict(
372
+ `Projection run '${input.execution.projectionRunId}' already has a source materialization owned by the current execution.`
373
+ )
374
+ }
375
+ if (candidates.length > 1) {
376
+ throw sourceConflict(
377
+ `Projection run '${input.execution.projectionRunId}' has multiple nonterminal source materializations.`
378
+ )
379
+ }
380
+ return candidates[0] ? this.transitionToAbandoned(sql, candidates[0], input.abandonedAt) : null
381
+ }
382
+
383
+ private async transitionToAbandoned(
384
+ sql: SQLClient,
385
+ manifest: PgOntologySourceRow,
386
+ abandonedAt: string
387
+ ): Promise<OntologySourceRecord> {
388
+ if (
389
+ abandonedAt < toIsoString(manifest.created_at) ||
390
+ (manifest.ready_at !== null && abandonedAt < toIsoString(manifest.ready_at))
391
+ ) {
392
+ throw new MaterializationValidationError(
393
+ "Source abandonedAt cannot precede source creation or readiness."
394
+ )
395
+ }
396
+ const rows = await sql<PgOntologySourceRow[]>`
397
+ UPDATE ontology_sources
398
+ SET status = 'abandoned', execution_token = NULL,
399
+ terminal_at = ${abandonedAt}, updated_at = ${abandonedAt}
400
+ WHERE project_id = ${manifest.project_id}
401
+ AND source_id = ${manifest.source_id}
402
+ AND materialization_id = ${manifest.materialization_id}
403
+ AND status IN ('staging', 'ready')
404
+ RETURNING *
405
+ `
406
+ if (!rows[0]) {
407
+ throw sourceConflict(
408
+ `Source materialization '${manifest.materialization_id}' cannot transition to 'abandoned'.`
409
+ )
410
+ }
411
+ return sourceRecord(rows[0])
412
+ }
413
+
414
+ private async assertReadyState(
415
+ sql: SQLClient,
416
+ manifest: PgOntologySourceRow,
417
+ rootCount: number,
418
+ assertionCount: number
419
+ ): Promise<void> {
420
+ const [counts] = await sql<
421
+ {
422
+ readonly assertions: number | string
423
+ readonly roots: number | string
424
+ readonly ordinals: number | string
425
+ readonly min_ordinal: number | string | null
426
+ readonly max_ordinal: number | string | null
427
+ }[]
428
+ >`
429
+ SELECT COUNT(*) AS assertions, COUNT(DISTINCT root_key) AS roots,
430
+ COUNT(DISTINCT staging_ordinal) AS ordinals,
431
+ MIN(staging_ordinal) AS min_ordinal, MAX(staging_ordinal) AS max_ordinal
432
+ FROM ontology_source_rows
433
+ WHERE project_id = ${manifest.project_id}
434
+ AND source_id = ${manifest.source_id}
435
+ AND materialization_id = ${manifest.materialization_id}
436
+ `
437
+ const assertions = Number(counts?.assertions ?? 0)
438
+ const roots = Number(counts?.roots ?? 0)
439
+ const ordinals = Number(counts?.ordinals ?? 0)
440
+ const minimum = counts?.min_ordinal === null ? null : Number(counts?.min_ordinal)
441
+ const maximum = counts?.max_ordinal === null ? null : Number(counts?.max_ordinal)
442
+ if (
443
+ assertions !== assertionCount ||
444
+ roots !== rootCount ||
445
+ ordinals !== rootCount ||
446
+ (rootCount > 0 && (minimum !== 0 || maximum !== rootCount - 1))
447
+ ) {
448
+ throw new MaterializationValidationError(
449
+ "Source ready counts do not match the staged roots and assertions."
450
+ )
451
+ }
452
+
453
+ const [invalid] =
454
+ manifest.projection_kind === "link"
455
+ ? await sql<{ readonly root_key: unknown }[]>`
456
+ SELECT root_key
457
+ FROM ontology_source_rows
458
+ WHERE project_id = ${manifest.project_id}
459
+ AND source_id = ${manifest.source_id}
460
+ AND materialization_id = ${manifest.materialization_id}
461
+ GROUP BY root_key
462
+ HAVING COUNT(*) <> 1
463
+ OR BOOL_OR(root_kind <> 'link')
464
+ OR BOOL_OR(entity_kind <> 'link')
465
+ OR BOOL_OR(root_key <> entity_key)
466
+ LIMIT 1
467
+ `
468
+ : await sql<{ readonly root_key: unknown }[]>`
469
+ SELECT root_key
470
+ FROM ontology_source_rows
471
+ WHERE project_id = ${manifest.project_id}
472
+ AND source_id = ${manifest.source_id}
473
+ AND materialization_id = ${manifest.materialization_id}
474
+ GROUP BY root_key
475
+ HAVING BOOL_OR(root_kind <> 'object')
476
+ OR COUNT(*) FILTER (
477
+ WHERE entity_kind = 'object' AND entity_key = root_key
478
+ ) <> 1
479
+ OR COUNT(*) FILTER (
480
+ WHERE (entity_kind = 'object' AND entity_key <> root_key)
481
+ OR (entity_kind = 'link' AND (
482
+ source_type_id <> root_object_type_id
483
+ OR source_primary_id <> root_primary_id
484
+ ))
485
+ ) <> 0
486
+ LIMIT 1
487
+ `
488
+ if (invalid) {
489
+ throw new MaterializationValidationError(
490
+ manifest.projection_kind === "link"
491
+ ? "Link projection roots must contain exactly their matching link assertion."
492
+ : "Object projection roots must contain exactly their matching object assertion plus links sourced from that root."
493
+ )
494
+ }
495
+ }
496
+
497
+ private async findStageRows(
498
+ sql: SQLClient,
499
+ input: StageSourceRowsInput,
500
+ rows: readonly SourceStageRow[]
501
+ ): Promise<readonly SourceStageRow[]> {
502
+ if (rows.length === 0) return []
503
+ const rootSortKeys = [...new Set(rows.map((row) => utf8SortKey(row.rootKey)))]
504
+ const ordinals = [...new Set(rows.map((row) => row.row.stagingOrdinal))]
505
+ const entitySortKeys = [...new Set(rows.map((row) => utf8SortKey(row.entityKey)))]
506
+ const existing = await sql<PgOntologySourceAssertionRow[]>`
507
+ SELECT * FROM ontology_source_rows
508
+ WHERE project_id = ${input.projectId}
509
+ AND source_id = ${input.source.projectionId}
510
+ AND materialization_id = ${input.materializationId}
511
+ AND (
512
+ root_sort_key = ANY(${sql.array(rootSortKeys)}::text[])
513
+ OR staging_ordinal = ANY(${sql.array(ordinals)}::bigint[])
514
+ OR entity_sort_key = ANY(${sql.array(entitySortKeys)}::text[])
515
+ )
516
+ `
517
+ return existing.map((row) => sourceStageRow(sourceAssertion(row)))
518
+ }
519
+
520
+ private async insertStageRows(
521
+ sql: SQLClient,
522
+ input: StageSourceRowsInput,
523
+ rows: readonly SourceStageRow[]
524
+ ): Promise<void> {
525
+ if (rows.length === 0) return
526
+ const payload = rows.map(({ entityKey, rootKey, row }) => {
527
+ const entity = entityColumns(row.assertion)
528
+ const root = entityColumns(row.root)
529
+ return {
530
+ entityKind: row.assertion.kind,
531
+ entityKey: JSON.parse(entityKey) as unknown,
532
+ entitySortKey: utf8SortKey(entityKey),
533
+ rootKind: row.root.kind,
534
+ rootKey: JSON.parse(rootKey) as unknown,
535
+ rootSortKey: utf8SortKey(rootKey),
536
+ stagingOrdinal: row.stagingOrdinal,
537
+ root: row.root,
538
+ assertion: row.assertion,
539
+ objectTypeId: entity.objectTypeId,
540
+ primaryId: entity.primaryId,
541
+ sourceTypeId: entity.sourceTypeId,
542
+ sourcePrimaryId: entity.sourcePrimaryId,
543
+ linkId: entity.linkId,
544
+ targetTypeId: entity.targetTypeId,
545
+ targetPrimaryId: entity.targetPrimaryId,
546
+ rootObjectTypeId: root.objectTypeId,
547
+ rootPrimaryId: root.primaryId,
548
+ rootSourceTypeId: root.sourceTypeId,
549
+ rootSourcePrimaryId: root.sourcePrimaryId,
550
+ rootLinkId: root.linkId,
551
+ rootTargetTypeId: root.targetTypeId,
552
+ rootTargetPrimaryId: root.targetPrimaryId,
553
+ }
554
+ })
555
+ await sql`
556
+ WITH staged AS (
557
+ SELECT value
558
+ FROM jsonb_array_elements(${jsonParameter(sql, payload)}::jsonb)
559
+ )
560
+ INSERT INTO ontology_source_rows (
561
+ project_id, source_id, materialization_id,
562
+ entity_kind, entity_key, entity_sort_key,
563
+ root_kind, root_key, root_sort_key, staging_ordinal,
564
+ root, assertion,
565
+ object_type_id, primary_id,
566
+ source_type_id, source_primary_id, link_id, target_type_id, target_primary_id,
567
+ root_object_type_id, root_primary_id,
568
+ root_source_type_id, root_source_primary_id, root_link_id,
569
+ root_target_type_id, root_target_primary_id
570
+ )
571
+ SELECT
572
+ ${input.projectId}, ${input.source.projectionId}, ${input.materializationId},
573
+ value->>'entityKind', value->'entityKey', value->>'entitySortKey',
574
+ value->>'rootKind', value->'rootKey', value->>'rootSortKey',
575
+ (value->>'stagingOrdinal')::bigint,
576
+ value->'root', value->'assertion',
577
+ value->>'objectTypeId', value->>'primaryId',
578
+ value->>'sourceTypeId', value->>'sourcePrimaryId', value->>'linkId',
579
+ value->>'targetTypeId', value->>'targetPrimaryId',
580
+ value->>'rootObjectTypeId', value->>'rootPrimaryId',
581
+ value->>'rootSourceTypeId', value->>'rootSourcePrimaryId', value->>'rootLinkId',
582
+ value->>'rootTargetTypeId', value->>'rootTargetPrimaryId'
583
+ FROM staged
584
+ `
585
+ }
586
+
587
+ private assertExecution(
588
+ sql: SQLClient,
589
+ input: {
590
+ readonly projectId: string
591
+ readonly source: { readonly projectionId: string }
592
+ readonly execution: { readonly projectionRunId: string; readonly executionToken: string }
593
+ },
594
+ identity?: AssertSourceMaterializationExecutionInput["identity"]
595
+ ): Promise<void> {
596
+ return assertProjectionExecution(sql, {
597
+ projectId: input.projectId,
598
+ sourceId: input.source.projectionId,
599
+ projectionRunId: input.execution.projectionRunId,
600
+ executionToken: input.execution.executionToken,
601
+ ...(identity ? { identity } : {}),
602
+ })
603
+ }
604
+
605
+ private async getRunCandidate(
606
+ sql: SQLClient,
607
+ input: BeginSourceMaterializationInput
608
+ ): Promise<PgOntologySourceRow | null> {
609
+ const [row] = await sql<PgOntologySourceRow[]>`
610
+ SELECT * FROM ontology_sources
611
+ WHERE project_id = ${input.projectId}
612
+ AND projection_run_id = ${input.execution.projectionRunId}
613
+ AND status IN ('staging', 'ready')
614
+ LIMIT 1
615
+ FOR UPDATE
616
+ `
617
+ return row ?? null
618
+ }
619
+
620
+ private async getManifest(
621
+ sql: SQLClient,
622
+ projectId: string,
623
+ sourceId: string,
624
+ materializationId: string,
625
+ lock = false
626
+ ): Promise<PgOntologySourceRow | null> {
627
+ const lockFragment = lock ? sql`FOR UPDATE` : sql``
628
+ const [row] = await sql<PgOntologySourceRow[]>`
629
+ SELECT * FROM ontology_sources
630
+ WHERE project_id = ${projectId}
631
+ AND source_id = ${sourceId}
632
+ AND materialization_id = ${materializationId}
633
+ ${lockFragment}
634
+ `
635
+ return row ?? null
636
+ }
637
+
638
+ private async requireManifest(
639
+ sql: SQLClient,
640
+ projectId: string,
641
+ sourceId: string,
642
+ materializationId: string,
643
+ lock = false
644
+ ): Promise<PgOntologySourceRow> {
645
+ const row = await this.getManifest(sql, projectId, sourceId, materializationId, lock)
646
+ if (!row) throw sourceConflict(`Source materialization '${materializationId}' does not exist.`)
647
+ return row
648
+ }
649
+ }
650
+
651
+ function toOptionalIsoString(value: Date | string | null | undefined): string | null {
652
+ if (value === null || value === undefined) return null
653
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString()
654
+ }
655
+
656
+ function numberOrNull(value: number | string | null): number | null {
657
+ return value === null ? null : Number(value)
658
+ }