@sixb/pg 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 (30) hide show
  1. package/dist/index.js +481 -227
  2. package/dist/index.js.map +13 -11
  3. package/dist/migrations.d.ts.map +1 -1
  4. package/dist/ontology-storage/materialization-session.d.ts +3 -0
  5. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  6. package/dist/ontology-storage/materialization-state.d.ts +2 -1
  7. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  8. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  9. package/dist/ontology-storage/shared.d.ts +2 -0
  10. package/dist/ontology-storage/shared.d.ts.map +1 -1
  11. package/dist/ontology-storage/source-cleanup.d.ts +4 -0
  12. package/dist/ontology-storage/source-cleanup.d.ts.map +1 -0
  13. package/dist/ontology-storage/source-roots.d.ts +24 -0
  14. package/dist/ontology-storage/source-roots.d.ts.map +1 -0
  15. package/dist/ontology-storage/sources.d.ts.map +1 -1
  16. package/dist/pg-projection-run-storage.d.ts.map +1 -1
  17. package/package.json +3 -3
  18. package/src/connector-connection-storage.ts +13 -3
  19. package/src/migrations/038-outbox-publication-order.sql +5 -0
  20. package/src/migrations/039-projection-source-roots.sql +46 -0
  21. package/src/migrations/040-connector-optional-pkce.sql +3 -0
  22. package/src/migrations.ts +13 -0
  23. package/src/ontology-storage/materialization-session.ts +24 -8
  24. package/src/ontology-storage/materialization-state.ts +65 -86
  25. package/src/ontology-storage/materializations.ts +39 -13
  26. package/src/ontology-storage/shared.ts +10 -0
  27. package/src/ontology-storage/source-cleanup.ts +61 -0
  28. package/src/ontology-storage/source-roots.ts +251 -0
  29. package/src/ontology-storage/sources.ts +21 -57
  30. package/src/pg-projection-run-storage.ts +8 -2
