@sixb/sqlite 0.1.9 → 0.1.11

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 (29) hide show
  1. package/dist/index.js +449 -195
  2. package/dist/index.js.map +13 -11
  3. package/dist/migrations.d.ts.map +1 -1
  4. package/dist/ontology-storage/materialization-state.d.ts +2 -1
  5. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  6. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  7. package/dist/ontology-storage/outbox.d.ts.map +1 -1
  8. package/dist/ontology-storage/shared.d.ts +2 -0
  9. package/dist/ontology-storage/shared.d.ts.map +1 -1
  10. package/dist/ontology-storage/source-cleanup.d.ts +4 -0
  11. package/dist/ontology-storage/source-cleanup.d.ts.map +1 -0
  12. package/dist/ontology-storage/source-roots.d.ts +27 -0
  13. package/dist/ontology-storage/source-roots.d.ts.map +1 -0
  14. package/dist/ontology-storage/sources.d.ts.map +1 -1
  15. package/dist/projection-run-storage.d.ts.map +1 -1
  16. package/package.json +3 -3
  17. package/src/connector-connection-storage.ts +13 -3
  18. package/src/migrations/038-projection-source-roots.sql +44 -0
  19. package/src/migrations/039-connector-optional-pkce.sql +52 -0
  20. package/src/migrations/040-outbox-publication-order.sql +8 -0
  21. package/src/migrations.ts +13 -0
  22. package/src/ontology-storage/materialization-state.ts +56 -91
  23. package/src/ontology-storage/materializations.ts +7 -1
  24. package/src/ontology-storage/outbox.ts +33 -16
  25. package/src/ontology-storage/shared.ts +10 -0
  26. package/src/ontology-storage/source-cleanup.ts +59 -0
  27. package/src/ontology-storage/source-roots.ts +230 -0
  28. package/src/ontology-storage/sources.ts +25 -78
  29. package/src/projection-run-storage.ts +11 -3
@@ -65,6 +65,7 @@ import {
65
65
  type SqliteOntologyCommitRow,
66
66
  type SqliteOntologySourceRow,
67
67
  } from "./shared"
68
+ import { activateSourceRoots } from "./source-roots"
68
69
 
69
70
  export class SqliteOntologyMaterializationStorage implements OntologyMaterializationStorage {
70
71
  private readonly sessions: SqliteMaterializationSessions
@@ -192,6 +193,7 @@ export class SqliteOntologyMaterializationStorage implements OntologyMaterializa
192
193
  sourceId: replacement.sourceId,
193
194
  candidateMaterializationId: replacement.candidateMaterializationId,
194
195
  previousMaterializationId: replacement.previousMaterializationId,
196
+ incremental: replacement.incremental,
195
197
  kind: "object",
196
198
  pageRows: input.pageRows,
197
199
  })) {
@@ -236,6 +238,7 @@ export class SqliteOntologyMaterializationStorage implements OntologyMaterializa
236
238
  sourceId: replacement.sourceId,
237
239
  candidateMaterializationId: replacement.candidateMaterializationId,
238
240
  previousMaterializationId: replacement.previousMaterializationId,
241
+ incremental: replacement.incremental,
239
242
  kind: "link",
240
243
  pageRows: input.pageRows,
241
244
  })) {
@@ -250,7 +253,8 @@ export class SqliteOntologyMaterializationStorage implements OntologyMaterializa
250
253
  replacement.sourceId,
251
254
  replacement.candidateMaterializationId,
252
255
  materializationIds,
253
- linkIdentities
256
+ linkIdentities,
257
+ replacement.incremental
254
258
  )
255
259
  yield { objects: [], links }
256
260
  }
@@ -359,6 +363,7 @@ export class SqliteOntologyMaterializationStorage implements OntologyMaterializa
359
363
  sourceId: input.source.projectionId,
360
364
  candidateMaterializationId: input.candidateMaterializationId,
361
365
  previousMaterializationId: previous?.materialization_id ?? null,
366
+ incremental: candidate.base_materialization_id !== null,
362
367
  projectionKind: candidate.projection_kind,
363
368
  objectStreamStarted: false,
364
369
  objectStreamCompleted: false,
@@ -627,6 +632,7 @@ export class SqliteOntologyMaterializationStorage implements OntologyMaterializa
627
632
  invalidCorrelation("Source activation does not match its ready candidate identity.")
628
633
  }
629
634
  this.assertSource(activation.expected, commit.projectId)
635
+ activateSourceRoots(this.db, commit.projectId, candidate, activation)
630
636
  const previous = this.getActiveSource(commit.projectId, activation.source.projectionId)
