@sixb/ducklake 0.1.7 → 0.1.9

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 (72) hide show
  1. package/README.md +60 -5
  2. package/dist/ducklake-storage.d.ts +3 -1
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +1963 -480
  5. package/dist/index.js.map +25 -15
  6. package/dist/internal/dataset-row-commit.d.ts +10 -0
  7. package/dist/internal/dataset-row-commit.d.ts.map +1 -1
  8. package/dist/internal/dataset-row-merge.d.ts.map +1 -1
  9. package/dist/internal/ducklake-dataset-catalog.d.ts +20 -3
  10. package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -1
  11. package/dist/internal/ducklake-dataset-table-ref.d.ts +13 -0
  12. package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -1
  13. package/dist/internal/ducklake-maintenance.d.ts +21 -1
  14. package/dist/internal/ducklake-maintenance.d.ts.map +1 -1
  15. package/dist/internal/ducklake-snapshot-reader.d.ts +81 -9
  16. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
  17. package/dist/internal/ducklake-sql-executor.d.ts +9 -1
  18. package/dist/internal/ducklake-sql-executor.d.ts.map +1 -1
  19. package/dist/internal/ducklake-version-transaction.d.ts +54 -0
  20. package/dist/internal/ducklake-version-transaction.d.ts.map +1 -0
  21. package/dist/internal/ducklake-write-coordinator.d.ts +30 -3
  22. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
  23. package/dist/internal/lineage-encoding.d.ts +8 -0
  24. package/dist/internal/lineage-encoding.d.ts.map +1 -0
  25. package/dist/internal/sql-lineage/analysis.d.ts +32 -0
  26. package/dist/internal/sql-lineage/analysis.d.ts.map +1 -0
  27. package/dist/internal/sql-lineage/expressions.d.ts +9 -0
  28. package/dist/internal/sql-lineage/expressions.d.ts.map +1 -0
  29. package/dist/internal/sql-lineage/plan-json.d.ts +34 -0
  30. package/dist/internal/sql-lineage/plan-json.d.ts.map +1 -0
  31. package/dist/internal/sql-lineage/plan-relation.d.ts +28 -0
  32. package/dist/internal/sql-lineage/plan-relation.d.ts.map +1 -0
  33. package/dist/internal/sql-lineage/plan-walker.d.ts +18 -0
  34. package/dist/internal/sql-lineage/plan-walker.d.ts.map +1 -0
  35. package/dist/internal/sql-lineage/rejection.d.ts +11 -0
  36. package/dist/internal/sql-lineage/rejection.d.ts.map +1 -0
  37. package/dist/internal/sql-lineage/statement-gate.d.ts +21 -0
  38. package/dist/internal/sql-lineage/statement-gate.d.ts.map +1 -0
  39. package/dist/internal/sql-lineage/table-functions.d.ts +6 -0
  40. package/dist/internal/sql-lineage/table-functions.d.ts.map +1 -0
  41. package/dist/internal/sql-transform-relations.d.ts +20 -5
  42. package/dist/internal/sql-transform-relations.d.ts.map +1 -1
  43. package/dist/internal/sql.d.ts +11 -2
  44. package/dist/internal/sql.d.ts.map +1 -1
  45. package/dist/internal/versions.d.ts +10 -1
  46. package/dist/internal/versions.d.ts.map +1 -1
  47. package/dist/types.d.ts +10 -1
  48. package/dist/types.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/ducklake-storage.ts +17 -2
  51. package/src/internal/dataset-row-commit.ts +28 -2
  52. package/src/internal/dataset-row-merge.ts +4 -0
  53. package/src/internal/ducklake-dataset-catalog.ts +162 -49
  54. package/src/internal/ducklake-dataset-table-ref.ts +75 -17
  55. package/src/internal/ducklake-maintenance.ts +110 -48
  56. package/src/internal/ducklake-snapshot-reader.ts +549 -254
  57. package/src/internal/ducklake-sql-executor.ts +214 -14
  58. package/src/internal/ducklake-version-transaction.ts +224 -0
  59. package/src/internal/ducklake-write-coordinator.ts +226 -148
  60. package/src/internal/lineage-encoding.ts +136 -0
  61. package/src/internal/sql-lineage/analysis.ts +257 -0
  62. package/src/internal/sql-lineage/expressions.ts +208 -0
  63. package/src/internal/sql-lineage/plan-json.ts +130 -0
  64. package/src/internal/sql-lineage/plan-relation.ts +59 -0
  65. package/src/internal/sql-lineage/plan-walker.ts +727 -0
  66. package/src/internal/sql-lineage/rejection.ts +16 -0
  67. package/src/internal/sql-lineage/statement-gate.ts +164 -0
  68. package/src/internal/sql-lineage/table-functions.ts +11 -0
  69. package/src/internal/sql-transform-relations.ts +90 -40
  70. package/src/internal/sql.ts +35 -6
  71. package/src/internal/versions.ts +37 -14
  72. package/src/types.ts +11 -1
@@ -1,29 +1,45 @@
1
- import type { DatasetDefinition } from "@sixb/core"
1
+ import type { DatasetDefinition, DatasetSchema } from "@sixb/core"
2
2
  import type {
3
3
  DatasetCatalogState,
4
4
  DatasetLatestVersionSummary,
5
+ DatasetProducer,
5
6
  DatasetVersion,
7
+ DatasetVersionLineage,
6
8
  DatasetVersionMode,
7
9
  DatasetVersionRef,
10
+ LatestVersionsSince,
11
+ ListLatestVersionsSinceInput,
8
12
  } from "@sixb/core/lake-storage"
9
13
  import { LakeStorageError } from "@sixb/core/lake-storage"
10
14
  import type { DuckLakeStorageOptions } from "../types"
11
- import { getBigIntLike, getBoolean, getDate, getOptionalString, getString } from "./duckdb-row"
15
+ import { getBigIntLike, getDate, getOptionalString, getString } from "./duckdb-row"
12
16
  import type { DuckDbQueryRuntime } from "./duckdb-runtime"
13
17
  import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
14
18
  import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
15
- import { type DatasetTableRef, resolveDatasetTableRef } from "./ducklake-dataset-table-ref"
16
- import { encodeDatasetTableName } from "./names"
17
- import { duckLakeMetadataTableName, quoteSqlString } from "./sql"
18
19
  import {
20
+ type DatasetTableRef,
21
+ datasetTableIds,
22
+ resolveDatasetTableRef,
23
+ resolveDatasetTableRefAtCurrentSnapshot,
24
+ } from "./ducklake-dataset-table-ref"
25
+ import type { LatestVersionAtSnapshot } from "./ducklake-version-transaction"
26
+ import { decodeLineage } from "./lineage-encoding"
27
+ import {
28
+ decodeDatasetTableName,
29
+ encodeDatasetSequenceTableName,
30
+ encodeDatasetTableName,
31
+ } from "./names"
32
+ import { buildDuckLakeMetadataQuery, duckLakeMetadataTableName, quoteSqlString } from "./sql"
33
+ import {
34
+ changesTouchTables,
19
35
  parseCommitMetadata,
20
- parseInlineDataChange,
36
+ parseTableDataChange,
21
37
  parseVersionId,
22
38
  type SixbCommitMetadata,
23
39
  toVersionId,
24
40
  } from "./versions"
25
41
 