@@ -0,0 +1,251 @@
1
+ import type { ProjectionEntityRef } from "@sixb/core/internal/materialization"
2
+ import {
3
+ MaterializationConflictError,
4
+ MaterializationValidationError,
5
+ projectionEntityKey,
6
+ } from "@sixb/core/internal/materialization"
7
+ import { sourceStageRoots, utf8SortKey } from "@sixb/core/internal/ontology-storage-provider"
8
+ import type { SourceActivationWrite, StageSourceRowsInput } from "@sixb/core/storage"
9
+ import type { SQLClient } from "../pg-client"
10
+ import type { PgOntologySourceAssertionRow } from "./shared"
11
+ import { jsonParameter, type PgOntologySourceRow } from "./shared"
12
+
13
+ export function replacementSourceRows(
14
+ sql: SQLClient,
15
+ input: {
16
+ readonly projectId: string
17
+ readonly sourceId: string
18
+ readonly materializationId: string
19
+ readonly incremental: boolean
20
+ }
21
+ ) {
22
+ const roots = input.incremental
23
+ ? sql`
24
+ SELECT roots.* FROM ontology_source_roots AS changed
25
+ CROSS JOIN LATERAL (
26
+ SELECT roots.* FROM ontology_source_roots AS roots
27
+ WHERE roots.project_id = changed.project_id AND roots.source_id = changed.source_id
28
+ AND roots.root_sort_key = changed.root_sort_key AND roots.active OFFSET 0
29
+ ) AS roots
30
+ WHERE changed.project_id = ${input.projectId} AND changed.source_id = ${input.sourceId}
31
+ AND changed.materialization_id = ${input.materializationId}
32
+ `
33
+ : sql`SELECT * FROM ontology_source_roots WHERE project_id = ${input.projectId} AND source_id = ${input.sourceId} AND active`
34
+ return sql`
35
+ SELECT * FROM ontology_source_rows WHERE project_id = ${input.projectId}
36
+ AND source_id = ${input.sourceId} AND materialization_id = ${input.materializationId}
37
+ UNION ALL
38
+ SELECT rows.* FROM (${roots}) AS roots
39
+ CROSS JOIN LATERAL (
40
+ SELECT rows.* FROM ontology_source_rows AS rows WHERE rows.project_id = roots.project_id
41
+ AND rows.source_id = roots.source_id AND rows.materialization_id = roots.materialization_id
42
+ AND rows.root_sort_key = roots.root_sort_key OFFSET 0
43
+ ) AS rows
44
+ `
45
+ }
46
+
47
+ export async function stageSourceRoots(
48
+ sql: SQLClient,
49
+ manifest: PgOntologySourceRow,
50
+ input: StageSourceRowsInput
51
+ ): Promise<void> {
52
+ const roots = sourceStageRoots(manifest.projection_kind, input)
53
+ if (roots.length === 0) return
54
+ if (!manifest.base_materialization_id && roots.some((root) => root.deleted)) {
55
+ throw new MaterializationValidationError("Root deletions require a source delta base.")
56
+ }
57
+ const values = jsonParameter(
58
+ sql,
59
+ roots.map((root) => ({
60
+ root_sort_key: root.sortKey,
61
+ root_kind: root.root.kind,
62
+ root_key: JSON.parse(root.rootKey),
63
+ root: root.root,
64
+ staging_ordinal: root.stagingOrdinal,
65
+ deleted: root.deleted,
66
+ }))
67
+ )
68
+ const inserted = await sql<{ root_sort_key: string }[]>`
69
+ INSERT INTO ontology_source_roots (
70
+ project_id, source_id, materialization_id, root_sort_key, root_kind, root_key, root, staging_ordinal, deleted
71
+ ) SELECT ${input.projectId}, ${input.source.projectionId}, ${input.materializationId},
72
+ root_sort_key, root_kind, root_key, root, staging_ordinal, deleted
73
+ FROM jsonb_to_recordset(${values}) AS input(
74
+ root_sort_key TEXT, root_kind TEXT, root_key JSONB, root JSONB, staging_ordinal BIGINT, deleted BOOLEAN)
75
+ ON CONFLICT DO NOTHING RETURNING root_sort_key
76
+ `
77
+ if (inserted.length === roots.length) return
78
+ const insertedKeys = new Set(inserted.map((row) => row.root_sort_key))
79
+ const skipped = roots.filter((root) => !insertedKeys.has(root.sortKey))
80
+ const stored = await sql<
81
+ { root_sort_key: string; staging_ordinal: string | number; deleted: boolean }[]
82
+ >`
83
+ SELECT root_sort_key, staging_ordinal, deleted FROM ontology_source_roots
84
+ WHERE project_id = ${input.projectId} AND source_id = ${input.source.projectionId}
85
+ AND materialization_id = ${input.materializationId}
86
+ AND root_sort_key = ANY(${sql.array(skipped.map((root) => root.sortKey))}::text[])
87
+ `
88
+ const existing = new Map(stored.map((root) => [root.root_sort_key, root]))
89
+ if (
90
+ skipped.some((root) => {
91
+ const previous = existing.get(root.sortKey)
92
+ return (
93
+ !previous ||
94
+ Number(previous.staging_ordinal) !== root.stagingOrdinal ||
95
+ previous.deleted !== root.deleted
96
+ )
97
+ })
98
+ )
99
+ throw new MaterializationValidationError(
100
+ "Source materialization repeats root or stream ordinal with different content."
101
+ )
102
+ }
103
+
104
+ export async function activateSourceRoots(
105
+ sql: SQLClient,
106
+ projectId: string,
107
+ candidate: PgOntologySourceRow,
108
+ activation: SourceActivationWrite
109
+ ): Promise<void> {
110
+ if (
111
+ candidate.base_materialization_id !== null &&
112
+ (candidate.base_materialization_id !== activation.expected.activeMaterializationId ||
113
+ candidate.base_commit_id !== activation.expected.lastCommitId)
114
+ )
115
+ throw new MaterializationConflictError(
116
+ "projection-fence",
117
+ "Source delta base changed before activation."
118
+ )
119
+ if (candidate.base_materialization_id !== null && Number(candidate.root_count) === 0) return
120
+ const affected =
121
+ candidate.base_materialization_id === null
122
+ ? sql``
123
+ : sql`AND root_sort_key IN (
124
+ SELECT root_sort_key FROM ontology_source_roots
125
+ WHERE project_id = ${projectId} AND source_id = ${activation.source.projectionId}
126
+ AND materialization_id = ${activation.materializationId}
127
+ )`
128
+ await sql`
129
+ UPDATE ontology_source_roots AS roots SET active = FALSE, retired_at = ${activation.updatedAt}
130
+ WHERE project_id = ${projectId} AND source_id = ${activation.source.projectionId} AND active
131
+ ${affected}
132
+ `
133
+ await sql`
134
+ UPDATE ontology_source_roots SET active = NOT deleted,
135
+ retired_at = CASE WHEN deleted THEN ${activation.updatedAt}::timestamptz ELSE NULL END
136
+ WHERE project_id = ${projectId} AND source_id = ${activation.source.projectionId}
137
+ AND materialization_id = ${activation.materializationId}
138
+ `
139
+ }
140
+
141
+ export async function assertSourceRootCoverage(
142
+ sql: SQLClient,
143
+ manifest: PgOntologySourceRow
144
+ ): Promise<void> {
145
+ const [invalid] = await sql`
146
+ SELECT 1 FROM ontology_source_roots AS roots
147
+ WHERE project_id = ${manifest.project_id} AND source_id = ${manifest.source_id}
148
+ AND materialization_id = ${manifest.materialization_id}
149
+ AND roots.deleted = EXISTS (
150
+ SELECT 1 FROM ontology_source_rows AS rows
151
+ WHERE rows.project_id = roots.project_id AND rows.source_id = roots.source_id
152
+ AND rows.materialization_id = roots.materialization_id AND rows.root_sort_key = roots.root_sort_key
153
+ ) LIMIT 1
154
+ `
155
+ if (invalid)
156
+ throw new MaterializationValidationError(
157
+ "Source roots must have complete assertions or an explicit deletion, never both."
158
+ )
159
+ }
160
+
161
+ export function activeSourceRows(
162
+ sql: SQLClient,
163
+ projectId: string,
164
+ refs: readonly ProjectionEntityRef[]
165
+ ) {
166
+ const keys = refs.flatMap((entity) => {
167
+ const entitySortKey = utf8SortKey(projectionEntityKey(entity))
168
+ const roots =
169
+ entity.kind === "object"
170
+ ? [entitySortKey]
171
+ : [
172
+ entitySortKey,
173
+ utf8SortKey(projectionEntityKey({ kind: "object", ref: entity.ref.source })),
174
+ ]
175
+ return roots.map((rootSortKey) => ({
176
+ root_sort_key: rootSortKey,
177
+ entity_sort_key: entitySortKey,
178
+ }))
179
+ })
180
+ return sql<PgOntologySourceAssertionRow[]>`
181
+ SELECT rows.* FROM jsonb_to_recordset(${jsonParameter(sql, keys)}) AS requested(root_sort_key TEXT, entity_sort_key TEXT)
182
+ JOIN ontology_source_roots AS roots ON roots.project_id = ${projectId}
183
+ AND roots.active AND roots.root_sort_key = requested.root_sort_key
184
+ JOIN ontology_source_rows AS rows ON rows.project_id = roots.project_id AND rows.source_id = roots.source_id
185
+ AND rows.materialization_id = roots.materialization_id AND rows.entity_sort_key = requested.entity_sort_key
186
+ AND rows.root_sort_key = roots.root_sort_key
187
+ `
188
+ }
189
+
190
+ export function replacementAssertionRows(
191
+ sql: SQLClient,
192
+ input: {
193
+ projectId: string
194
+ sourceId: string
195
+ materializationId: string
196
+ incremental: boolean
197
+ includePrevious: boolean
198
+ refs: readonly ProjectionEntityRef[]
199
+ }
200
+ ) {
201
+ const keys = input.refs.map((ref) => ({
202
+ entity_kind: ref.kind,
203
+ entity_key: JSON.parse(projectionEntityKey(ref)),
204
+ root_keys:
205
+ ref.kind === "object"
206
+ ? [utf8SortKey(projectionEntityKey(ref))]
207
+ : [
208
+ utf8SortKey(projectionEntityKey(ref)),
209
+ utf8SortKey(projectionEntityKey({ kind: "object", ref: ref.ref.source })),
210
+ ],
211
+ }))
212
+ const previous = !input.includePrevious
213
+ ? sql``
214
+ : sql`UNION ALL
215
+ SELECT rows.* FROM (
216
+ SELECT roots.* FROM ontology_source_roots AS roots
217
+ WHERE roots.project_id = ${input.projectId} AND roots.source_id = ${input.sourceId}
218
+ AND roots.active AND roots.root_sort_key = ANY(requested.root_keys)
219
+ ${
220
+ !input.incremental
221
+ ? sql``
222
+ : sql`AND EXISTS (SELECT 1 FROM ontology_source_roots AS changed
223
+ WHERE changed.project_id = roots.project_id AND changed.source_id = roots.source_id
224
+ AND changed.materialization_id = ${input.materializationId} AND changed.root_sort_key = roots.root_sort_key)`
225
+ }
226
+
227
+ OFFSET 0
228
+ ) AS roots
229
+ CROSS JOIN LATERAL (
230
+ SELECT rows.* FROM ontology_source_rows AS rows
231
+ WHERE rows.project_id = roots.project_id AND rows.source_id = roots.source_id
232
+ AND rows.materialization_id = roots.materialization_id AND rows.root_sort_key = roots.root_sort_key
233
+ AND rows.entity_kind = requested.entity_kind AND rows.entity_key = requested.entity_key
234
+ OFFSET 0
235
+ ) AS rows
236
+ `
237
+ // Keep each JSON request as an indexed identity lookup even with fresh/stale statistics.
238
+ // Flattening this LATERAL caused a cold 10k-root run to spend seconds on each state page.
239
+ return sql<PgOntologySourceAssertionRow[]>`
240
+ SELECT selected.* FROM jsonb_to_recordset(${jsonParameter(sql, keys)})
241
+ AS requested(entity_kind TEXT, entity_key JSONB, root_keys TEXT[])
242
+ CROSS JOIN LATERAL (
243
+ SELECT rows.* FROM ontology_source_rows AS rows
244
+ WHERE rows.project_id = ${input.projectId} AND rows.source_id = ${input.sourceId}
245
+ AND rows.materialization_id = ${input.materializationId}
246
+ AND rows.entity_kind = requested.entity_kind AND rows.entity_key = requested.entity_key
247
+ ${previous}
248
+ OFFSET 0
249
+ ) AS selected
250
+ `
251
+ }
@@ -49,6 +49,8 @@ import {
49
49
  sourceRecord,
50
50
  toIsoString,
51
51
  } from "./shared"
