@sixb/ducklake 0.1.8 → 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 (69) hide show
  1. package/README.md +59 -4
  2. package/dist/ducklake-storage.d.ts +1 -0
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +1817 -511
  5. package/dist/index.js.map +24 -14
  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 +63 -16
  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/versions.d.ts +10 -1
  44. package/dist/internal/versions.d.ts.map +1 -1
  45. package/dist/types.d.ts +10 -1
  46. package/dist/types.d.ts.map +1 -1
  47. package/package.json +4 -4
  48. package/src/ducklake-storage.ts +9 -2
  49. package/src/internal/dataset-row-commit.ts +28 -2
  50. package/src/internal/dataset-row-merge.ts +4 -0
  51. package/src/internal/ducklake-dataset-catalog.ts +162 -49
  52. package/src/internal/ducklake-dataset-table-ref.ts +75 -17
  53. package/src/internal/ducklake-maintenance.ts +110 -48
  54. package/src/internal/ducklake-snapshot-reader.ts +352 -304
  55. package/src/internal/ducklake-sql-executor.ts +214 -14
  56. package/src/internal/ducklake-version-transaction.ts +224 -0
  57. package/src/internal/ducklake-write-coordinator.ts +226 -148
  58. package/src/internal/lineage-encoding.ts +136 -0
  59. package/src/internal/sql-lineage/analysis.ts +257 -0
  60. package/src/internal/sql-lineage/expressions.ts +208 -0
  61. package/src/internal/sql-lineage/plan-json.ts +130 -0
  62. package/src/internal/sql-lineage/plan-relation.ts +59 -0
  63. package/src/internal/sql-lineage/plan-walker.ts +727 -0
  64. package/src/internal/sql-lineage/rejection.ts +16 -0
  65. package/src/internal/sql-lineage/statement-gate.ts +164 -0
  66. package/src/internal/sql-lineage/table-functions.ts +11 -0
  67. package/src/internal/sql-transform-relations.ts +90 -40
  68. package/src/internal/versions.ts +37 -14
  69. package/src/types.ts +11 -1
@@ -1,8 +1,10 @@
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,
8
10
  LatestVersionsSince,
@@ -10,22 +12,34 @@ import type {
10
12
  } from "@sixb/core/lake-storage"
11
13
  import { LakeStorageError } from "@sixb/core/lake-storage"
12
14
  import type { DuckLakeStorageOptions } from "../types"
13
- import { getBigIntLike, getBoolean, getDate, getOptionalString, getString } from "./duckdb-row"
15
+ import { getBigIntLike, getDate, getOptionalString, getString } from "./duckdb-row"
14
16
  import type { DuckDbQueryRuntime } from "./duckdb-runtime"
15
17
  import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
16
18
  import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
17
- import { type DatasetTableRef, resolveDatasetTableRef } from "./ducklake-dataset-table-ref"
18
- import { decodeDatasetTableName, encodeDatasetTableName } from "./names"
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"
19
32
  import { buildDuckLakeMetadataQuery, duckLakeMetadataTableName, quoteSqlString } from "./sql"
20
33
  import {
34
+ changesTouchTables,
21
35
  parseCommitMetadata,
22
- parseInlineDataChange,
36
+ parseTableDataChange,
23
37
  parseVersionId,
24
38
  type SixbCommitMetadata,
25
39
  toVersionId,
26
40
  } from "./versions"
27
41
 