26
- interface DatasetSnapshotRow {
42
+ export interface DatasetSnapshotRow {
27
43
  readonly snapshotId: string
28
44
  readonly tableId: bigint
29
45
  readonly createdAt: Date
@@ -36,30 +52,44 @@ interface DatasetSnapshotCandidateRow {
36
52
  readonly snapshotId: string
37
53
  readonly createdAt: Date
38
54
  readonly changesMade: string
39
- readonly hasFileChange: boolean
40
- readonly hasFileDeleteChange: boolean
41
55
  readonly metadata?: SixbCommitMetadata
42
56
  }
43
57
 
44
- interface SnapshotCandidateQueryInput {
58
+ interface FeedTable {
59
+ readonly datasetId: string
45
60
  readonly tableId: bigint
61
+ /** A dataset cannot have a version before its table exists. */
62
+ readonly startSnapshotId: bigint
63
+ }
64
+
65
+ interface SnapshotCandidateQueryInput {
66
+ readonly table: DatasetTableRef
46
67
  readonly exactSnapshotId?: string
47
68
  readonly beforeSnapshotId?: string
48
- readonly limit?: number
69
+ readonly limit: number
49
70
  }
50
71
 
51
72
  interface VisibleSnapshotRowsInput {
52
- readonly datasetId: string
53
- readonly tableId: bigint
73
+ readonly table: DatasetTableRef
54
74
  readonly exactSnapshotId?: string
55
75
  readonly beforeSnapshotId?: string
56
76
  readonly visibleRowLimit?: number
57
77
  }
58
78
 
79
+ /** A version reference, with the DuckLake table and snapshot it was resolved through. */
80
+ export interface DuckLakeTableVersionRef extends DatasetVersionRef {
81
+ readonly table: DatasetTableRef
82
+ readonly snapshot: DatasetSnapshotRow
83
+ }
84
+
85
+ /** What a commit needs of the version it builds on. */
59
86
  export interface DuckLakeVersionSummary {
60
87
  readonly datasetId: string
61
88
  readonly versionId: string
89
+ readonly schema: DatasetSchema
90
+ readonly producer?: DatasetProducer
62
91
  readonly inputs?: readonly DatasetVersionRef[]
92
+ readonly lineage?: DatasetVersionLineage
63
93
  readonly rowCount?: number
64
94
  readonly validatedPrimaryKeyColumns?: readonly string[]
65
95
  }
@@ -69,16 +99,16 @@ interface CatalogScanSnapshot {
69
99
  readonly snapshotId: string
70
100
  readonly createdAt: Date
71
101
  readonly changesMade: string
72
- readonly metadata?: SixbCommitMetadata
73
- }
74
-
75
- /** Per (snapshot, table) file-change flags merged into the catalog scan. */
76
- interface FileChangeFlags {
77
- readonly hasFileChange: boolean
78
- readonly hasFileDeleteChange: boolean
79
102
  }
80
103
 
81
104
  const SNAPSHOT_ROW_BATCH_SIZE = 128
105
+ // One feed read covers at most this many commits; the next read continues from its cursor.
106
+ const FEED_PAGE_SIZE = 512
107
+
108
+ // A read of the latest version, or of the one before a version, usually finds it among the
109
+ // dataset's most recent snapshots. It first reads only a few of them: each carries the commit
110
+ // metadata of its version, whose lineage may be large.
111
+ const SHORT_READ_BATCH_SIZE = 8
82
112
 
83
113
  // The bulk catalog scan shares one descending walk of recent snapshots across
84
114
  // all requested datasets, so its cost is bounded by this window rather than by
@@ -96,8 +126,13 @@ const CATALOG_SNAPSHOT_SCAN_LIMIT = 512
96
126
  *
97
127
  * This class intentionally reads DuckLake metadata directly. The provider does
98
128
  * not keep a Sixb side table for versions; a Sixb version is a DuckLake
99
- * snapshot that either changed the dataset table or was explicitly tagged with
100
- * Sixb dataset metadata.
129
+ * snapshot whose `changes_made` names the dataset table: one that changed its
130
+ * rows, or that altered the table with Sixb commit metadata naming the dataset,
131
+ * as a version that changes no rows does (see the write coordinator's
132
+ * `claimTable`).
133
+ *
134
+ * DuckLake names a table in `changes_made` for every change to its rows, inlined
135
+ * or written to files, so version reads never scan the catalog's file tables.
101
136
  */
102
137
  export class DuckLakeSnapshotReader {
103
138
  constructor(
@@ -172,11 +207,11 @@ export class DuckLakeSnapshotReader {
172
207
  datasetIds: readonly string[]
173
208
  ): Promise<readonly DatasetCatalogState[]> {
174
209
  const uniqueIds = [...new Set(datasetIds)]
175
- const tableIdsByDatasetId = await this.resolveDatasetTableIds(runtime, uniqueIds)
176
- const latestRowByDatasetId = await this.resolveLatestSnapshotRows(runtime, tableIdsByDatasetId)
210
+ const tablesByDatasetId = await this.resolveDatasetTables(runtime, uniqueIds)
211
+ const latestRowByDatasetId = await this.resolveLatestSnapshotRows(runtime, tablesByDatasetId)
177
212
 
178
213
  return uniqueIds.map((datasetId) => {
179
- if (!tableIdsByDatasetId.has(datasetId)) {
214
+ if (!tablesByDatasetId.has(datasetId)) {
180
215
  return { datasetId, materialized: false, latestVersion: null }
181
216
  }
182
217
 
@@ -189,6 +224,205 @@ export class DuckLakeSnapshotReader {
189
224
  })
190
225
  }
191
226
 
227
+ /**
228
+ * Change feed over DuckLake's snapshot table, which is an append-only commit log: snapshot ids
229
+ * only increase, and a commit becomes visible only after every lower id has (concurrent commits
230
+ * that pick the same id conflict, and the loser retries with the next one). The cursor is the
231
+ * last snapshot id read. Every query is bounded by a snapshot-id range, so an idle read is one
232
+ * indexed query that returns the cursor's own snapshot, whatever the history length.
233
+ */
234
+ async listLatestVersionsSince(
235
+ input: ListLatestVersionsSinceInput
236
+ ): Promise<LatestVersionsSince | null> {
237
+ this.connections.assertOpen()
238
+
239
+ return this.connections.withAttachedRuntime(async (runtime) => {
240
+ if (input.cursor === null) {
241
+ return { cursor: await this.queryHeadSnapshotId(runtime), versions: [] }
242
+ }
243
+ if (!/^\d+$/.test(input.cursor)) {
244
+ return null
245
+ }
246
+
247
+ const snapshots = await this.querySnapshotsFrom(runtime, input.cursor)
248
+ // The cursor's own snapshot proves the cursor is still a position in this lake's history.
249
+ if (snapshots[0]?.snapshotId !== input.cursor) {
250
+ return null
251
+ }
252
+ const newSnapshots = snapshots.slice(1)
253
+ if (newSnapshots.length === 0) {
254
+ return { cursor: input.cursor, versions: [] }
255
+ }
256
+
257
+ const tables = await this.resolveFeedTables(runtime, input.datasetIds)
258
+ const latest = await this.collectFeedPage(runtime, tables, newSnapshots)
259
+ return {
260
+ cursor: newSnapshots[newSnapshots.length - 1]?.snapshotId ?? input.cursor,
261
+ versions: [...latest].map(([datasetId, row]) => this.snapshotRowToSummary(datasetId, row)),
262
+ }
263
+ })
264
+ }
265
+
266
+ private async queryHeadSnapshotId(runtime: DuckDbQueryRuntime): Promise<string> {
267
+ const [row] = await runtime.query(
268
+ buildDuckLakeMetadataQuery(
269
+ this.options,
270
+ (table) => `SELECT max(snapshot_id) AS snapshot_id FROM ${table("ducklake_snapshot")}`
271
+ )
272
+ )
273
+ if (row?.snapshot_id === null || row?.snapshot_id === undefined) {
274
+ throw new LakeStorageError("[SixbDuckLake] DuckLake catalog has no snapshots.")
275
+ }
276
+ return String(getBigIntLike(row, "snapshot_id"))
277
+ }
278
+
279
+ /** The cursor's snapshot and up to one feed page of snapshots after it, oldest first. */
280
+ private async querySnapshotsFrom(
281
+ runtime: DuckDbQueryRuntime,
282
+ cursor: string
283
+ ): Promise<readonly { readonly snapshotId: string; readonly createdAt: Date }[]> {
284
+ const rows = await runtime.query(
285
+ buildDuckLakeMetadataQuery(
286
+ this.options,
287
+ (table) => `
288
+ SELECT snapshot_id, snapshot_time
289
+ FROM ${table("ducklake_snapshot")}
290
+ WHERE snapshot_id >= ${cursor}
291
+ ORDER BY snapshot_id
292
+ LIMIT ${FEED_PAGE_SIZE + 1}
293
+ `
294
+ )
295
+ )
296
+ return rows.map((row) => ({
297
+ snapshotId: String(getBigIntLike(row, "snapshot_id")),
298
+ createdAt: getDate(row, "snapshot_time"),
299
+ }))
300
+ }
301
+
302
+ /** Current dataset tables, with the first snapshot each table id existed in. */
303
+ private async resolveFeedTables(
304
+ runtime: DuckDbQueryRuntime,
305
+ datasetIds: readonly string[] | undefined
306
+ ): Promise<readonly FeedTable[]> {
307
+ const requested = datasetIds === undefined ? null : new Set(datasetIds)
308
+ const rows = await runtime.query(`
309
+ SELECT table_id, table_name, begin_snapshot, end_snapshot
310
+ FROM ${duckLakeMetadataTableName(this.options, "ducklake_table")}
311
+ `)
312
+
313
+ const startByTableId = new Map<bigint, bigint>()
314
+ const current: { datasetId: string; tableId: bigint }[] = []
315
+ for (const row of rows) {
316
+ const tableId = getBigIntLike(row, "table_id")
317
+ const begin = getBigIntLike(row, "begin_snapshot")
318
+ const start = startByTableId.get(tableId)
319
+ if (start === undefined || begin < start) {
320
+ startByTableId.set(tableId, begin)
321
+ }
322
+ if (row.end_snapshot !== null && row.end_snapshot !== undefined) {
323
+ continue
324
+ }
325
+ const datasetId = decodeDatasetTableName(getString(row, "table_name"))
326
+ if (datasetId !== null && (requested === null || requested.has(datasetId))) {
327
+ current.push({ datasetId, tableId })
328
+ }
329
+ }
330
+
331
+ return current.map((table) => ({
332
+ ...table,
333
+ startSnapshotId: startByTableId.get(table.tableId) ?? 0n,
334
+ }))
335
+ }
336
+
337
+ /** Folds a page of snapshots, oldest first, into each dataset's newest data version. */
338
+ private async collectFeedPage(
339
+ runtime: DuckDbQueryRuntime,
340
+ tables: readonly FeedTable[],
341
+ snapshots: readonly { readonly snapshotId: string; readonly createdAt: Date }[]
342
+ ): Promise<Map<string, DatasetSnapshotRow>> {
343
+ const latest = new Map<string, DatasetSnapshotRow>()
344
+ const first = snapshots[0]?.snapshotId
345
+ const last = snapshots[snapshots.length - 1]?.snapshotId
346
+ if (first === undefined || last === undefined || tables.length === 0) {
347
+ return latest
348
+ }
349
+
350
+ const changeRows = await runtime.query(
351
+ buildDuckLakeMetadataQuery(
352
+ this.options,
353
+ (table) => `
354
+ SELECT snapshot_id, changes_made, commit_extra_info
355
+ FROM ${table("ducklake_snapshot_changes")}
356
+ WHERE snapshot_id >= ${first} AND snapshot_id <= ${last}
357
+ `
358
+ )
359
+ )
360
+ const changesBySnapshotId = new Map(
361
+ changeRows.map((row) => [String(getBigIntLike(row, "snapshot_id")), row])
362
+ )
363
+ const tablesByTableId = new Map(tables.map((table) => [table.tableId.toString(), table]))
364
+
365
+ for (const snapshot of snapshots) {
366
+ const changes = changesBySnapshotId.get(snapshot.snapshotId)
367
+ if (changes === undefined) {
368
+ continue
369
+ }
370
+ const changesMade = getString(changes, "changes_made")
371
+ const metadata = parseCommitMetadata(changes.commit_extra_info)
372
+
373
+ // Only the tables this snapshot names can have a version in it.
374
+ const touched = new Set<FeedTable>()
375
+ for (const change of changesMade.split(",")) {
376
+ const table = tablesByTableId.get(change.split(":")[1] ?? "")
377
+ if (table) touched.add(table)
378
+ }
379
+
380
+ for (const table of touched) {
381
+ if (BigInt(snapshot.snapshotId) < table.startSnapshotId) {
382
+ continue
383
+ }
384
+ const row = this.candidateToSnapshotRow(table.datasetId, table.tableId, {
385
+ snapshotId: snapshot.snapshotId,
386
+ createdAt: snapshot.createdAt,
387
+ changesMade,
388
+ ...(metadata !== undefined ? { metadata } : {}),
389
+ })
390
+ if (row && row.mode !== "schema") {
391
+ latest.set(table.datasetId, row)
392
+ }
393
+ }
394
+ }
395
+
396
+ return latest
397
+ }
398
+
399
+ /**
400
+ * The snapshot of every dataset's latest version, however old, from one shared walk down the
401
+ * whole history: maintenance must never expire one.
402
+ */
403
+ async latestVersionSnapshotIds(runtime: DuckDbQueryRuntime): Promise<readonly string[]> {
404
+ const tables = await runtime.query(`
405
+ SELECT table_name
406
+ FROM ${duckLakeMetadataTableName(this.options, "ducklake_table")}
407
+ WHERE end_snapshot IS NULL
408
+ `)
409
+ const datasetIds = tables.flatMap((table) => {
410
+ const datasetId = decodeDatasetTableName(getString(table, "table_name"))
411
+ return datasetId === null ? [] : [datasetId]
412
+ })
413
+
414
+ if (datasetIds.length === 0) {
415
+ return []
416
+ }
417
+
418
+ const latest = await this.resolveLatestSnapshotRows(
419
+ runtime,
420
+ await this.resolveDatasetTables(runtime, datasetIds),
421
+ Number.POSITIVE_INFINITY
422
+ )
423
+ return [...latest.values()].map((row) => row.snapshotId)
424
+ }
425
+
192
426
  private snapshotRowToSummary(
193
427
  datasetId: string,
194
428
  row: DatasetSnapshotRow
@@ -202,19 +436,21 @@ export class DuckLakeSnapshotReader {
202
436
  }
203
437
  }
204
438
 
205
- private async resolveDatasetTableIds(
439
+ private async resolveDatasetTables(
206
440
  runtime: DuckDbQueryRuntime,
207
441
  datasetIds: readonly string[]
208
- ): Promise<Map<string, bigint>> {
442
+ ): Promise<Map<string, DatasetTableRef>> {
209
443
  // Encoded table names are a collision-free bijection with dataset ids, so a
210
- // single `ducklake_table` read maps every requested id to its current table.
444
+ // single `ducklake_table` read maps every requested id to its current tables.
211
445
  const datasetIdByTableName = new Map<string, string>()
446
+ const datasetIdBySequenceTableName = new Map<string, string>()
212
447
  for (const datasetId of datasetIds) {
213
448
  datasetIdByTableName.set(encodeDatasetTableName(datasetId), datasetId)
449
+ datasetIdBySequenceTableName.set(encodeDatasetSequenceTableName(datasetId), datasetId)
214
450
  }
215
451
 
216
452
  const ducklakeTable = duckLakeMetadataTableName(this.options, "ducklake_table")
217
- const tableNameList = [...datasetIdByTableName.keys()]
453
+ const tableNameList = [...datasetIdByTableName.keys(), ...datasetIdBySequenceTableName.keys()]
218
454
  .map((name) => quoteSqlString(name))
219
455
  .join(", ")
220
456
  const rows = await runtime.query(`
@@ -224,15 +460,31 @@ export class DuckLakeSnapshotReader {
224
460
  AND end_snapshot IS NULL
225
461
  `)
226
462
 
227
- const tableIdsByDatasetId = new Map<string, bigint>()
463
+ const tablesByDatasetId = new Map<string, DatasetTableRef>()
228
464
  for (const row of rows) {
229
- const datasetId = datasetIdByTableName.get(getString(row, "table_name"))
465
+ const tableName = getString(row, "table_name")
466
+ const datasetId = datasetIdByTableName.get(tableName)
230
467
  if (datasetId !== undefined) {
231
- tableIdsByDatasetId.set(datasetId, getBigIntLike(row, "table_id"))
468
+ tablesByDatasetId.set(datasetId, {
469
+ datasetId,
470
+ tableName,
471
+ tableId: getBigIntLike(row, "table_id"),
472
+ })
232
473
  }
233
474
  }
234
475
 
235
- return tableIdsByDatasetId
476
+ for (const row of rows) {
477
+ const datasetId = datasetIdBySequenceTableName.get(getString(row, "table_name"))
478
+ const table = datasetId === undefined ? undefined : tablesByDatasetId.get(datasetId)
479
+ if (datasetId !== undefined && table !== undefined) {
480
+ tablesByDatasetId.set(datasetId, {
481
+ ...table,
482
+ sequenceTableId: getBigIntLike(row, "table_id"),
483
+ })
484
+ }
485
+ }
486
+
487
+ return tablesByDatasetId
236
488
  }
237
489
 
238
490
  /**
@@ -244,19 +496,19 @@ export class DuckLakeSnapshotReader {
244
496
  */
245
497
  private async resolveLatestSnapshotRows(
246
498
  runtime: DuckDbQueryRuntime,
247
- tableIdsByDatasetId: ReadonlyMap<string, bigint>
499
+ tablesByDatasetId: ReadonlyMap<string, DatasetTableRef>,
500
+ scanLimit = CATALOG_SNAPSHOT_SCAN_LIMIT
248
501
  ): Promise<Map<string, DatasetSnapshotRow>> {
249
502
  const result = new Map<string, DatasetSnapshotRow>()
250
- if (tableIdsByDatasetId.size === 0) {
503
+ if (tablesByDatasetId.size === 0) {
251
504
  return result
252
505
  }
253
506
 
254
- const unresolved = new Map<string, bigint>(tableIdsByDatasetId)
255
- const tableIds = [...new Set(tableIdsByDatasetId.values())]
507
+ const unresolved = new Map<string, DatasetTableRef>(tablesByDatasetId)
256
508
 
257
509
  let beforeSnapshotId: string | undefined
258
510
  let scanned = 0
259
- while (unresolved.size > 0 && scanned < CATALOG_SNAPSHOT_SCAN_LIMIT) {
511
+ while (unresolved.size > 0 && scanned < scanLimit) {
260
512
  const candidates = await this.queryCatalogSnapshotBatch(
261
513
  runtime,
262
514
  beforeSnapshotId,
@@ -266,23 +518,27 @@ export class DuckLakeSnapshotReader {
266
518
  break
267
519
  }
268
520
 
269
- const fileFlags = await this.queryFileChangeFlags(
521
+ const tables = [...unresolved.values()]
522
+ const metadata = await this.readCommitMetadata(
270
523
  runtime,
271
- candidates.map((candidate) => candidate.snapshotId),
272
- tableIds
524
+ candidates
525
+ .filter((candidate) =>
526
+ tables.some((table) =>
527
+ changesTouchTables(candidate.changesMade, datasetTableIds(table))
528
+ )
529
+ )
530
+ .map((candidate) => candidate.snapshotId)
273
531
  )
274
532
 
275
533
  for (const candidate of candidates) {
276
534
  scanned += 1
277
- for (const [datasetId, tableId] of [...unresolved]) {
278
- const flags = fileFlags.get(fileChangeKey(candidate.snapshotId, tableId))
279
- const row = this.candidateToSnapshotRow(datasetId, tableId, {
535
+ const candidateMetadata = metadata.get(candidate.snapshotId)
536
+ for (const [datasetId, table] of [...unresolved]) {
537
+ const row = this.candidateToSnapshotRow(datasetId, table.tableId, {
280
538
  snapshotId: candidate.snapshotId,
281
539
  createdAt: candidate.createdAt,
282
540
  changesMade: candidate.changesMade,
283
- hasFileChange: flags?.hasFileChange ?? false,
284
- hasFileDeleteChange: flags?.hasFileDeleteChange ?? false,
285
- ...(candidate.metadata !== undefined ? { metadata: candidate.metadata } : {}),
541
+ ...(candidateMetadata !== undefined ? { metadata: candidateMetadata } : {}),
286
542
  })
287
543
  if (row) {
288
544
  result.set(datasetId, row)
@@ -325,8 +581,7 @@ export class DuckLakeSnapshotReader {
325
581
  SELECT
326
582
  snapshot.snapshot_id,
327
583
  snapshot.snapshot_time,
328
- changes.changes_made,
329
- changes.commit_extra_info
584
+ changes.changes_made
330
585
  FROM ${ducklakeSnapshot} snapshot
331
586
  JOIN ${ducklakeSnapshotChanges} changes ON changes.snapshot_id = snapshot.snapshot_id
332
587
  ${whereSql}
@@ -334,70 +589,11 @@ export class DuckLakeSnapshotReader {
334
589
  LIMIT ${Math.max(0, Math.trunc(limit))}
335
590
  `)
336
591
 
337
- return rows.map((row) => {
338
- const metadata = parseCommitMetadata(getOptionalString(row, "commit_extra_info"))
339
- return {
340
- snapshotId: String(getBigIntLike(row, "snapshot_id")),
341
- createdAt: getDate(row, "snapshot_time"),
342
- changesMade: getString(row, "changes_made"),
343
- ...(metadata !== undefined ? { metadata } : {}),
344
- }
345
- })
346
- }
347
-
348
- private async queryFileChangeFlags(
349
- runtime: DuckDbQueryRuntime,
350
- snapshotIds: readonly string[],
351
- tableIds: readonly bigint[]
352
- ): Promise<Map<string, FileChangeFlags>> {
353
- const flags = new Map<string, FileChangeFlags>()
354
- if (snapshotIds.length === 0 || tableIds.length === 0) {
355
- return flags
356
- }
357
-
358
- for (const snapshotId of snapshotIds) {
359
- assertDuckLakeSnapshotId(snapshotId)
360
- }
361
-
362
- const snapshotIdList = snapshotIds.join(", ")
363
- const tableIdList = tableIds.map((tableId) => tableId.toString()).join(", ")
364
- const ducklakeDataFile = duckLakeMetadataTableName(this.options, "ducklake_data_file")
365
- const ducklakeDeleteFile = duckLakeMetadataTableName(this.options, "ducklake_delete_file")
366
-
367
- // Large tables keep their changes in data/delete files instead of inline
368
- // `changes_made`, so merge file-level changes for the scanned snapshots.
369
- const rows = await runtime.query(`
370
- WITH file_changes AS (
371
- SELECT begin_snapshot AS snapshot_id, table_id, false AS is_delete
372
- FROM ${ducklakeDataFile}
373
- WHERE table_id IN (${tableIdList}) AND begin_snapshot IN (${snapshotIdList})
374
- UNION ALL
375
- SELECT end_snapshot, table_id, true
376
- FROM ${ducklakeDataFile}
377
- WHERE table_id IN (${tableIdList}) AND end_snapshot IN (${snapshotIdList})
378
- UNION ALL
379
- SELECT begin_snapshot, table_id, true
380
- FROM ${ducklakeDeleteFile}
381
- WHERE table_id IN (${tableIdList}) AND begin_snapshot IN (${snapshotIdList})
382
- UNION ALL
383
- SELECT end_snapshot, table_id, true
384
- FROM ${ducklakeDeleteFile}
385
- WHERE table_id IN (${tableIdList}) AND end_snapshot IN (${snapshotIdList})
386
- )
387
- SELECT snapshot_id, table_id, bool_or(is_delete) AS has_delete
388
- FROM file_changes
389
- GROUP BY snapshot_id, table_id
390
- `)
391
-
392
- for (const row of rows) {
393
- const key = fileChangeKey(
394
- String(getBigIntLike(row, "snapshot_id")),
395
- getBigIntLike(row, "table_id")
396
- )
397
- flags.set(key, { hasFileChange: true, hasFileDeleteChange: getBoolean(row, "has_delete") })
398
- }
399
-
400
- return flags
592
+ return rows.map((row) => ({
593
+ snapshotId: String(getBigIntLike(row, "snapshot_id")),
594
+ createdAt: getDate(row, "snapshot_time"),
595
+ changesMade: getString(row, "changes_made"),
596
+ }))
401
597
  }
402
598
 
403
599
  private assertNoMetadataConflict(
@@ -423,9 +619,14 @@ export class DuckLakeSnapshotReader {
423
619
  async getLatestVersionRefForDefinition(
424
620
  runtime: DuckDbQueryRuntime,
425
621
  dataset: DatasetDefinition
426
- ): Promise<DatasetVersionRef | null> {
427
- const summary = await this.getLatestVersionSummaryForDefinition(runtime, dataset)
428
- return summary ? { datasetId: summary.datasetId, versionId: summary.versionId } : null
622
+ ): Promise<DuckLakeTableVersionRef | null> {
623
+ const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
624
+ if (!tableRef) {
625
+ return null
626
+ }
627
+
628
+ const row = await this.getLatestSnapshotRowForTableRef(runtime, tableRef)
629
+ return row ? tableVersionRef(tableRef, row) : null
429
630
  }
430
631
 
431
632
  async getLatestVersionSummaryForDefinition(
@@ -433,19 +634,49 @@ export class DuckLakeSnapshotReader {
433
634
  dataset: DatasetDefinition
434
635
  ): Promise<DuckLakeVersionSummary | null> {
435
636
  const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
436
- if (!tableRef) {
437
- return null
637
+ return tableRef ? this.getLatestVersionSummaryForTableRef(runtime, tableRef) : null
638
+ }
639
+
640
+ /**
641
+ * The latest version a commit builds on, with the lake's snapshot it was read at. Its first
642
+ * read fixes the snapshot of a transaction it runs in (see `beginTransactionOnLatestVersion`).
643
+ */
644
+ async getLatestVersionSummaryAtCurrentSnapshot(
645
+ runtime: DuckDbQueryRuntime,
646
+ dataset: DatasetDefinition
647
+ ): Promise<LatestVersionAtSnapshot<DuckLakeVersionSummary>> {
648
+ const { snapshotId, table } = await resolveDatasetTableRefAtCurrentSnapshot(
649
+ this.options,
650
+ runtime,
651
+ dataset.id
652
+ )
653
+
654
+ return {
655
+ snapshotId,
656
+ latestVersion: table ? await this.getLatestVersionSummaryForTableRef(runtime, table) : null,
438
657
  }
658
+ }
439
659
 
660
+ private async getLatestVersionSummaryForTableRef(
661
+ runtime: DuckDbQueryRuntime,
662
+ tableRef: DatasetTableRef
663
+ ): Promise<DuckLakeVersionSummary | null> {
440
664
  const row = await this.getLatestSnapshotRowForTableRef(runtime, tableRef)
441
665
  if (!row) {
442
666
  return null
443
667
  }
444
668
 
669
+ const [schema] = await this.datasets.getDatasetSchemasAtSnapshots(runtime, [
670
+ { table: tableRef, snapshotId: row.snapshotId },
671
+ ])
672
+
445
673
  return {
446
674
  datasetId: tableRef.datasetId,
447
675
  versionId: toVersionId(row.snapshotId),
676
+ schema,
677
+ producer: row.metadata?.producer,
448
678
  inputs: row.metadata?.inputs,
679
+ ...storedLineage(tableRef.datasetId, row),
449
680
  ...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
450
681
  ...(row.metadata?.validatedPrimaryKeyColumns !== undefined
451
682
  ? { validatedPrimaryKeyColumns: row.metadata.validatedPrimaryKeyColumns }
@@ -472,14 +703,14 @@ export class DuckLakeSnapshotReader {
472
703
  const match = await this.getSnapshotRowForTableRef(runtime, tableRef, snapshotId, {
473
704
  includeParent: true,
474
705
  })
475
- return match ? this.snapshotToVersion(runtime, tableRef, match) : null
706
+ return match ? (await this.snapshotsToVersions(runtime, tableRef, [match]))[0] : null
476
707
  }
477
708
 
478
709
  async getVersionRefForSnapshot(
479
710
  runtime: DuckDbQueryRuntime,
480
711
  dataset: DatasetDefinition,
481
712
  snapshotId: string
482
- ): Promise<DatasetVersionRef | null> {
713
+ ): Promise<DuckLakeTableVersionRef | null> {
483
714
  assertDuckLakeSnapshotId(snapshotId)
484
715
 
485
716
  const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
@@ -490,7 +721,23 @@ export class DuckLakeSnapshotReader {
490
721
  const row = await this.getSnapshotRowForTableRef(runtime, tableRef, snapshotId, {
491
722
  includeParent: false,
492
723
  })
493
- return row ? { datasetId: tableRef.datasetId, versionId: toVersionId(row.snapshotId) } : null
724
+ return row ? tableVersionRef(tableRef, row) : null
725
+ }
726
+
727
+ /**
728
+ * The versions resolved refs point to, with their schemas from one catalog read. Refs resolve
729
+ * without their parent, so these versions carry no `parentVersionId`.
730
+ */
731
+ async getVersionsForRefs(
732
+ runtime: DuckDbQueryRuntime,
733
+ refs: readonly DuckLakeTableVersionRef[]
734
+ ): Promise<DatasetVersion[]> {
735
+ const schemas = await this.datasets.getDatasetSchemasAtSnapshots(
736
+ runtime,
737
+ refs.map((ref) => ({ table: ref.table, snapshotId: ref.snapshot.snapshotId }))
738
+ )
739
+
740
+ return refs.map((ref, index) => snapshotToVersion(ref.table, ref.snapshot, schemas[index]))
494
741
  }
495
742
 
496
743
  async listVersionsForDefinition(
@@ -508,13 +755,7 @@ export class DuckLakeSnapshotReader {
508
755
  limit?: number
509
756
  ): Promise<readonly DatasetVersion[]> {
510
757
  const limitedRows = await this.getSnapshotRowsForTableRef(runtime, tableRef, limit)
511
- const versions: DatasetVersion[] = []
512
-
513
- for (const row of limitedRows) {
514
- versions.push(await this.snapshotToVersion(runtime, tableRef, row))
515
- }
516
-
517
- return versions
758
+ return this.snapshotsToVersions(runtime, tableRef, limitedRows)
518
759
  }
519
760
 
520
761
  async getLatestVersionForTableRef(
@@ -530,8 +771,7 @@ export class DuckLakeSnapshotReader {
530
771
  tableRef: DatasetTableRef
531
772
  ): Promise<DatasetSnapshotRow | null> {
532
773
  const [row] = await this.collectVisibleSnapshotRows(runtime, {
533
- datasetId: tableRef.datasetId,
534
- tableId: tableRef.tableId,
774
+ table: tableRef,
535
775
  visibleRowLimit: 1,
536
776
  })
537
777
  return row ?? null
@@ -546,8 +786,7 @@ export class DuckLakeSnapshotReader {
546
786
  assertDuckLakeSnapshotId(snapshotId)
547
787
 
548
788
  const [row] = await this.collectVisibleSnapshotRows(runtime, {
549
- datasetId: tableRef.datasetId,
550
- tableId: tableRef.tableId,
789
+ table: tableRef,
551
790
  exactSnapshotId: snapshotId,
552
791
  visibleRowLimit: 1,
553
792
  })
@@ -563,8 +802,7 @@ export class DuckLakeSnapshotReader {
563
802
  }
564
803
 
565
804
  const [parent] = await this.collectVisibleSnapshotRows(runtime, {
566
- datasetId: tableRef.datasetId,
567
- tableId: tableRef.tableId,
805
+ table: tableRef,
568
806
  beforeSnapshotId: snapshotId,
569
807
  visibleRowLimit: 1,
570
808
  })
@@ -583,8 +821,7 @@ export class DuckLakeSnapshotReader {
583
821
  }
584
822
 
585
823
  const rows = await this.collectVisibleSnapshotRows(runtime, {
586
- datasetId: tableRef.datasetId,
587
- tableId: tableRef.tableId,
824
+ table: tableRef,
588
825
  visibleRowLimit: visibleLimit === undefined ? undefined : visibleLimit + 1,
589
826
  })
590
827
  const rowsWithParents = this.withParentSnapshotIds(rows)
@@ -601,20 +838,29 @@ export class DuckLakeSnapshotReader {
601
838
 
602
839
  const snapshots: DatasetSnapshotRow[] = []
603
840
  let beforeSnapshotId = input.beforeSnapshotId
841
+ let shortRead =
842
+ input.exactSnapshotId !== undefined ||
843
+ (input.visibleRowLimit !== undefined && input.visibleRowLimit <= 2)
604
844
 
605
845
  while (input.visibleRowLimit === undefined || snapshots.length < input.visibleRowLimit) {
846
+ const batchSize = shortRead ? SHORT_READ_BATCH_SIZE : SNAPSHOT_ROW_BATCH_SIZE
606
847
  const candidates = await this.querySnapshotCandidates(runtime, {
607
- tableId: input.tableId,
848
+ table: input.table,
608
849
  exactSnapshotId: input.exactSnapshotId,
609
850
  beforeSnapshotId,
610
- limit: input.exactSnapshotId === undefined ? SNAPSHOT_ROW_BATCH_SIZE : 1,
851
+ limit: input.exactSnapshotId === undefined ? batchSize : 1,
611
852
  })
853
+ shortRead = false
612
854
  if (candidates.length === 0) {
613
855
  break
614
856
  }
615
857
 
616
858
  for (const candidate of candidates) {
617
- const snapshot = this.candidateToSnapshotRow(input.datasetId, input.tableId, candidate)
859
+ const snapshot = this.candidateToSnapshotRow(
860
+ input.table.datasetId,
861
+ input.table.tableId,
862
+ candidate
863
+ )
618
864
  if (snapshot) {
619
865
  snapshots.push(snapshot)
620
866
  }
@@ -624,7 +870,7 @@ export class DuckLakeSnapshotReader {
624
870
  }
625
871
  }
626
872
 
627
- if (input.exactSnapshotId !== undefined || candidates.length < SNAPSHOT_ROW_BATCH_SIZE) {
873
+ if (input.exactSnapshotId !== undefined || candidates.length < batchSize) {
628
874
  break
629
875
  }
630
876
 
@@ -634,121 +880,133 @@ export class DuckLakeSnapshotReader {
634
880
  return snapshots
635
881
  }
636
882
 
883
+ /**
884
+ * Hydrates, newest first, the snapshots whose changes name one of the dataset's tables: every
885
+ * snapshot that may hold one of its versions. The text match is a superset;
886
+ * candidateToSnapshotRow decides visibility.
887
+ *
888
+ * The query walks the catalog's snapshot ids downwards, from `beforeSnapshotId` or the head,
889
+ * and stops at the limit or at the table's creation. A lookup therefore reads the snapshots
890
+ * between where it starts and the versions it returns: a few for an active dataset, at most the
891
+ * table's lifetime for one written long ago. It never reads the file tables, whose replaced
892
+ * files stay until cleanup.
893
+ */
637
894
  private async querySnapshotCandidates(
638
895
  runtime: DuckDbQueryRuntime,
639
896
  input: SnapshotCandidateQueryInput
640
897
  ): Promise<readonly DatasetSnapshotCandidateRow[]> {
641
- if (input.limit !== undefined && input.limit <= 0) {
898
+ const limit = Math.max(0, Math.trunc(input.limit))
899
+ if (limit === 0) {
642
900
  return []
643
901
  }
644
-
645
- // A dataset cannot have a version before its table exists. Use the earliest table record:
646
- // a rename may create a newer record with the same table id and must not hide older versions.
647
- const ducklakeTable = duckLakeMetadataTableName(this.options, "ducklake_table")
648
- const where = [
649
- `snapshot.snapshot_id >= (SELECT min(begin_snapshot) FROM ${ducklakeTable} WHERE table_id = ${input.tableId})`,
650
- ]
651
902
  if (input.exactSnapshotId !== undefined) {
652
903
  assertDuckLakeSnapshotId(input.exactSnapshotId)
653
- where.push(`snapshot.snapshot_id = ${input.exactSnapshotId}`)
654
904
  }
655
905
  if (input.beforeSnapshotId !== undefined) {
656
906
  assertDuckLakeSnapshotId(input.beforeSnapshotId)
657
- where.push(`snapshot.snapshot_id < ${input.beforeSnapshotId}`)
658
907
  }
659
908
 
660
- const whereSql = `WHERE ${where.join(" AND ")}`
661
- const limitSql =
662
- input.limit === undefined ? "" : `LIMIT ${Math.max(0, Math.trunc(input.limit))}`
663
- const ducklakeSnapshot = duckLakeMetadataTableName(this.options, "ducklake_snapshot")
664
- const ducklakeSnapshotChanges = duckLakeMetadataTableName(
665
- this.options,
666
- "ducklake_snapshot_changes"
667
- )
668
- const ducklakeDataFile = duckLakeMetadataTableName(this.options, "ducklake_data_file")
669
- const ducklakeDeleteFile = duckLakeMetadataTableName(this.options, "ducklake_delete_file")
909
+ const rows = await runtime.query(
910
+ buildDuckLakeMetadataQuery(this.options, (table) => {
911
+ // A dataset cannot have a version before its table exists. Use the earliest table record:
912
+ // a rename may create a newer record with the same table id and must not hide older
913
+ // versions.
914
+ const bounds = [
915
+ `changes.snapshot_id >= (
916
+ SELECT min(begin_snapshot)
917
+ FROM ${table("ducklake_table")}
918
+ WHERE table_id = ${input.table.tableId}
919
+ )`,
920
+ ]
921
+ if (input.exactSnapshotId !== undefined) {
922
+ bounds.push(`changes.snapshot_id = ${input.exactSnapshotId}`)
923
+ }
924
+ if (input.beforeSnapshotId !== undefined) {
925
+ bounds.push(`changes.snapshot_id < ${input.beforeSnapshotId}`)
926
+ }
670
927
 
671
- const rows = await runtime.query(`
672
- WITH candidate_snapshots AS (
673
- -- Recent DuckLake snapshots to inspect; Sixb visibility is filtered later.
674
- SELECT
675
- snapshot.snapshot_id,
676
- snapshot.snapshot_time,
677
- changes.changes_made,
678
- changes.commit_extra_info
679
- FROM ${ducklakeSnapshot} snapshot
680
- JOIN ${ducklakeSnapshotChanges} changes
681
- ON changes.snapshot_id = snapshot.snapshot_id
682
- ${whereSql}
683
- ORDER BY snapshot.snapshot_id DESC
684
- ${limitSql}
685
- ),
686
- file_changes AS (
687
- -- File metadata tells whether this table changed in each candidate snapshot.
688
- SELECT begin_snapshot AS snapshot_id, false AS is_delete_change
689
- FROM ${ducklakeDataFile}
690
- WHERE table_id = ${input.tableId}
691
- AND begin_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
692
- UNION ALL
693
- SELECT end_snapshot AS snapshot_id, true AS is_delete_change
694
- FROM ${ducklakeDataFile}
695
- WHERE table_id = ${input.tableId}
696
- AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
697
- UNION ALL
698
- SELECT begin_snapshot AS snapshot_id, true AS is_delete_change
699
- FROM ${ducklakeDeleteFile}
700
- WHERE table_id = ${input.tableId}
701
- AND begin_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
702
- UNION ALL
703
- SELECT end_snapshot AS snapshot_id, true AS is_delete_change
704
- FROM ${ducklakeDeleteFile}
705
- WHERE table_id = ${input.tableId}
706
- AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
707
- ),
708
- file_changes_by_snapshot AS (
709
- -- Collapse file-level changes into one row per snapshot.
710
- SELECT
711
- snapshot_id,
712
- count(*) > 0 AS has_file_change,
713
- count(*) FILTER (WHERE is_delete_change) > 0 AS has_file_delete_change
714
- FROM file_changes
715
- GROUP BY snapshot_id
716
- )
717
- SELECT
718
- -- Keep metadata-only candidates; Sixb filters them with commit_extra_info.
719
- candidate.snapshot_id,
720
- candidate.snapshot_time,
721
- candidate.changes_made,
722
- candidate.commit_extra_info,
723
- coalesce(file_changes.has_file_change, false) AS has_file_change,
724
- coalesce(file_changes.has_file_delete_change, false) AS has_file_delete_change
725
- FROM candidate_snapshots candidate
726
- LEFT JOIN file_changes_by_snapshot file_changes
727
- ON file_changes.snapshot_id = candidate.snapshot_id
728
- ORDER BY candidate.snapshot_id DESC
729
- `)
928
+ const namesDatasetTable = datasetTableIds(input.table)
929
+ .map(
930
+ (tableId) =>
931
+ `strpos(',' || changes.changes_made || ',', ${quoteSqlString(`:${tableId},`)}) > 0`
932
+ )
933
+ .join(" OR ")
934
+
935
+ // Bounds and order follow the snapshot-id primary key, so PostgreSQL reads it backwards
936
+ // and stops at the limit; the text match only filters the rows it passes.
937
+ return `
938
+ SELECT
939
+ changes.snapshot_id,
940
+ snapshot.snapshot_time,
941
+ changes.changes_made,
942
+ changes.commit_extra_info
943
+ FROM ${table("ducklake_snapshot_changes")} changes
944
+ JOIN ${table("ducklake_snapshot")} snapshot
945
+ ON snapshot.snapshot_id = changes.snapshot_id
946
+ WHERE ${bounds.join(" AND ")}
947
+ AND (${namesDatasetTable})
948
+ ORDER BY changes.snapshot_id DESC
949
+ LIMIT ${limit}
950
+ `
951
+ })
952
+ )
730
953
 
731
954
  return rows.map((row) => {
732
955
  const metadata = parseCommitMetadata(row.commit_extra_info)
956
+
733
957
  return {
734
958
  snapshotId: String(getBigIntLike(row, "snapshot_id")),
735
959
  createdAt: getDate(row, "snapshot_time"),
736
960
  changesMade: getString(row, "changes_made"),
737
- hasFileChange: getBoolean(row, "has_file_change"),
738
- hasFileDeleteChange: getBoolean(row, "has_file_delete_change"),
739
961
  ...(metadata !== undefined ? { metadata } : {}),
740
962
  }
741
963
  })
742
964
  }
743
965
 
966
+ /**
967
+ * Sixb commit metadata of the snapshots that changed a scanned dataset. Each one carries the
968
+ * lineage of its version, which can be large: scans read it only for the snapshots they keep,
969
+ * never for the other datasets' snapshots they page past.
970
+ */
971
+ private async readCommitMetadata(
972
+ runtime: DuckDbQueryRuntime,
973
+ snapshotIds: readonly string[]
974
+ ): Promise<Map<string, SixbCommitMetadata>> {
975
+ const metadata = new Map<string, SixbCommitMetadata>()
976
+ if (snapshotIds.length === 0) {
977
+ return metadata
978
+ }
979
+
980
+ for (const snapshotId of snapshotIds) {
981
+ assertDuckLakeSnapshotId(snapshotId)
982
+ }
983
+
984
+ const ducklakeSnapshotChanges = duckLakeMetadataTableName(
985
+ this.options,
986
+ "ducklake_snapshot_changes"
987
+ )
988
+ const rows = await runtime.query(`
989
+ SELECT snapshot_id, commit_extra_info
990
+ FROM ${ducklakeSnapshotChanges}
991
+ WHERE snapshot_id IN (${snapshotIds.join(", ")})
992
+ `)
993
+
994
+ for (const row of rows) {
995
+ const parsed = parseCommitMetadata(getOptionalString(row, "commit_extra_info"))
996
+ if (parsed !== undefined) {
997
+ metadata.set(String(getBigIntLike(row, "snapshot_id")), parsed)
998
+ }
999
+ }
1000
+
1001
+ return metadata
1002
+ }
1003
+
744
1004
  private candidateToSnapshotRow(
745
1005
  datasetId: string,
746
1006
  tableId: bigint,
747
1007
  candidate: DatasetSnapshotCandidateRow
748
1008
  ): DatasetSnapshotRow | null {
749
- const inlineChange = parseInlineDataChange(candidate.changesMade, tableId)
750
- const hasDataChange = inlineChange.hasDataChange || candidate.hasFileChange
751
- const hasDeleteChange = inlineChange.hasDeleteChange || candidate.hasFileDeleteChange
1009
+ const { hasDataChange, hasDeleteChange } = parseTableDataChange(candidate.changesMade, tableId)
752
1010
 
753
1011
  // Metadata-only snapshots are common for table comments, schema changes,
754
1012
  // and other catalog operations. Treat them as dataset versions only when
@@ -782,36 +1040,17 @@ export class DuckLakeSnapshotReader {
782
1040
  }
783
1041
  }
784
1042
 
785
- private async snapshotToVersion(
1043
+ /** One catalog read for the schemas of every listed version, not one per version. */
1044
+ private async snapshotsToVersions(
786
1045
  runtime: DuckDbQueryRuntime,
787
1046
  tableRef: DatasetTableRef,
788
- row: DatasetSnapshotRow
789
- ): Promise<DatasetVersion> {
790
- const schemaAtSnapshot = await this.datasets.getDatasetSchemaAtSnapshot(
1047
+ rows: readonly DatasetSnapshotRow[]
1048
+ ): Promise<DatasetVersion[]> {
1049
+ const schemas = await this.datasets.getDatasetSchemasAtSnapshots(
791
1050
  runtime,
792
- tableRef.datasetId,
793
- tableRef.tableName,
794
- tableRef.tableId,
795
- row.snapshotId
1051
+ rows.map((row) => ({ table: tableRef, snapshotId: row.snapshotId }))
796
1052
  )
797
-
798
- // DuckLake gives us version id, timestamp, and time travel. Sixb metadata
799
- // fills in caller intent such as append vs snapshot and producer lineage.
800
- // Version listing is a metadata path, so it only carries row counts already
801
- // stored in Sixb commit metadata and never counts historical table contents.
802
-
803
- return {
804
- datasetId: tableRef.datasetId,
805
- versionId: toVersionId(row.snapshotId),
806
- parentVersionId:
807
- row.parentSnapshotId === undefined ? undefined : toVersionId(row.parentSnapshotId),
808
- mode: row.mode,
809
- createdAt: row.createdAt,
810
- schema: schemaAtSnapshot,
811
- producer: row.metadata?.producer,
812
- inputs: row.metadata?.inputs,
813
- ...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
814
- }
1053
+ return rows.map((row, index) => snapshotToVersion(tableRef, row, schemas[index]))
815
1054
  }
816
1055
 
817
1056
  private withParentSnapshotIds(
@@ -829,12 +1068,68 @@ export class DuckLakeSnapshotReader {
829
1068
  }
830
1069
  }
831
1070
 
1071
+ function snapshotToVersion(
1072
+ tableRef: DatasetTableRef,
1073
+ row: DatasetSnapshotRow,
1074
+ schema: DatasetVersion["schema"]
1075
+ ): DatasetVersion {
1076
+ // DuckLake gives us version id, timestamp, and time travel. Sixb metadata
1077
+ // fills in caller intent such as append vs snapshot and producer lineage.
1078
+ // Version listing is a metadata path, so it only carries row counts already
1079
+ // stored in Sixb commit metadata and never counts historical table contents.
1080
+
1081
+ return {
1082
+ datasetId: tableRef.datasetId,
1083
+ versionId: toVersionId(row.snapshotId),
1084
+ parentVersionId:
1085
+ row.parentSnapshotId === undefined ? undefined : toVersionId(row.parentSnapshotId),
1086
+ mode: row.mode,
1087
+ createdAt: row.createdAt,
1088
+ schema,
1089
+ producer: row.metadata?.producer,
1090
+ inputs: row.metadata?.inputs,
1091
+ ...storedLineage(tableRef.datasetId, row),
1092
+ ...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
1093
+ }
1094
+ }
1095
+
1096
+ /**
1097
+ * A data snapshot without Sixb metadata was written outside Sixb, by any DuckLake client. It may
1098
+ * keep rows of its parent whatever change it made, and nothing says where its own rows come from:
1099
+ * its lineage is unknown.
1100
+ */
1101
+ function storedLineage(
1102
+ datasetId: string,
1103
+ row: DatasetSnapshotRow
1104
+ ): Pick<DatasetVersion, "lineage"> {
1105
+ const metadata = row.metadata
1106
+ if (metadata === undefined) {
1107
+ return {
1108
+ lineage: {
1109
+ unknown: `version '${toVersionId(row.snapshotId)}' of '${datasetId}' was written outside Sixb`,
1110
+ },
1111
+ }
1112
+ }
1113
+
1114
+ return metadata.lineage === undefined
1115
+ ? {}
1116
+ : { lineage: decodeLineage(metadata.datasetId, metadata.lineage) }
1117
+ }
1118
+
1119
+ function tableVersionRef(
1120
+ table: DatasetTableRef,
1121
+ snapshot: DatasetSnapshotRow
1122
+ ): DuckLakeTableVersionRef {
1123
+ return {
1124
+ datasetId: table.datasetId,
1125
+ versionId: toVersionId(snapshot.snapshotId),
1126
+ table,
1127
+ snapshot,
1128
+ }
1129
+ }
1130
+
832
1131
  function assertDuckLakeSnapshotId(snapshotId: string): void {
833
1132
  if (!/^\d+$/.test(snapshotId)) {
834
1133
  throw new LakeStorageError(`[SixbDuckLake] Invalid DuckLake snapshot id '${snapshotId}'.`)
835
1134
  }
836
1135
  }
837
-
838
- function fileChangeKey(snapshotId: string, tableId: bigint): string {
839
- return `${snapshotId}|${tableId}`
840
- }