52
+ import { cleanupSourceVersions } from "./source-cleanup"
53
+ import { assertSourceRootCoverage, stageSourceRoots } from "./source-roots"
52
54
 
53
55
  export class PgOntologySourceStorage implements OntologySourceStorage {
54
56
  constructor(private readonly runRootOperation: PgRootOperation) {}
@@ -97,14 +99,15 @@ export class PgOntologySourceStorage implements OntologySourceStorage {
97
99
  dataset_id, dataset_version_id, dataset_version_created_at,
98
100
  projection_revision, ownership_hash, ontology_revision,
99
101
  root_count, assertion_count, created_at, ready_at, activated_at,
100
- terminal_at, last_commit_id, updated_at
102
+ terminal_at, last_commit_id, updated_at, base_materialization_id, base_commit_id
101
103
  ) VALUES (
102
104
  ${input.projectId}, ${input.source.projectionId}, ${input.materializationId},
103
105
  ${input.execution.projectionRunId}, ${input.projectionKind}, 'replacement',
104
106
  'staging', ${input.execution.executionToken}, ${input.datasetVersion.datasetId},
105
107
  ${input.datasetVersion.versionId}, ${input.datasetVersion.createdAt},
106
108
  ${input.projectionRevision}, ${input.ownershipHash}, ${input.ontologyRevision},
107
- NULL, NULL, ${input.createdAt}, NULL, NULL, NULL, NULL, ${input.createdAt}
109
+ NULL, NULL, ${input.createdAt}, NULL, NULL, NULL, NULL, ${input.createdAt},
110
+ ${input.base?.materializationId ?? null}, ${input.base?.lastCommitId ?? null}
108
111
  )
109
112
  ON CONFLICT DO NOTHING
110
113
  RETURNING *
@@ -153,6 +156,7 @@ export class PgOntologySourceStorage implements OntologySourceStorage {
153
156
  const rows = sourceStageRows(manifest.projection_kind, input.rows)
154
157
  const existing = await this.findStageRows(sql, input, rows)
155
158
  const { pending, unchanged } = reconcileSourceStageRows(rows, existing)
159
+ await stageSourceRoots(sql, manifest, input)
156
160
  await this.insertStageRows(sql, input, pending)
157
161
  return { inserted: pending.length, unchanged }
158
162
  })