28
- interface DatasetSnapshotRow {
42
+ export interface DatasetSnapshotRow {
29
43
  readonly snapshotId: string
30
44
  readonly tableId: bigint
31
45
  readonly createdAt: Date
@@ -38,8 +52,6 @@ interface DatasetSnapshotCandidateRow {
38
52
  readonly snapshotId: string
39
53
  readonly createdAt: Date
40
54
  readonly changesMade: string
41
- readonly hasFileChange: boolean
42
- readonly hasFileDeleteChange: boolean
43
55
  readonly metadata?: SixbCommitMetadata
44
56
  }
45
57
 
@@ -50,31 +62,34 @@ interface FeedTable {
50
62
  readonly startSnapshotId: bigint
51
63
  }
52
64
 
53
- interface MissingSnapshotCandidate {
54
- readonly snapshotId: string
55
- readonly missing: true
56
- }
57
-
58
65
  interface SnapshotCandidateQueryInput {
59
- readonly datasetId: string
60
- readonly tableId: bigint
66
+ readonly table: DatasetTableRef
61
67
  readonly exactSnapshotId?: string
62
68
  readonly beforeSnapshotId?: string
63
69
  readonly limit: number
64
70
  }
65
71
 
66
72
  interface VisibleSnapshotRowsInput {
67
- readonly datasetId: string
68
- readonly tableId: bigint
73
+ readonly table: DatasetTableRef
69
74
  readonly exactSnapshotId?: string
70
75
  readonly beforeSnapshotId?: string
71
76
  readonly visibleRowLimit?: number
72
77
  }
73
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. */
74
86
  export interface DuckLakeVersionSummary {
75
87
  readonly datasetId: string
76
88
  readonly versionId: string
89
+ readonly schema: DatasetSchema
90
+ readonly producer?: DatasetProducer
77
91
  readonly inputs?: readonly DatasetVersionRef[]
92
+ readonly lineage?: DatasetVersionLineage
78
93
  readonly rowCount?: number
79
94
  readonly validatedPrimaryKeyColumns?: readonly string[]
80
95
  }
@@ -84,19 +99,17 @@ interface CatalogScanSnapshot {
84
99
  readonly snapshotId: string
85
100
  readonly createdAt: Date
86
101
  readonly changesMade: string
87
- readonly metadata?: SixbCommitMetadata
88
- }
89
-
90
- /** Per (snapshot, table) file-change flags merged into the catalog scan. */
91
- interface FileChangeFlags {
92
- readonly hasFileChange: boolean
93
- readonly hasFileDeleteChange: boolean
94
102
  }
95
103
 
96
104
  const SNAPSHOT_ROW_BATCH_SIZE = 128
97
105
  // One feed read covers at most this many commits; the next read continues from its cursor.
98
106
  const FEED_PAGE_SIZE = 512
99
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
112
+
100
113
  // The bulk catalog scan shares one descending walk of recent snapshots across
101
114
  // all requested datasets, so its cost is bounded by this window rather than by
102
115
  // the dataset count. A dataset whose latest version is older than the window
@@ -113,8 +126,13 @@ const CATALOG_SNAPSHOT_SCAN_LIMIT = 512
113
126
  *
114
127
  * This class intentionally reads DuckLake metadata directly. The provider does
115
128
  * not keep a Sixb side table for versions; a Sixb version is a DuckLake
116
- * snapshot that either changed the dataset table or was explicitly tagged with
117
- * 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.
118
136
  */
119
137
  export class DuckLakeSnapshotReader {
120
138
  constructor(
@@ -189,11 +207,11 @@ export class DuckLakeSnapshotReader {
189
207
  datasetIds: readonly string[]
190
208
  ): Promise<readonly DatasetCatalogState[]> {
191
209
  const uniqueIds = [...new Set(datasetIds)]
192
- const tableIdsByDatasetId = await this.resolveDatasetTableIds(runtime, uniqueIds)
193
- const latestRowByDatasetId = await this.resolveLatestSnapshotRows(runtime, tableIdsByDatasetId)
210
+ const tablesByDatasetId = await this.resolveDatasetTables(runtime, uniqueIds)
211
+ const latestRowByDatasetId = await this.resolveLatestSnapshotRows(runtime, tablesByDatasetId)
194
212
 
195
213
  return uniqueIds.map((datasetId) => {
196
- if (!tableIdsByDatasetId.has(datasetId)) {
214
+ if (!tablesByDatasetId.has(datasetId)) {
197
215
  return { datasetId, materialized: false, latestVersion: null }
198
216
  }
199
217
 
@@ -339,16 +357,10 @@ export class DuckLakeSnapshotReader {
339
357
  `
340
358
  )
341
359
  )
342
- const fileFlags = await this.queryFileChangeFlags(
343
- runtime,
344
- snapshots.map((snapshot) => snapshot.snapshotId),
345
- tables.map((table) => table.tableId)
346
- )
347
360
  const changesBySnapshotId = new Map(
348
361
  changeRows.map((row) => [String(getBigIntLike(row, "snapshot_id")), row])
349
362
  )
350
363
  const tablesByTableId = new Map(tables.map((table) => [table.tableId.toString(), table]))
351
- const tablesByDatasetId = new Map(tables.map((table) => [table.datasetId, table]))
352
364
 
353
365
  for (const snapshot of snapshots) {
354
366
  const changes = changesBySnapshotId.get(snapshot.snapshotId)
@@ -358,29 +370,21 @@ export class DuckLakeSnapshotReader {
358
370
  const changesMade = getString(changes, "changes_made")
359
371
  const metadata = parseCommitMetadata(changes.commit_extra_info)
360
372
 
361
- // Only tables this snapshot names, touches through files, or tags can change visibility.
373
+ // Only the tables this snapshot names can have a version in it.
362
374
  const touched = new Set<FeedTable>()
363
375
  for (const change of changesMade.split(",")) {
364
376
  const table = tablesByTableId.get(change.split(":")[1] ?? "")
365
377
  if (table) touched.add(table)
366
378
  }
367
- for (const table of tables) {
368
- if (fileFlags.has(fileChangeKey(snapshot.snapshotId, table.tableId))) touched.add(table)
369
- }
370
- const tagged = metadata === undefined ? undefined : tablesByDatasetId.get(metadata.datasetId)
371
- if (tagged) touched.add(tagged)
372
379
 
373
380
  for (const table of touched) {
374
381
  if (BigInt(snapshot.snapshotId) < table.startSnapshotId) {
375
382
  continue
376
383
  }
377
- const flags = fileFlags.get(fileChangeKey(snapshot.snapshotId, table.tableId))
378
384
  const row = this.candidateToSnapshotRow(table.datasetId, table.tableId, {
379
385
  snapshotId: snapshot.snapshotId,
380
386
  createdAt: snapshot.createdAt,
381
387
  changesMade,
382
- hasFileChange: flags?.hasFileChange ?? false,
383
- hasFileDeleteChange: flags?.hasFileDeleteChange ?? false,
384
388
  ...(metadata !== undefined ? { metadata } : {}),
385
389
  })
386
390
  if (row && row.mode !== "schema") {
@@ -392,6 +396,33 @@ export class DuckLakeSnapshotReader {
392
396
  return latest
393
397
  }
394
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
+
395
426
  private snapshotRowToSummary(
396
427
  datasetId: string,
397
428
  row: DatasetSnapshotRow
@@ -405,19 +436,21 @@ export class DuckLakeSnapshotReader {
405
436
  }
406
437
  }
407
438
 
408
- private async resolveDatasetTableIds(
439
+ private async resolveDatasetTables(
409
440
  runtime: DuckDbQueryRuntime,
410
441
  datasetIds: readonly string[]
411
- ): Promise<Map<string, bigint>> {
442
+ ): Promise<Map<string, DatasetTableRef>> {
412
443
  // Encoded table names are a collision-free bijection with dataset ids, so a
413
- // 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.
414
445
  const datasetIdByTableName = new Map<string, string>()
446
+ const datasetIdBySequenceTableName = new Map<string, string>()
415
447
  for (const datasetId of datasetIds) {
416
448
  datasetIdByTableName.set(encodeDatasetTableName(datasetId), datasetId)
449
+ datasetIdBySequenceTableName.set(encodeDatasetSequenceTableName(datasetId), datasetId)
417
450
  }
418
451
 
419
452
  const ducklakeTable = duckLakeMetadataTableName(this.options, "ducklake_table")
420
- const tableNameList = [...datasetIdByTableName.keys()]
453
+ const tableNameList = [...datasetIdByTableName.keys(), ...datasetIdBySequenceTableName.keys()]
421
454
  .map((name) => quoteSqlString(name))
422
455
  .join(", ")
423
456
  const rows = await runtime.query(`
@@ -427,15 +460,31 @@ export class DuckLakeSnapshotReader {
427
460
  AND end_snapshot IS NULL
428
461
  `)
429
462
 
430
- const tableIdsByDatasetId = new Map<string, bigint>()
463
+ const tablesByDatasetId = new Map<string, DatasetTableRef>()
431
464
  for (const row of rows) {
432
- const datasetId = datasetIdByTableName.get(getString(row, "table_name"))
465
+ const tableName = getString(row, "table_name")
466
+ const datasetId = datasetIdByTableName.get(tableName)
433
467
  if (datasetId !== undefined) {
434
- tableIdsByDatasetId.set(datasetId, getBigIntLike(row, "table_id"))
468
+ tablesByDatasetId.set(datasetId, {
469
+ datasetId,
470
+ tableName,
471
+ tableId: getBigIntLike(row, "table_id"),
472
+ })
473
+ }
474
+ }
475
+
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
+ })
435
484
  }
436
485
  }
437
486
 
438
- return tableIdsByDatasetId
487
+ return tablesByDatasetId
439
488
  }
440
489
 
441
490
  /**
@@ -447,19 +496,19 @@ export class DuckLakeSnapshotReader {
447
496
  */
448
497
  private async resolveLatestSnapshotRows(
449
498
  runtime: DuckDbQueryRuntime,
450
- tableIdsByDatasetId: ReadonlyMap<string, bigint>
499
+ tablesByDatasetId: ReadonlyMap<string, DatasetTableRef>,
500
+ scanLimit = CATALOG_SNAPSHOT_SCAN_LIMIT
451
501
  ): Promise<Map<string, DatasetSnapshotRow>> {
452
502
  const result = new Map<string, DatasetSnapshotRow>()
453
- if (tableIdsByDatasetId.size === 0) {
503
+ if (tablesByDatasetId.size === 0) {
454
504
  return result
455
505
  }
456
506
 
457
- const unresolved = new Map<string, bigint>(tableIdsByDatasetId)
458
- const tableIds = [...new Set(tableIdsByDatasetId.values())]
507
+ const unresolved = new Map<string, DatasetTableRef>(tablesByDatasetId)
459
508
 
460
509
  let beforeSnapshotId: string | undefined
461
510
  let scanned = 0
462
- while (unresolved.size > 0 && scanned < CATALOG_SNAPSHOT_SCAN_LIMIT) {
511
+ while (unresolved.size > 0 && scanned < scanLimit) {
463
512
  const candidates = await this.queryCatalogSnapshotBatch(
464
513
  runtime,
465
514
  beforeSnapshotId,
@@ -469,23 +518,27 @@ export class DuckLakeSnapshotReader {
469
518
  break
470
519
  }
471
520
 
472
- const fileFlags = await this.queryFileChangeFlags(
521
+ const tables = [...unresolved.values()]
522
+ const metadata = await this.readCommitMetadata(
473
523
  runtime,
474
- candidates.map((candidate) => candidate.snapshotId),
475
- tableIds
524
+ candidates
525
+ .filter((candidate) =>
526
+ tables.some((table) =>
527
+ changesTouchTables(candidate.changesMade, datasetTableIds(table))
528
+ )
529
+ )
530
+ .map((candidate) => candidate.snapshotId)
476
531
  )
477
532
 
478
533
  for (const candidate of candidates) {
479
534
  scanned += 1
480
- for (const [datasetId, tableId] of [...unresolved]) {
481
- const flags = fileFlags.get(fileChangeKey(candidate.snapshotId, tableId))
482
- 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, {
483
538
  snapshotId: candidate.snapshotId,
484
539
  createdAt: candidate.createdAt,
485
540
  changesMade: candidate.changesMade,
486
- hasFileChange: flags?.hasFileChange ?? false,
487
- hasFileDeleteChange: flags?.hasFileDeleteChange ?? false,
488
- ...(candidate.metadata !== undefined ? { metadata: candidate.metadata } : {}),
541
+ ...(candidateMetadata !== undefined ? { metadata: candidateMetadata } : {}),
489
542
  })
490
543
  if (row) {
491
544
  result.set(datasetId, row)
@@ -528,8 +581,7 @@ export class DuckLakeSnapshotReader {
528
581
  SELECT
529
582
  snapshot.snapshot_id,
530
583
  snapshot.snapshot_time,
531
- changes.changes_made,
532
- changes.commit_extra_info
584
+ changes.changes_made
533
585
  FROM ${ducklakeSnapshot} snapshot
534
586
  JOIN ${ducklakeSnapshotChanges} changes ON changes.snapshot_id = snapshot.snapshot_id
535
587
  ${whereSql}
@@ -537,70 +589,11 @@ export class DuckLakeSnapshotReader {
537
589
  LIMIT ${Math.max(0, Math.trunc(limit))}
538
590
  `)
539
591
 
540
- return rows.map((row) => {
541
- const metadata = parseCommitMetadata(getOptionalString(row, "commit_extra_info"))
542
- return {
543
- snapshotId: String(getBigIntLike(row, "snapshot_id")),
544
- createdAt: getDate(row, "snapshot_time"),
545
- changesMade: getString(row, "changes_made"),
546
- ...(metadata !== undefined ? { metadata } : {}),
547
- }
548
- })
549
- }
550
-
551
- private async queryFileChangeFlags(
552
- runtime: DuckDbQueryRuntime,
553
- snapshotIds: readonly string[],
554
- tableIds: readonly bigint[]
555
- ): Promise<Map<string, FileChangeFlags>> {
556
- const flags = new Map<string, FileChangeFlags>()
557
- if (snapshotIds.length === 0 || tableIds.length === 0) {
558
- return flags
559
- }
560
-
561
- for (const snapshotId of snapshotIds) {
562
- assertDuckLakeSnapshotId(snapshotId)
563
- }
564
-
565
- const snapshotIdList = snapshotIds.join(", ")
566
- const tableIdList = tableIds.map((tableId) => tableId.toString()).join(", ")
567
- const ducklakeDataFile = duckLakeMetadataTableName(this.options, "ducklake_data_file")
568
- const ducklakeDeleteFile = duckLakeMetadataTableName(this.options, "ducklake_delete_file")
569
-
570
- // Large tables keep their changes in data/delete files instead of inline
571
- // `changes_made`, so merge file-level changes for the scanned snapshots.
572
- const rows = await runtime.query(`
573
- WITH file_changes AS (
574
- SELECT begin_snapshot AS snapshot_id, table_id, false AS is_delete
575
- FROM ${ducklakeDataFile}
576
- WHERE table_id IN (${tableIdList}) AND begin_snapshot IN (${snapshotIdList})
577
- UNION ALL
578
- SELECT end_snapshot, table_id, true
579
- FROM ${ducklakeDataFile}
580
- WHERE table_id IN (${tableIdList}) AND end_snapshot IN (${snapshotIdList})
581
- UNION ALL
582
- SELECT begin_snapshot, table_id, true
583
- FROM ${ducklakeDeleteFile}
584
- WHERE table_id IN (${tableIdList}) AND begin_snapshot IN (${snapshotIdList})
585
- UNION ALL
586
- SELECT end_snapshot, table_id, true
587
- FROM ${ducklakeDeleteFile}
588
- WHERE table_id IN (${tableIdList}) AND end_snapshot IN (${snapshotIdList})
589
- )
590
- SELECT snapshot_id, table_id, bool_or(is_delete) AS has_delete
591
- FROM file_changes
592
- GROUP BY snapshot_id, table_id
593
- `)
594
-
595
- for (const row of rows) {
596
- const key = fileChangeKey(
597
- String(getBigIntLike(row, "snapshot_id")),
598
- getBigIntLike(row, "table_id")
599
- )
600
- flags.set(key, { hasFileChange: true, hasFileDeleteChange: getBoolean(row, "has_delete") })
601
- }
602
-
603
- 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
+ }))
604
597
  }
605
598
 
606
599
  private assertNoMetadataConflict(
@@ -626,9 +619,14 @@ export class DuckLakeSnapshotReader {
626
619
  async getLatestVersionRefForDefinition(
627
620
  runtime: DuckDbQueryRuntime,
628
621
  dataset: DatasetDefinition
629
- ): Promise<DatasetVersionRef | null> {
630
- const summary = await this.getLatestVersionSummaryForDefinition(runtime, dataset)
631
- 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
632
630
  }
633
631
 
634
632
  async getLatestVersionSummaryForDefinition(
@@ -636,19 +634,49 @@ export class DuckLakeSnapshotReader {
636
634
  dataset: DatasetDefinition
637
635
  ): Promise<DuckLakeVersionSummary | null> {
638
636
  const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
639
- if (!tableRef) {
640
- 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,
641
657
  }
658
+ }
642
659
 
660
+ private async getLatestVersionSummaryForTableRef(
661
+ runtime: DuckDbQueryRuntime,
662
+ tableRef: DatasetTableRef
663
+ ): Promise<DuckLakeVersionSummary | null> {
643
664
  const row = await this.getLatestSnapshotRowForTableRef(runtime, tableRef)
644
665
  if (!row) {
645
666
  return null
646
667
  }
647
668
 
669
+ const [schema] = await this.datasets.getDatasetSchemasAtSnapshots(runtime, [
670
+ { table: tableRef, snapshotId: row.snapshotId },
671
+ ])
672
+
648
673
  return {
649
674
  datasetId: tableRef.datasetId,
650
675
  versionId: toVersionId(row.snapshotId),
676
+ schema,
677
+ producer: row.metadata?.producer,
651
678
  inputs: row.metadata?.inputs,
679
+ ...storedLineage(tableRef.datasetId, row),
652
680
  ...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
653
681
  ...(row.metadata?.validatedPrimaryKeyColumns !== undefined
654
682
  ? { validatedPrimaryKeyColumns: row.metadata.validatedPrimaryKeyColumns }
@@ -675,14 +703,14 @@ export class DuckLakeSnapshotReader {
675
703
  const match = await this.getSnapshotRowForTableRef(runtime, tableRef, snapshotId, {
676
704
  includeParent: true,
677
705
  })
678
- return match ? this.snapshotToVersion(runtime, tableRef, match) : null
706
+ return match ? (await this.snapshotsToVersions(runtime, tableRef, [match]))[0] : null
679
707
  }
680
708
 
681
709
  async getVersionRefForSnapshot(
682
710
  runtime: DuckDbQueryRuntime,
683
711
  dataset: DatasetDefinition,
684
712
  snapshotId: string
685
- ): Promise<DatasetVersionRef | null> {
713
+ ): Promise<DuckLakeTableVersionRef | null> {
686
714
  assertDuckLakeSnapshotId(snapshotId)
687
715
 
688
716
  const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
@@ -693,7 +721,23 @@ export class DuckLakeSnapshotReader {
693
721
  const row = await this.getSnapshotRowForTableRef(runtime, tableRef, snapshotId, {
694
722
  includeParent: false,
695
723
  })
696
- 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]))
697
741
  }
698
742
 
699
743
  async listVersionsForDefinition(
@@ -711,13 +755,7 @@ export class DuckLakeSnapshotReader {
711
755
  limit?: number
712
756
  ): Promise<readonly DatasetVersion[]> {
713
757
  const limitedRows = await this.getSnapshotRowsForTableRef(runtime, tableRef, limit)
714
- const versions: DatasetVersion[] = []
715
-
716
- for (const row of limitedRows) {
717
- versions.push(await this.snapshotToVersion(runtime, tableRef, row))
718
- }
719
-
720
- return versions
758
+ return this.snapshotsToVersions(runtime, tableRef, limitedRows)
721
759
  }
722
760
 
723
761
  async getLatestVersionForTableRef(
@@ -733,8 +771,7 @@ export class DuckLakeSnapshotReader {
733
771
  tableRef: DatasetTableRef
734
772
  ): Promise<DatasetSnapshotRow | null> {
735
773
  const [row] = await this.collectVisibleSnapshotRows(runtime, {
736
- datasetId: tableRef.datasetId,
737
- tableId: tableRef.tableId,
774
+ table: tableRef,
738
775
  visibleRowLimit: 1,
739
776
  })
740
777
  return row ?? null
@@ -749,8 +786,7 @@ export class DuckLakeSnapshotReader {
749
786
  assertDuckLakeSnapshotId(snapshotId)
750
787
 
751
788
  const [row] = await this.collectVisibleSnapshotRows(runtime, {
752
- datasetId: tableRef.datasetId,
753
- tableId: tableRef.tableId,
789
+ table: tableRef,
754
790
  exactSnapshotId: snapshotId,
755
791
  visibleRowLimit: 1,
756
792
  })
@@ -766,8 +802,7 @@ export class DuckLakeSnapshotReader {
766
802
  }
767
803
 
768
804
  const [parent] = await this.collectVisibleSnapshotRows(runtime, {
769
- datasetId: tableRef.datasetId,
770
- tableId: tableRef.tableId,
805
+ table: tableRef,
771
806
  beforeSnapshotId: snapshotId,
772
807
  visibleRowLimit: 1,
773
808
  })
@@ -786,8 +821,7 @@ export class DuckLakeSnapshotReader {
786
821
  }
787
822
 
788
823
  const rows = await this.collectVisibleSnapshotRows(runtime, {
789
- datasetId: tableRef.datasetId,
790
- tableId: tableRef.tableId,
824
+ table: tableRef,
791
825
  visibleRowLimit: visibleLimit === undefined ? undefined : visibleLimit + 1,
792
826
  })
793
827
  const rowsWithParents = this.withParentSnapshotIds(rows)
@@ -804,24 +838,29 @@ export class DuckLakeSnapshotReader {
804
838
 
805
839
  const snapshots: DatasetSnapshotRow[] = []
806
840
  let beforeSnapshotId = input.beforeSnapshotId
841
+ let shortRead =
842
+ input.exactSnapshotId !== undefined ||
843
+ (input.visibleRowLimit !== undefined && input.visibleRowLimit <= 2)
807
844
 
808
845
  while (input.visibleRowLimit === undefined || snapshots.length < input.visibleRowLimit) {
846
+ const batchSize = shortRead ? SHORT_READ_BATCH_SIZE : SNAPSHOT_ROW_BATCH_SIZE
809
847
  const candidates = await this.querySnapshotCandidates(runtime, {
810
- datasetId: input.datasetId,
811
- tableId: input.tableId,
848
+ table: input.table,
812
849
  exactSnapshotId: input.exactSnapshotId,
813
850
  beforeSnapshotId,
814
- limit: input.exactSnapshotId === undefined ? SNAPSHOT_ROW_BATCH_SIZE : 1,
851
+ limit: input.exactSnapshotId === undefined ? batchSize : 1,
815
852
  })
853
+ shortRead = false
816
854
  if (candidates.length === 0) {
817
855
  break
818
856
  }
819
857
 
820
858
  for (const candidate of candidates) {
821
- if ("missing" in candidate) {
822
- continue
823
- }
824
- 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
+ )
825
864
  if (snapshot) {
826
865
  snapshots.push(snapshot)
827
866
  }
@@ -831,7 +870,7 @@ export class DuckLakeSnapshotReader {
831
870
  }
832
871
  }
833
872
 
834
- if (input.exactSnapshotId !== undefined || candidates.length < SNAPSHOT_ROW_BATCH_SIZE) {
873
+ if (input.exactSnapshotId !== undefined || candidates.length < batchSize) {
835
874
  break
836
875
  }
837
876
 
@@ -842,20 +881,20 @@ export class DuckLakeSnapshotReader {
842
881
  }
843
882
 
844
883
  /**
845
- * Hydrates, newest first, the snapshots that may hold a version of one dataset table: those
846
- * that added or removed its data or delete files, that DuckLake recorded as changing it inline,
847
- * or whose Sixb commit metadata names the dataset. The text matches are a superset;
848
- * candidateToSnapshotRow decides visibility. Only this table's snapshots are read, so the cost
849
- * follows the dataset's own history rather than every commit in the catalog.
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.
850
887
  *
851
- * Returns one row per candidate id, including ids whose snapshot no longer exists, so the caller
852
- * can tell a short batch from the end of the history.
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.
853
893
  */
854
894
  private async querySnapshotCandidates(
855
895
  runtime: DuckDbQueryRuntime,
856
896
  input: SnapshotCandidateQueryInput
857
- ): Promise<readonly (DatasetSnapshotCandidateRow | MissingSnapshotCandidate)[]> {
858
- const tableId = input.tableId.toString()
897
+ ): Promise<readonly DatasetSnapshotCandidateRow[]> {
859
898
  const limit = Math.max(0, Math.trunc(input.limit))
860
899
  if (limit === 0) {
861
900
  return []
@@ -872,130 +911,102 @@ export class DuckLakeSnapshotReader {
872
911
  // A dataset cannot have a version before its table exists. Use the earliest table record:
873
912
  // a rename may create a newer record with the same table id and must not hide older
874
913
  // versions.
875
- const tableStart = `(SELECT min(begin_snapshot) FROM ${table("ducklake_table")} WHERE table_id = ${tableId})`
876
- const candidateIds =
877
- input.exactSnapshotId !== undefined
878
- ? `SELECT CAST(${input.exactSnapshotId} AS BIGINT) AS snapshot_id
879
- WHERE ${input.exactSnapshotId} >= ${tableStart}`
880
- : `SELECT DISTINCT snapshot_id
881
- FROM (
882
- SELECT begin_snapshot AS snapshot_id
883
- FROM ${table("ducklake_data_file")} WHERE table_id = ${tableId}
884
- UNION ALL
885
- SELECT end_snapshot
886
- FROM ${table("ducklake_data_file")}
887
- WHERE table_id = ${tableId} AND end_snapshot IS NOT NULL
888
- UNION ALL
889
- SELECT begin_snapshot
890
- FROM ${table("ducklake_delete_file")} WHERE table_id = ${tableId}
891
- UNION ALL
892
- SELECT end_snapshot
893
- FROM ${table("ducklake_delete_file")}
894
- WHERE table_id = ${tableId} AND end_snapshot IS NOT NULL
895
- UNION ALL
896
- SELECT snapshot_id
897
- FROM ${table("ducklake_snapshot_changes")}
898
- WHERE strpos(',' || changes_made || ',', ${quoteSqlString(`:${tableId},`)}) > 0
899
- OR strpos(
900
- commit_extra_info,
901
- ${quoteSqlString(`"datasetId":${JSON.stringify(input.datasetId)}`)}
902
- ) > 0
903
- ) touched
904
- WHERE snapshot_id >= ${tableStart}
905
- ${input.beforeSnapshotId === undefined ? "" : `AND snapshot_id < ${input.beforeSnapshotId}`}
906
- ORDER BY snapshot_id DESC
907
- LIMIT ${limit}`
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
+ }
908
927
 
909
- return `
910
- WITH candidate_ids AS (
911
- ${candidateIds}
912
- ),
913
- candidate_snapshots AS (
914
- SELECT
915
- snapshot.snapshot_id,
916
- snapshot.snapshot_time,
917
- changes.changes_made,
918
- changes.commit_extra_info
919
- FROM ${table("ducklake_snapshot")} snapshot
920
- JOIN ${table("ducklake_snapshot_changes")} changes
921
- ON changes.snapshot_id = snapshot.snapshot_id
922
- WHERE snapshot.snapshot_id IN (SELECT snapshot_id FROM candidate_ids)
923
- ),
924
- file_changes AS (
925
- -- File metadata tells whether this table changed in each candidate snapshot.
926
- SELECT begin_snapshot AS snapshot_id, false AS is_delete_change
927
- FROM ${table("ducklake_data_file")}
928
- WHERE table_id = ${tableId}
929
- AND begin_snapshot IN (SELECT snapshot_id FROM candidate_ids)
930
- UNION ALL
931
- SELECT end_snapshot AS snapshot_id, true AS is_delete_change
932
- FROM ${table("ducklake_data_file")}
933
- WHERE table_id = ${tableId}
934
- AND end_snapshot IN (SELECT snapshot_id FROM candidate_ids)
935
- UNION ALL
936
- SELECT begin_snapshot AS snapshot_id, true AS is_delete_change
937
- FROM ${table("ducklake_delete_file")}
938
- WHERE table_id = ${tableId}
939
- AND begin_snapshot IN (SELECT snapshot_id FROM candidate_ids)
940
- UNION ALL
941
- SELECT end_snapshot AS snapshot_id, true AS is_delete_change
942
- FROM ${table("ducklake_delete_file")}
943
- WHERE table_id = ${tableId}
944
- AND end_snapshot IN (SELECT snapshot_id FROM candidate_ids)
945
- ),
946
- file_changes_by_snapshot AS (
947
- -- Collapse file-level changes into one row per snapshot.
948
- SELECT
949
- snapshot_id,
950
- count(*) > 0 AS has_file_change,
951
- count(*) FILTER (WHERE is_delete_change) > 0 AS has_file_delete_change
952
- FROM file_changes
953
- GROUP BY snapshot_id
928
+ const namesDatasetTable = datasetTableIds(input.table)
929
+ .map(
930
+ (tableId) =>
931
+ `strpos(',' || changes.changes_made || ',', ${quoteSqlString(`:${tableId},`)}) > 0`
954
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 `
955
938
  SELECT
956
- -- Keep metadata-only candidates; Sixb filters them with commit_extra_info.
957
- candidate_ids.snapshot_id,
958
- candidate.snapshot_time,
959
- candidate.changes_made,
960
- candidate.commit_extra_info,
961
- coalesce(file_changes.has_file_change, false) AS has_file_change,
962
- coalesce(file_changes.has_file_delete_change, false) AS has_file_delete_change
963
- FROM candidate_ids
964
- LEFT JOIN candidate_snapshots candidate
965
- ON candidate.snapshot_id = candidate_ids.snapshot_id
966
- LEFT JOIN file_changes_by_snapshot file_changes
967
- ON file_changes.snapshot_id = candidate_ids.snapshot_id
968
- ORDER BY candidate_ids.snapshot_id DESC
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}
969
950
  `
970
951
  })
971
952
  )
972
953
 
973
954
  return rows.map((row) => {
974
- const snapshotId = String(getBigIntLike(row, "snapshot_id"))
975
- // Expired snapshots keep their file records; they are no longer versions.
976
- if (row.snapshot_time === null || row.snapshot_time === undefined) {
977
- return { snapshotId, missing: true } as const
978
- }
979
955
  const metadata = parseCommitMetadata(row.commit_extra_info)
956
+
980
957
  return {
981
- snapshotId,
958
+ snapshotId: String(getBigIntLike(row, "snapshot_id")),
982
959
  createdAt: getDate(row, "snapshot_time"),
983
960
  changesMade: getString(row, "changes_made"),
984
- hasFileChange: getBoolean(row, "has_file_change"),
985
- hasFileDeleteChange: getBoolean(row, "has_file_delete_change"),
986
961
  ...(metadata !== undefined ? { metadata } : {}),
987
962
  }
988
963
  })
989
964
  }
990
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
+
991
1004
  private candidateToSnapshotRow(
992
1005
  datasetId: string,
993
1006
  tableId: bigint,
994
1007
  candidate: DatasetSnapshotCandidateRow
995
1008
  ): DatasetSnapshotRow | null {
996
- const inlineChange = parseInlineDataChange(candidate.changesMade, tableId)
997
- const hasDataChange = inlineChange.hasDataChange || candidate.hasFileChange
998
- const hasDeleteChange = inlineChange.hasDeleteChange || candidate.hasFileDeleteChange
1009
+ const { hasDataChange, hasDeleteChange } = parseTableDataChange(candidate.changesMade, tableId)
999
1010
 
1000
1011
  // Metadata-only snapshots are common for table comments, schema changes,
1001
1012
  // and other catalog operations. Treat them as dataset versions only when
@@ -1029,36 +1040,17 @@ export class DuckLakeSnapshotReader {
1029
1040
  }
1030
1041
  }
1031
1042
 
1032
- private async snapshotToVersion(
1043
+ /** One catalog read for the schemas of every listed version, not one per version. */
1044
+ private async snapshotsToVersions(
1033
1045
  runtime: DuckDbQueryRuntime,
1034
1046
  tableRef: DatasetTableRef,
1035
- row: DatasetSnapshotRow
1036
- ): Promise<DatasetVersion> {
1037
- const schemaAtSnapshot = await this.datasets.getDatasetSchemaAtSnapshot(
1047
+ rows: readonly DatasetSnapshotRow[]
1048
+ ): Promise<DatasetVersion[]> {
1049
+ const schemas = await this.datasets.getDatasetSchemasAtSnapshots(
1038
1050
  runtime,
1039
- tableRef.datasetId,
1040
- tableRef.tableName,
1041
- tableRef.tableId,
1042
- row.snapshotId
1051
+ rows.map((row) => ({ table: tableRef, snapshotId: row.snapshotId }))
1043
1052
  )
1044
-
1045
- // DuckLake gives us version id, timestamp, and time travel. Sixb metadata
1046
- // fills in caller intent such as append vs snapshot and producer lineage.
1047
- // Version listing is a metadata path, so it only carries row counts already
1048
- // stored in Sixb commit metadata and never counts historical table contents.
1049
-
1050
- return {
1051
- datasetId: tableRef.datasetId,
1052
- versionId: toVersionId(row.snapshotId),
1053
- parentVersionId:
1054
- row.parentSnapshotId === undefined ? undefined : toVersionId(row.parentSnapshotId),
1055
- mode: row.mode,
1056
- createdAt: row.createdAt,
1057
- schema: schemaAtSnapshot,
1058
- producer: row.metadata?.producer,
1059
- inputs: row.metadata?.inputs,
1060
- ...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
1061
- }
1053
+ return rows.map((row, index) => snapshotToVersion(tableRef, row, schemas[index]))
1062
1054
  }
1063
1055
 
1064
1056
  private withParentSnapshotIds(
@@ -1076,12 +1068,68 @@ export class DuckLakeSnapshotReader {
1076
1068
  }
1077
1069
  }
1078
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
+
1079
1131
  function assertDuckLakeSnapshotId(snapshotId: string): void {
1080
1132
  if (!/^\d+$/.test(snapshotId)) {
1081
1133
  throw new LakeStorageError(`[SixbDuckLake] Invalid DuckLake snapshot id '${snapshotId}'.`)
1082
1134
  }
1083
1135
  }
1084
-
1085
- function fileChangeKey(snapshotId: string, tableId: bigint): string {
1086
- return `${snapshotId}|${tableId}`
1087
- }