631
637
  if (previous) {
632
638
  assertPinnedDatasetWatermark(
@@ -43,19 +43,37 @@ export class SqliteOntologyOutboxStorage implements OntologyOutboxStorage {
43
43
  )
44
44
  }
45
45
 
46
+ // Probe at most one page plus a sentinel through the eligibility index. Sparse retries
47
+ // avoid scanning delayed rows; overflowing backlogs use publication order without a sort.
48
+ const eligible = this.db
49
+ .query(
50
+ `SELECT CAST(rowid AS TEXT) AS rowId
51
+ FROM ontology_outbox INDEXED BY idx_ontology_outbox_claim
52
+ WHERE project_id = ? AND published_at IS NULL AND available_at <= ?
53
+ AND (lease_expires_at IS NULL OR lease_expires_at <= ?)
54
+ LIMIT ?`
55
+ )
56
+ .all(input.projectId, input.now, input.now, input.limit + 1) as { rowId: string }[]
57
+ if (eligible.length === 0) return []
58
+ const selected =
59
+ eligible.length <= input.limit
60
+ ? eligible
61
+ : (this.db
62
+ .query(
63
+ `SELECT CAST(rowid AS TEXT) AS rowId
64
+ FROM ontology_outbox INDEXED BY idx_ontology_outbox_publication_order
65
+ WHERE project_id = ? AND published_at IS NULL AND available_at <= ?
66
+ AND (lease_expires_at IS NULL OR lease_expires_at <= ?)
67
+ ORDER BY created_at, commit_id, commit_ordinal
68
+ LIMIT ?`
69
+ )
70
+ .all(input.projectId, input.now, input.now, input.limit) as { rowId: string }[])
46
71
  const rows = this.db
47
72
  .query(
48
73
  `
49
74
  UPDATE ontology_outbox
50
75
  SET attempts = attempts + 1, lease_id = ?, lease_expires_at = ?
51
- WHERE rowid IN (
52
- SELECT rowid
53
- FROM ontology_outbox
54
- WHERE project_id = ? AND published_at IS NULL AND available_at <= ?
55
- AND (lease_expires_at IS NULL OR lease_expires_at <= ?)
56
- ORDER BY created_at, commit_id, commit_ordinal
57
- LIMIT ?
58
- )
76
+ WHERE rowid IN (SELECT CAST(value AS INTEGER) FROM json_each(?))
59
77
  RETURNING envelope, available_at, attempts, lease_id, lease_expires_at,
60
78
  published_at, last_failure, created_at
61
79
  `
@@ -63,10 +81,7 @@ export class SqliteOntologyOutboxStorage implements OntologyOutboxStorage {
63
81
  .all(
64
82
  input.leaseId,
65
83
  input.leaseExpiresAt,
66
- input.projectId,
67
- input.now,
68
- input.now,
69
- input.limit
84
+ JSON.stringify(selected.map((row) => row.rowId))
70
85
  ) as SqliteOntologyOutboxRow[]
71
86
  return rows
72
87
  .map((row) => outboxRecord(row))
@@ -98,8 +113,9 @@ export class SqliteOntologyOutboxStorage implements OntologyOutboxStorage {
98
113
  WITH requested(id) AS MATERIALIZED (
99
114
  SELECT CAST(value AS TEXT) FROM json_each(?)
100
115
  ), leased(id) AS MATERIALIZED (
101
- SELECT outbox.id FROM ontology_outbox AS outbox
102
- JOIN requested USING (id)
116
+ -- Drive validation from the bounded ID list even before ANALYZE.
117
+ SELECT outbox.id FROM requested
118
+ CROSS JOIN ontology_outbox AS outbox USING (id)
103
119
  WHERE outbox.project_id = ? AND outbox.lease_id = ?
104
120
  AND outbox.lease_expires_at IS NOT NULL
105
121
  ), eligible(id) AS (
@@ -139,8 +155,9 @@ export class SqliteOntologyOutboxStorage implements OntologyOutboxStorage {
139
155
  WITH requested(id) AS MATERIALIZED (
140
156
  SELECT CAST(value AS TEXT) FROM json_each(?)
141
157
  ), leased(id) AS MATERIALIZED (
142
- SELECT outbox.id FROM ontology_outbox AS outbox
143
- JOIN requested USING (id)
158
+ -- Drive validation from the bounded ID list even before ANALYZE.
159
+ SELECT outbox.id FROM requested
160
+ CROSS JOIN ontology_outbox AS outbox USING (id)
144
161
  WHERE outbox.project_id = ? AND outbox.lease_id = ?
145
162
  AND outbox.lease_expires_at IS NOT NULL
146
163
  ), eligible(id) AS (
@@ -62,6 +62,8 @@ export interface SqliteOntologySourceRow {
62
62
  readonly ownership_hash: string
63
63
  readonly ontology_revision: string
64
64
  readonly root_count: number | null
65
+ readonly base_materialization_id: string | null
66
+ readonly base_commit_id: string | null
65
67
  readonly assertion_count: number | null
66
68
  readonly created_at: string
67
69
  readonly ready_at: string | null
@@ -125,6 +127,14 @@ export function sourceRecord(row: SqliteOntologySourceRow): OntologySourceRecord
125
127
  projectionRevision: row.projection_revision,
126
128
  ownershipHash: row.ownership_hash,
127
129
  ontologyRevision: row.ontology_revision,
130
+ ...(row.base_materialization_id === null
131
+ ? {}
132
+ : {
133
+ base: {
134
+ materializationId: row.base_materialization_id,
135
+ lastCommitId: row.base_commit_id!,
136
+ },
137
+ }),
128
138
  rootCount: row.root_count,
129
139
  assertionCount: row.assertion_count,
130
140
  createdAt: row.created_at,
@@ -0,0 +1,59 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import type {
3
+ CleanupTerminalSourceMaterializationsInput,
4
+ CleanupTerminalSourceMaterializationsResult,
5
+ } from "@sixb/core/storage"
6
+
7
+ export function cleanupSourceVersions(
8
+ db: Database,
9
+ input: CleanupTerminalSourceMaterializationsInput
10
+ ): CleanupTerminalSourceMaterializationsResult {
11
+ let remaining = input.limit
12
+ let rowsDeleted = db
13
+ .query(`DELETE FROM ontology_source_rows WHERE rowid IN (
14
+ SELECT rows.rowid FROM ontology_source_roots AS roots
15
+ JOIN ontology_sources AS sources USING (project_id, source_id, materialization_id)
16
+ CROSS JOIN ontology_source_rows AS rows
17
+ ON rows.project_id = roots.project_id AND rows.source_id = roots.source_id
18
+ AND rows.materialization_id = roots.materialization_id AND rows.root_sort_key = roots.root_sort_key
19
+ WHERE roots.project_id = ? AND roots.active = 0 AND roots.retired_at < ?
20
+ AND sources.status IN ('superseded', 'abandoned')
21
+ ORDER BY roots.retired_at, roots.source_id, roots.materialization_id, roots.root_sort_key, rows.entity_sort_key
22
+ LIMIT ?
23
+ )`)
24
+ .run(input.projectId, input.terminalBefore, remaining).changes
25
+ remaining -= rowsDeleted
26
+ if (remaining > 0) {
27
+ const removed = db
28
+ .query(`DELETE FROM ontology_source_roots WHERE rowid IN (
29
+ SELECT roots.rowid FROM ontology_source_roots AS roots
30
+ JOIN ontology_sources AS sources USING (project_id, source_id, materialization_id)
31
+ WHERE roots.project_id = ? AND roots.active = 0 AND roots.retired_at < ?
32
+ AND sources.status IN ('superseded', 'abandoned')
33
+ AND NOT EXISTS (SELECT 1 FROM ontology_source_rows AS rows
34
+ WHERE rows.project_id = roots.project_id AND rows.source_id = roots.source_id
35
+ AND rows.materialization_id = roots.materialization_id AND rows.root_sort_key = roots.root_sort_key)
36
+ ORDER BY roots.retired_at, roots.source_id, roots.materialization_id, roots.root_sort_key LIMIT ?
37
+ )`)
38
+ .run(input.projectId, input.terminalBefore, remaining).changes
39
+ rowsDeleted += removed
40
+ remaining -= removed
41
+ }
42
+ const materializationsDeleted =
43
+ remaining === 0
44
+ ? 0
45
+ : db
46
+ .query(`DELETE FROM ontology_sources WHERE rowid IN (
47
+ SELECT sources.rowid FROM ontology_sources AS sources
48
+ WHERE project_id = ? AND status IN ('superseded', 'abandoned') AND terminal_at < ?
49
+ AND NOT EXISTS (SELECT 1 FROM ontology_source_roots AS roots
50
+ WHERE roots.project_id = sources.project_id AND roots.source_id = sources.source_id
51
+ AND roots.materialization_id = sources.materialization_id)
52
+ AND NOT EXISTS (SELECT 1 FROM ontology_source_rows AS rows
53
+ WHERE rows.project_id = sources.project_id AND rows.source_id = sources.source_id
54
+ AND rows.materialization_id = sources.materialization_id)
55
+ ORDER BY terminal_at, source_id, materialization_id LIMIT ?
56
+ )`)
57
+ .run(input.projectId, input.terminalBefore, remaining).changes
58
+ return { rowsDeleted, materializationsDeleted }
59
+ }
@@ -0,0 +1,230 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import type { ProjectionEntityRef } from "@sixb/core/internal/materialization"
3
+ import {
4
+ MaterializationConflictError,
5
+ MaterializationValidationError,
6
+ projectionEntityKey,
7
+ } from "@sixb/core/internal/materialization"
8
+ import { sourceStageRoots, utf8SortKey } from "@sixb/core/internal/ontology-storage-provider"
9
+ import type { SourceActivationWrite, StageSourceRowsInput } from "@sixb/core/storage"
10
+ import type { SqliteOntologySourceAssertionRow } from "./shared"
11
+ import { canonicalJson, type SqliteOntologySourceRow } from "./shared"
12
+
13
+ export function replacementSourceRows(input: {
14
+ readonly projectId: string
15
+ readonly sourceId: string
16
+ readonly materializationId: string
17
+ readonly incremental: boolean
18
+ }): { readonly sql: string; readonly values: readonly string[] } {
19
+ const roots = input.incremental
20
+ ? `ontology_source_roots AS changed
21
+ CROSS JOIN ontology_source_roots AS roots ON roots.project_id = changed.project_id
22
+ AND roots.source_id = changed.source_id AND roots.root_sort_key = changed.root_sort_key AND roots.active = 1`
23
+ : "ontology_source_roots AS roots"
24
+ const selected = input.incremental ? "changed" : "roots"
25
+ return {
26
+ sql: `SELECT * FROM ontology_source_rows WHERE project_id = ? AND source_id = ? AND materialization_id = ?
27
+ UNION ALL
28
+ SELECT rows.* FROM ${roots}
29
+ CROSS JOIN ontology_source_rows AS rows ON rows.project_id = roots.project_id
30
+ AND rows.source_id = roots.source_id AND rows.materialization_id = roots.materialization_id
31
+ AND rows.root_sort_key = roots.root_sort_key
32
+ WHERE ${selected}.project_id = ? AND ${selected}.source_id = ?
33
+ AND ${input.incremental ? "changed.materialization_id = ?" : "roots.active = 1"}`,
34
+ values: [
35
+ input.projectId,
36
+ input.sourceId,
37
+ input.materializationId,
38
+ input.projectId,
39
+ input.sourceId,
40
+ ...(input.incremental ? [input.materializationId] : []),
41
+ ],
42
+ }
43
+ }
44
+
45
+ export function stageSourceRoots(
46
+ db: Database,
47
+ manifest: SqliteOntologySourceRow,
48
+ input: StageSourceRowsInput
49
+ ): void {
50
+ const roots = sourceStageRoots(manifest.projection_kind, input)
51
+ if (!manifest.base_materialization_id && roots.some((root) => root.deleted)) {
52
+ throw new MaterializationValidationError("Root deletions require a source delta base.")
53
+ }
54
+ const existing = db.query(`SELECT staging_ordinal, deleted FROM ontology_source_roots
55
+ WHERE project_id = ? AND source_id = ? AND materialization_id = ? AND root_sort_key = ?`)
56
+ const insert = db.query(`INSERT INTO ontology_source_roots (
57
+ project_id, source_id, materialization_id, root_sort_key, root_kind, root_key, root, staging_ordinal, deleted
58
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING RETURNING root_sort_key`)
59
+ for (const root of roots) {
60
+ const inserted = insert.get(
61
+ input.projectId,
62
+ input.source.projectionId,
63
+ input.materializationId,
64
+ root.sortKey,
65
+ root.root.kind,
66
+ root.rootKey,
67
+ canonicalJson(root.root),
68
+ root.stagingOrdinal,
69
+ Number(root.deleted)
70
+ )
71
+ if (inserted) continue
72
+ const previous = existing.get(
73
+ input.projectId,
74
+ input.source.projectionId,
75
+ input.materializationId,
76
+ root.sortKey
77
+ ) as { staging_ordinal: number; deleted: number } | null
78
+ if (
79
+ !previous ||
80
+ previous.staging_ordinal !== root.stagingOrdinal ||
81
+ Boolean(previous.deleted) !== root.deleted
82
+ ) {
83
+ throw new MaterializationValidationError(
84
+ "Source materialization repeats root or stream ordinal with different content."
85
+ )
86
+ }
87
+ }
88
+ }
89
+
90
+ export function activateSourceRoots(
91
+ db: Database,
92
+ projectId: string,
93
+ candidate: SqliteOntologySourceRow,
94
+ activation: SourceActivationWrite
95
+ ): void {
96
+ if (
97
+ candidate.base_materialization_id !== null &&
98
+ (candidate.base_materialization_id !== activation.expected.activeMaterializationId ||
99
+ candidate.base_commit_id !== activation.expected.lastCommitId)
100
+ )
101
+ throw new MaterializationConflictError(
102
+ "projection-fence",
103
+ "Source delta base changed before activation."
104
+ )
105
+
106
+ if (candidate.base_materialization_id !== null && candidate.root_count === 0) return
107
+ const scope = [projectId, activation.source.projectionId, activation.materializationId] as const
108
+ db.query(`UPDATE ontology_source_roots AS roots SET active = 0, retired_at = ?
109
+ WHERE project_id = ? AND source_id = ? AND active = 1
110
+ ${
111
+ candidate.base_materialization_id === null
112
+ ? ""
113
+ : `AND root_sort_key IN (
114
+ SELECT root_sort_key FROM ontology_source_roots
115
+ WHERE project_id = ? AND source_id = ? AND materialization_id = ?)`
116
+ }`).run(
117
+ activation.updatedAt,
118
+ projectId,
119
+ activation.source.projectionId,
120
+ ...(candidate.base_materialization_id === null ? [] : [...scope])
121
+ )
122
+ db.query(`UPDATE ontology_source_roots SET active = CASE WHEN deleted = 1 THEN 0 ELSE 1 END,
123
+ retired_at = CASE WHEN deleted = 1 THEN ? ELSE NULL END
124
+ WHERE project_id = ? AND source_id = ? AND materialization_id = ?`).run(
125
+ activation.updatedAt,
126
+ ...scope
127
+ )
128
+ }
129
+
130
+ export function assertSourceRootCoverage(db: Database, manifest: SqliteOntologySourceRow): void {
131
+ const invalid = db
132
+ .query(`SELECT 1 FROM ontology_source_roots AS roots
133
+ WHERE project_id = ? AND source_id = ? AND materialization_id = ?
134
+ AND roots.deleted = EXISTS (
135
+ SELECT 1 FROM ontology_source_rows AS rows
136
+ WHERE rows.project_id = roots.project_id AND rows.source_id = roots.source_id
137
+ AND rows.materialization_id = roots.materialization_id AND rows.root_sort_key = roots.root_sort_key
138
+ ) LIMIT 1`)
139
+ .get(manifest.project_id, manifest.source_id, manifest.materialization_id)
140
+ if (invalid)
141
+ throw new MaterializationValidationError(
142
+ "Source roots must have complete assertions or an explicit deletion, never both."
143
+ )
144
+ }
145
+
146
+ export function activeSourceRows(
147
+ db: Database,
148
+ projectId: string,
149
+ refs: readonly ProjectionEntityRef[]
150
+ ): SqliteOntologySourceAssertionRow[] {
151
+ const keys = refs.flatMap((entity) => {
152
+ const entitySortKey = utf8SortKey(projectionEntityKey(entity))
153
+ const roots =
154
+ entity.kind === "object"
155
+ ? [entitySortKey]
156
+ : [
157
+ entitySortKey,
158
+ utf8SortKey(projectionEntityKey({ kind: "object", ref: entity.ref.source })),
159
+ ]
160
+ return roots.map((rootSortKey) => ({ rootSortKey, entitySortKey }))
161
+ })
162
+ return db
163
+ .query(`WITH requested AS (
164
+ SELECT DISTINCT json_extract(value, '$.rootSortKey') AS root_sort_key,
165
+ json_extract(value, '$.entitySortKey') AS entity_sort_key FROM json_each(?)
166
+ ) SELECT rows.* FROM requested
167
+ CROSS JOIN ontology_source_roots AS roots ON roots.project_id = ?
168
+ AND roots.active = 1 AND roots.root_sort_key = requested.root_sort_key
169
+ CROSS JOIN ontology_source_rows AS rows ON rows.project_id = roots.project_id AND rows.source_id = roots.source_id
170
+ AND rows.materialization_id = roots.materialization_id AND rows.entity_sort_key = requested.entity_sort_key
171
+ AND rows.root_sort_key = roots.root_sort_key`)
172
+ .all(canonicalJson(keys), projectId) as SqliteOntologySourceAssertionRow[]
173
+ }
174
+
175
+ export function replacementAssertionRows(
176
+ db: Database,
177
+ input: {
178
+ projectId: string
179
+ sourceId: string
180
+ materializationId: string
181
+ incremental: boolean
182
+ includePrevious: boolean
183
+ refs: readonly ProjectionEntityRef[]
184
+ }
185
+ ): SqliteOntologySourceAssertionRow[] {
186
+ const keys = input.refs.map((ref) => ({
187
+ entityKind: ref.kind,
188
+ entityKey: projectionEntityKey(ref),
189
+ rootKeys:
190
+ ref.kind === "object"
191
+ ? [utf8SortKey(projectionEntityKey(ref))]
192
+ : [
193
+ utf8SortKey(projectionEntityKey(ref)),
194
+ utf8SortKey(projectionEntityKey({ kind: "object", ref: ref.ref.source })),
195
+ ],
196
+ }))
197
+ const previous = !input.includePrevious
198
+ ? ""
199
+ : `UNION ALL
200
+ SELECT rows.* FROM requested_entities AS requested
201
+ CROSS JOIN ontology_source_roots AS roots ON roots.project_id = ? AND roots.source_id = ?
202
+ AND roots.active = 1 AND roots.root_sort_key IN (SELECT value FROM json_each(requested.root_keys))
203
+ CROSS JOIN ontology_source_rows AS rows ON rows.project_id = roots.project_id AND rows.source_id = roots.source_id
204
+ AND rows.materialization_id = roots.materialization_id AND rows.root_sort_key = roots.root_sort_key
205
+ AND rows.entity_kind = requested.entity_kind AND rows.entity_key = requested.entity_key
206
+ ${
207
+ !input.incremental
208
+ ? ""
209
+ : `WHERE EXISTS (SELECT 1 FROM ontology_source_roots AS changed
210
+ WHERE changed.project_id = roots.project_id AND changed.source_id = roots.source_id
211
+ AND changed.materialization_id = ? AND changed.root_sort_key = roots.root_sort_key)`
212
+ }`
213
+ return db
214
+ .query(`WITH requested_entities AS (
215
+ SELECT DISTINCT json_extract(value, '$.entityKind') AS entity_kind,
216
+ json_extract(value, '$.entityKey') AS entity_key, json_extract(value, '$.rootKeys') AS root_keys FROM json_each(?)
217
+ ) SELECT rows.* FROM requested_entities AS requested
218
+ CROSS JOIN ontology_source_rows AS rows ON rows.project_id = ? AND rows.source_id = ?
219
+ AND rows.materialization_id = ? AND rows.entity_kind = requested.entity_kind AND rows.entity_key = requested.entity_key
220
+ ${previous}`)
221
+ .all(
222
+ canonicalJson(keys),
223
+ input.projectId,
224
+ input.sourceId,
225
+ input.materializationId,
226
+ ...(input.includePrevious
227
+ ? [input.projectId, input.sourceId, ...(input.incremental ? [input.materializationId] : [])]
228
+ : [])
229
+ ) as SqliteOntologySourceAssertionRow[]
230
+ }
@@ -47,6 +47,8 @@ import {
47
47
  sourceAssertion,
48
48
  sourceRecord,
49
49
  } from "./shared"
50
+ import { cleanupSourceVersions } from "./source-cleanup"
51
+ import { assertSourceRootCoverage, stageSourceRoots } from "./source-roots"
50
52
 
51
53
  export class SqliteOntologySourceStorage implements OntologySourceStorage {
52
54
  constructor(
@@ -98,9 +100,9 @@ export class SqliteOntologySourceStorage implements OntologySourceStorage {
98
100
  dataset_id, dataset_version_id, dataset_version_created_at,
99
101
  projection_revision, ownership_hash, ontology_revision,
100
102
  root_count, assertion_count, created_at, ready_at, activated_at,
101
- terminal_at, last_commit_id, updated_at
103
+ terminal_at, last_commit_id, updated_at, base_materialization_id, base_commit_id
102
104
  ) VALUES (?, ?, ?, ?, ?, 'replacement', 'staging', ?, ?, ?, ?, ?, ?, ?,
103
- NULL, NULL, ?, NULL, NULL, NULL, NULL, ?)
105
+ NULL, NULL, ?, NULL, NULL, NULL, NULL, ?, ?, ?)
104
106
  `
105
107
  )
106
108
  .run(
@@ -117,7 +119,9 @@ export class SqliteOntologySourceStorage implements OntologySourceStorage {
117
119
  input.ownershipHash,
118
120
  input.ontologyRevision,
119
121
  input.createdAt,
120
- input.createdAt
122
+ input.createdAt,
123
+ input.base?.materializationId ?? null,
124
+ input.base?.lastCommitId ?? null
121
125
  )
122
126
  return sourceRecord(
123
127
  this.requireManifest(input.projectId, input.source.projectionId, input.materializationId)
@@ -144,6 +148,7 @@ export class SqliteOntologySourceStorage implements OntologySourceStorage {
144
148
  const rows = sourceStageRows(manifest.projection_kind, input.rows)
145
149
  const existing = this.findStageRows(input, rows)
146
150
  const { pending, unchanged } = reconcileSourceStageRows(rows, existing)
151
+ stageSourceRoots(this.db, manifest, input)
147
152
  this.insertStageRows(input, pending)
148
153
  return { inserted: pending.length, unchanged }
149
154
  })
@@ -249,78 +254,7 @@ export class SqliteOntologySourceStorage implements OntologySourceStorage {
249
254
  assertNonblank(input.projectId, "Terminal source cleanup project id")
250
255
  assertTimestamp(input.terminalBefore, "Terminal source cleanup cutoff", true)
251
256
  assertPositiveInteger(input.limit, "Terminal source cleanup limit")
252
- const manifests = this.db
253
- .query(
254
- `
255
- SELECT * FROM ontology_sources
256
- WHERE project_id = ? AND status IN ('superseded', 'abandoned')
257
- AND terminal_at < ?
258
- ORDER BY terminal_at, source_id, materialization_id
259
- LIMIT ?
260
- `
261
- )
262
- .all(input.projectId, input.terminalBefore, input.limit) as SqliteOntologySourceRow[]
263
- let remaining = input.limit
264
- let rowsDeleted = 0
265
- let materializationsDeleted = 0
266
- for (const manifest of manifests) {
267
- if (remaining === 0) break
268
- const deleted = this.db
269
- .query(
270
- `
271
- DELETE FROM ontology_source_rows
272
- WHERE rowid IN (
273
- SELECT rows.rowid
274
- FROM ontology_source_rows AS rows
275
- JOIN ontology_sources AS sources
276
- ON sources.project_id = rows.project_id
277
- AND sources.source_id = rows.source_id
278
- AND sources.materialization_id = rows.materialization_id
279
- WHERE rows.project_id = ? AND rows.source_id = ? AND rows.materialization_id = ?
280
- AND sources.status IN ('superseded', 'abandoned')
281
- AND sources.terminal_at = ? AND sources.terminal_at < ?
282
- ORDER BY rows.entity_sort_key
283
- LIMIT ?
284
- )
285
- `
286
- )
287
- .run(
288
- manifest.project_id,
289
- manifest.source_id,
290
- manifest.materialization_id,
291
- manifest.terminal_at,
292
- input.terminalBefore,
293
- remaining
294
- ).changes
295
- rowsDeleted += deleted
296
- remaining -= deleted
297
- if (remaining === 0) break
298
- const removed = this.db
299
- .query(
300
- `
301
- DELETE FROM ontology_sources
302
- WHERE project_id = ? AND source_id = ? AND materialization_id = ?
303
- AND status IN ('superseded', 'abandoned')
304
- AND terminal_at = ? AND terminal_at < ?
305
- AND NOT EXISTS (
306
- SELECT 1 FROM ontology_source_rows AS rows
307
- WHERE rows.project_id = ontology_sources.project_id
308
- AND rows.source_id = ontology_sources.source_id
309
- AND rows.materialization_id = ontology_sources.materialization_id
310
- )
311
- `
312
- )
313
- .run(
314
- manifest.project_id,
315
- manifest.source_id,
316
- manifest.materialization_id,
317
- manifest.terminal_at,
318
- input.terminalBefore
319
- ).changes
320
- materializationsDeleted += removed
321
- remaining -= removed
322
- }
323
- return { rowsDeleted, materializationsDeleted }
257
+ return cleanupSourceVersions(this.db, input)
324
258
  })
325
259
  }
326
260
 
@@ -433,6 +367,10 @@ export class SqliteOntologySourceStorage implements OntologySourceStorage {
433
367
  `Source materialization '${manifest.materialization_id}' cannot transition to 'abandoned'.`
434
368
  )
435
369
  }
370
+ this.db
371
+ .query(`UPDATE ontology_source_roots SET retired_at = ?
372
+ WHERE project_id = ? AND source_id = ? AND materialization_id = ? AND active = 0`)
373
+ .run(abandonedAt, manifest.project_id, manifest.source_id, manifest.materialization_id)
436
374
  return sourceRecord(
437
375
  this.requireManifest(manifest.project_id, manifest.source_id, manifest.materialization_id)
438
376
  )
@@ -446,14 +384,22 @@ export class SqliteOntologySourceStorage implements OntologySourceStorage {
446
384
  const counts = this.db
447
385
  .query(
448
386
  `
449
- SELECT COUNT(*) AS assertions, COUNT(DISTINCT root_key) AS roots,
387
+ SELECT (SELECT COUNT(*) FROM ontology_source_rows
388
+ WHERE project_id = ? AND source_id = ? AND materialization_id = ?) AS assertions, COUNT(*) AS roots,
450
389
  COUNT(DISTINCT staging_ordinal) AS ordinals,
451
390
  MIN(staging_ordinal) AS min_ordinal, MAX(staging_ordinal) AS max_ordinal
452
- FROM ontology_source_rows
391
+ FROM ontology_source_roots
453
392
  WHERE project_id = ? AND source_id = ? AND materialization_id = ?
454
393
  `
455
394
  )
456
- .get(manifest.project_id, manifest.source_id, manifest.materialization_id) as {
395
+ .get(
396
+ manifest.project_id,
397
+ manifest.source_id,
398
+ manifest.materialization_id,
399
+ manifest.project_id,
400
+ manifest.source_id,
401
+ manifest.materialization_id
402
+ ) as {
457
403
  assertions: number
458
404
  roots: number
459
405
  ordinals: number
@@ -470,6 +416,7 @@ export class SqliteOntologySourceStorage implements OntologySourceStorage {
470
416
  "Source ready counts do not match the staged roots and assertions."
471
417
  )
472
418
  }
419
+ assertSourceRootCoverage(this.db, manifest)
473
420
  const invalid = this.db
474
421
  .query(
475
422
  manifest.projection_kind === "link"
@@ -114,7 +114,7 @@ export class SqliteProjectionRunStorage implements ProjectionRunStorage {
114
114
  input_exhausted = ?, missing_target_object_type_id = NULL,
115
115
  missing_target_object_id = NULL, missing_target_batch_ordinal = NULL,
116
116
  missing_target_first_seen_at = NULL, source_rows_read = 0,
117
- source_rows_skipped = 0, error = NULL
117
+ source_rows_skipped = 0, source_changes_read = NULL, error = NULL
118
118
  WHERE project_id = ? AND id = ? AND status = 'failed'
119
119
  AND json_extract(error, '$.code') = 'queue.enqueue_failed'
120
120
  `
@@ -257,6 +257,7 @@ export class SqliteProjectionRunStorage implements ProjectionRunStorage {
257
257
  execution_token = NULL,
258
258
  source_rows_read = ?,
259
259
  source_rows_skipped = ?,
260
+ source_changes_read = ?,
260
261
  input_exhausted = ?,
261
262
  error = ?
262
263
  WHERE project_id = ? AND id = ? AND status = 'running' AND execution_token = ?
@@ -267,6 +268,7 @@ export class SqliteProjectionRunStorage implements ProjectionRunStorage {
267
268
  plan.finishedAt.toISOString(),
268
269
  plan.progress.sourceRowsRead,
269
270
  plan.progress.sourceRowsSkipped,
271
+ plan.progress.sourceChangesRead ?? null,
270
272
  plan.inputExhausted ? 1 : existingRow.input_exhausted,
271
273
  plan.error === undefined
272
274
  ? null
@@ -531,9 +533,10 @@ export class SqliteProjectionRunStorage implements ProjectionRunStorage {
531
533
  progress: ProjectionRunProgress,
532
534
  executionToken: string
533
535
  ): number {
534
- const args: (string | number)[] = [
536
+ const args: (string | number | null)[] = [
535
537
  progress.sourceRowsRead,
536
538
  progress.sourceRowsSkipped,
539
+ progress.sourceChangesRead ?? null,
537
540
  existing.project_id,
538
541
  existing.id,
539
542
  executionToken,
@@ -544,7 +547,8 @@ export class SqliteProjectionRunStorage implements ProjectionRunStorage {
544
547
  UPDATE projection_runs
545
548
  SET
546
549
  source_rows_read = ?,
547
- source_rows_skipped = ?
550
+ source_rows_skipped = ?,
551
+ source_changes_read = ?
548
552
  WHERE project_id = ? AND id = ? AND status = 'running'
549
553
  AND execution_token = ?
550
554
  `
@@ -631,6 +635,9 @@ function restoreProjectionRunRow(row: DatabaseRow): StoredProjectionRunRecord {
631
635
  progress: {
632
636
  sourceRowsRead: databaseSafeInteger(row.source_rows_read, "sourceRowsRead"),
633
637
  sourceRowsSkipped: databaseSafeInteger(row.source_rows_skipped, "sourceRowsSkipped"),
638
+ ...(row.source_changes_read == null
639
+ ? {}
640
+ : { sourceChangesRead: databaseSafeInteger(row.source_changes_read, "sourceChangesRead") }),
634
641
  },
635
642
  error: row.error ?? undefined,
636
643
  }
@@ -694,6 +701,7 @@ interface DatabaseRow {
694
701
  missing_target_first_seen_at: string | null
695
702
  source_rows_read: number
696
703
  source_rows_skipped: number
704
+ source_changes_read: number | null
697
705
  error: string | null
698
706
  }
699
707