@@ -193,6 +197,12 @@ export class PgOntologySourceStorage implements OntologySourceStorage {
193
197
  if (input.readyAt < toIsoString(manifest.created_at)) {
194
198
  throw new MaterializationValidationError("Source readyAt cannot precede source createdAt.")
195
199
  }
200
+ // A newly loaded run is absent from the planner statistics. Without refreshing them,
201
+ // PostgreSQL can scan the whole run for each identity instead of using its full key.
202
+ // Sparse deltas avoid this table-level sampling cost.
203
+ if (input.assertionCount >= 1_000 || input.rootCount >= 1_000) {
204
+ await sql`ANALYZE ontology_source_rows, ontology_source_roots`
205
+ }
196
206
  await this.assertReadyState(sql, manifest, input.rootCount, input.assertionCount)
197
207
  const rows = await sql<PgOntologySourceRow[]>`
198
208
  UPDATE ontology_sources
@@ -247,59 +257,7 @@ export class PgOntologySourceStorage implements OntologySourceStorage {
247
257
  assertNonblank(input.projectId, "Terminal source cleanup project id")
248
258
  assertTimestamp(input.terminalBefore, "Terminal source cleanup cutoff", true)
249
259
  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 }
260
+ return cleanupSourceVersions(sql, input)
303
261
  })
304
262
  }
305
263
 
@@ -408,6 +366,9 @@ export class PgOntologySourceStorage implements OntologySourceStorage {
408
366
  `Source materialization '${manifest.materialization_id}' cannot transition to 'abandoned'.`
409
367
  )
410
368
  }
369
+ await sql`UPDATE ontology_source_roots SET retired_at = ${abandonedAt}
370
+ WHERE project_id = ${manifest.project_id} AND source_id = ${manifest.source_id}
371
+ AND materialization_id = ${manifest.materialization_id} AND NOT active`
411
372
  return sourceRecord(rows[0])
412
373
  }
413
374
 
@@ -426,10 +387,12 @@ export class PgOntologySourceStorage implements OntologySourceStorage {
426
387
  readonly max_ordinal: number | string | null
427
388
  }[]
428
389
  >`
429
- SELECT COUNT(*) AS assertions, COUNT(DISTINCT root_key) AS roots,
390
+ SELECT (SELECT COUNT(*) FROM ontology_source_rows
391
+ WHERE project_id = ${manifest.project_id} AND source_id = ${manifest.source_id}
392
+ AND materialization_id = ${manifest.materialization_id}) AS assertions, COUNT(*) AS roots,
430
393
  COUNT(DISTINCT staging_ordinal) AS ordinals,
431
394
  MIN(staging_ordinal) AS min_ordinal, MAX(staging_ordinal) AS max_ordinal
432
- FROM ontology_source_rows
395
+ FROM ontology_source_roots
433
396
  WHERE project_id = ${manifest.project_id}
434
397
  AND source_id = ${manifest.source_id}
435
398
  AND materialization_id = ${manifest.materialization_id}
@@ -449,6 +412,7 @@ export class PgOntologySourceStorage implements OntologySourceStorage {
449
412
  "Source ready counts do not match the staged roots and assertions."
450
413
  )
451
414
  }
415
+ await assertSourceRootCoverage(sql, manifest)
452
416
 
453
417
  const [invalid] =
454
418
  manifest.projection_kind === "link"
@@ -102,7 +102,7 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
102
102
  input_exhausted = ${input.fixedBatchSize === undefined ? null : false},
103
103
  missing_target_object_type_id = ${null}, missing_target_object_id = ${null},
104
104
  missing_target_batch_ordinal = ${null}, missing_target_first_seen_at = ${null},
105
- source_rows_read = ${0}, source_rows_skipped = ${0}, error = ${null}
105
+ source_rows_read = ${0}, source_rows_skipped = ${0}, source_changes_read = ${null}, error = ${null}
106
106
  WHERE project_id = ${input.projectId} AND id = ${input.id}
107
107
  AND status = ${"failed"} AND error->>'code' = ${"queue.enqueue_failed"}
108
108
  RETURNING *
@@ -216,7 +216,8 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
216
216
  UPDATE projection_runs
217
217
  SET
218
218
  source_rows_read = ${progress.sourceRowsRead},
219
- source_rows_skipped = ${progress.sourceRowsSkipped}
219
+ source_rows_skipped = ${progress.sourceRowsSkipped},
220
+ source_changes_read = ${progress.sourceChangesRead ?? null}
220
221
  WHERE project_id = ${input.projectId}
221
222
  AND id = ${input.id}
222
223
  AND status = ${"running"}
@@ -241,6 +242,7 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
241
242
  execution_token = ${null},
242
243
  source_rows_read = ${plan.progress.sourceRowsRead},
243
244
  source_rows_skipped = ${plan.progress.sourceRowsSkipped},
245
+ source_changes_read = ${plan.progress.sourceChangesRead ?? null},
244
246
  input_exhausted = ${plan.inputExhausted ?? existingRow.input_exhausted},
245
247
  error = ${plan.error === undefined ? null : serializeSixbFailure(plan.error, PROJECTION_RUN_FAILURE_CODES)}::text::jsonb
246
248
  WHERE project_id = ${input.projectId}
@@ -529,6 +531,9 @@ function restoreProjectionRunRow(row: DatabaseRow): StoredProjectionRunRecord {
529
531
  progress: {
530
532
  sourceRowsRead: databaseSafeInteger(row.source_rows_read, "sourceRowsRead"),
531
533
  sourceRowsSkipped: databaseSafeInteger(row.source_rows_skipped, "sourceRowsSkipped"),
534
+ ...(row.source_changes_read == null
535
+ ? {}
536
+ : { sourceChangesRead: databaseSafeInteger(row.source_changes_read, "sourceChangesRead") }),
532
537
  },
533
538
  error: row.error ?? undefined,
534
539
  }
@@ -584,6 +589,7 @@ interface DatabaseRow {
584
589
  missing_target_first_seen_at: Date | string | null
585
590
  source_rows_read: number | string
586
591
  source_rows_skipped: number | string
592
+ source_changes_read: number | string | null
587
593
  error: JsonValue | null
588
594
  }
589
595