@sixb/ducklake 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +490 -0
  3. package/dist/ducklake-storage.d.ts +42 -0
  4. package/dist/ducklake-storage.d.ts.map +1 -0
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +3112 -0
  8. package/dist/index.js.map +34 -0
  9. package/dist/internal/catalog-key.d.ts +11 -0
  10. package/dist/internal/catalog-key.d.ts.map +1 -0
  11. package/dist/internal/dataset-row-commit.d.ts +26 -0
  12. package/dist/internal/dataset-row-commit.d.ts.map +1 -0
  13. package/dist/internal/duckdb-column-types.d.ts +45 -0
  14. package/dist/internal/duckdb-column-types.d.ts.map +1 -0
  15. package/dist/internal/duckdb-decimal.d.ts +6 -0
  16. package/dist/internal/duckdb-decimal.d.ts.map +1 -0
  17. package/dist/internal/duckdb-row.d.ts +8 -0
  18. package/dist/internal/duckdb-row.d.ts.map +1 -0
  19. package/dist/internal/duckdb-runtime.d.ts +63 -0
  20. package/dist/internal/duckdb-runtime.d.ts.map +1 -0
  21. package/dist/internal/duckdb-value-converter.d.ts +7 -0
  22. package/dist/internal/duckdb-value-converter.d.ts.map +1 -0
  23. package/dist/internal/ducklake-catalog-schema.d.ts +12 -0
  24. package/dist/internal/ducklake-catalog-schema.d.ts.map +1 -0
  25. package/dist/internal/ducklake-connection-manager.d.ts +67 -0
  26. package/dist/internal/ducklake-connection-manager.d.ts.map +1 -0
  27. package/dist/internal/ducklake-current-dataset-definitions.d.ts +10 -0
  28. package/dist/internal/ducklake-current-dataset-definitions.d.ts.map +1 -0
  29. package/dist/internal/ducklake-dataset-catalog.d.ts +33 -0
  30. package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -0
  31. package/dist/internal/ducklake-dataset-table-ref.d.ts +9 -0
  32. package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -0
  33. package/dist/internal/ducklake-maintenance.d.ts +9 -0
  34. package/dist/internal/ducklake-maintenance.d.ts.map +1 -0
  35. package/dist/internal/ducklake-row-reader.d.ts +24 -0
  36. package/dist/internal/ducklake-row-reader.d.ts.map +1 -0
  37. package/dist/internal/ducklake-snapshot-reader.d.ts +73 -0
  38. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -0
  39. package/dist/internal/ducklake-sql-executor.d.ts +33 -0
  40. package/dist/internal/ducklake-sql-executor.d.ts.map +1 -0
  41. package/dist/internal/ducklake-write-coordinator.d.ts +53 -0
  42. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -0
  43. package/dist/internal/ducklake-write-session.d.ts +17 -0
  44. package/dist/internal/ducklake-write-session.d.ts.map +1 -0
  45. package/dist/internal/names.d.ts +13 -0
  46. package/dist/internal/names.d.ts.map +1 -0
  47. package/dist/internal/row-appender.d.ts +7 -0
  48. package/dist/internal/row-appender.d.ts.map +1 -0
  49. package/dist/internal/schema.d.ts +35 -0
  50. package/dist/internal/schema.d.ts.map +1 -0
  51. package/dist/internal/sql-transform-relations.d.ts +13 -0
  52. package/dist/internal/sql-transform-relations.d.ts.map +1 -0
  53. package/dist/internal/sql.d.ts +62 -0
  54. package/dist/internal/sql.d.ts.map +1 -0
  55. package/dist/internal/versions.d.ts +22 -0
  56. package/dist/internal/versions.d.ts.map +1 -0
  57. package/dist/tsconfig.json +5 -0
  58. package/dist/types.d.ts +147 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/package.json +64 -0
  61. package/src/ducklake-storage.ts +130 -0
  62. package/src/index.ts +14 -0
  63. package/src/internal/catalog-key.ts +28 -0
  64. package/src/internal/dataset-row-commit.ts +128 -0
  65. package/src/internal/duckdb-column-types.ts +68 -0
  66. package/src/internal/duckdb-decimal.ts +36 -0
  67. package/src/internal/duckdb-row.ts +83 -0
  68. package/src/internal/duckdb-runtime.ts +347 -0
  69. package/src/internal/duckdb-value-converter.ts +27 -0
  70. package/src/internal/ducklake-catalog-schema.ts +116 -0
  71. package/src/internal/ducklake-connection-manager.ts +510 -0
  72. package/src/internal/ducklake-current-dataset-definitions.ts +228 -0
  73. package/src/internal/ducklake-dataset-catalog.ts +363 -0
  74. package/src/internal/ducklake-dataset-table-ref.ts +35 -0
  75. package/src/internal/ducklake-maintenance.ts +116 -0
  76. package/src/internal/ducklake-row-reader.ts +143 -0
  77. package/src/internal/ducklake-snapshot-reader.ts +826 -0
  78. package/src/internal/ducklake-sql-executor.ts +334 -0
  79. package/src/internal/ducklake-write-coordinator.ts +532 -0
  80. package/src/internal/ducklake-write-session.ts +170 -0
  81. package/src/internal/names.ts +111 -0
  82. package/src/internal/row-appender.ts +73 -0
  83. package/src/internal/schema.ts +149 -0
  84. package/src/internal/sql-transform-relations.ts +99 -0
  85. package/src/internal/sql.ts +440 -0
  86. package/src/internal/versions.ts +140 -0
  87. package/src/types.ts +159 -0
@@ -0,0 +1,826 @@
1
+ import type { DatasetDefinition } from "@sixb/core"
2
+ import type {
3
+ DatasetCatalogState,
4
+ DatasetLatestVersionSummary,
5
+ DatasetVersion,
6
+ DatasetVersionMode,
7
+ DatasetVersionRef,
8
+ } from "@sixb/core/lake-storage"
9
+ import { LakeStorageError } from "@sixb/core/lake-storage"
10
+ import type { DuckLakeStorageOptions } from "../types"
11
+ import { getBigIntLike, getBoolean, getDate, getOptionalString, getString } from "./duckdb-row"
12
+ import type { DuckDbQueryRuntime } from "./duckdb-runtime"
13
+ import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
14
+ 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
+ import {
19
+ parseCommitMetadata,
20
+ parseInlineDataChange,
21
+ parseVersionId,
22
+ type SixbCommitMetadata,
23
+ toVersionId,
24
+ } from "./versions"
25
+
26
+ interface DatasetSnapshotRow {
27
+ readonly snapshotId: string
28
+ readonly tableId: bigint
29
+ readonly createdAt: Date
30
+ readonly mode: DatasetVersionMode
31
+ readonly parentSnapshotId?: string
32
+ readonly metadata?: SixbCommitMetadata
33
+ }
34
+
35
+ interface DatasetSnapshotCandidateRow {
36
+ readonly snapshotId: string
37
+ readonly createdAt: Date
38
+ readonly changesMade: string
39
+ readonly hasFileChange: boolean
40
+ readonly hasFileDeleteChange: boolean
41
+ readonly metadata?: SixbCommitMetadata
42
+ }
43
+
44
+ interface SnapshotCandidateQueryInput {
45
+ readonly tableId: bigint
46
+ readonly exactSnapshotId?: string
47
+ readonly beforeSnapshotId?: string
48
+ readonly limit?: number
49
+ }
50
+
51
+ interface VisibleSnapshotRowsInput {
52
+ readonly datasetId: string
53
+ readonly tableId: bigint
54
+ readonly exactSnapshotId?: string
55
+ readonly beforeSnapshotId?: string
56
+ readonly visibleRowLimit?: number
57
+ }
58
+
59
+ export interface DuckLakeVersionSummary {
60
+ readonly datasetId: string
61
+ readonly versionId: string
62
+ readonly rowCount?: number
63
+ }
64
+
65
+ /** One snapshot row from the shared catalog scan, before per-dataset filtering. */
66
+ interface CatalogScanSnapshot {
67
+ readonly snapshotId: string
68
+ readonly createdAt: Date
69
+ readonly changesMade: string
70
+ readonly metadata?: SixbCommitMetadata
71
+ }
72
+
73
+ /** Per (snapshot, table) file-change flags merged into the catalog scan. */
74
+ interface FileChangeFlags {
75
+ readonly hasFileChange: boolean
76
+ readonly hasFileDeleteChange: boolean
77
+ }
78
+
79
+ const SNAPSHOT_ROW_BATCH_SIZE = 128
80
+
81
+ // The bulk catalog scan shares one descending walk of recent snapshots across
82
+ // all requested datasets, so its cost is bounded by this window rather than by
83
+ // the dataset count. A dataset whose latest version is older than the window
84
+ // reports a null latest version, which is acceptable for a catalog summary; the
85
+ // detail routes still hydrate exact history.
86
+ const CATALOG_SNAPSHOT_SCAN_LIMIT = 512
87
+
88
+ /**
89
+ * Reconstructs Sixb DatasetVersion objects from DuckLake snapshots.
90
+ *
91
+ * DuckLake remains the source of truth for version ids, commit times, and
92
+ * historical reads. Sixb commit metadata only hydrates fields DuckLake does
93
+ * not know about, such as producer info and declared inputs.
94
+ *
95
+ * This class intentionally reads DuckLake metadata directly. The provider does
96
+ * not keep a Sixb side table for versions; a Sixb version is a DuckLake
97
+ * snapshot that either changed the dataset table or was explicitly tagged with
98
+ * Sixb dataset metadata.
99
+ */
100
+ export class DuckLakeSnapshotReader {
101
+ constructor(
102
+ private readonly options: DuckLakeStorageOptions,
103
+ private readonly connections: DuckLakeConnectionManager,
104
+ private readonly datasets: DuckLakeDatasetCatalog
105
+ ) {}
106
+
107
+ async listVersions(datasetId: string, limit?: number): Promise<readonly DatasetVersion[]> {
108
+ this.connections.assertOpen()
109
+
110
+ return this.connections.withAttachedRuntime(async (runtime) => {
111
+ const tableRef = await resolveDatasetTableRef(this.options, runtime, datasetId)
112
+ if (!tableRef) {
113
+ return []
114
+ }
115
+
116
+ return this.listVersionsForTableRef(runtime, tableRef, limit)
117
+ })
118
+ }
119
+
120
+ async getLatestVersion(datasetId: string): Promise<DatasetVersion | null> {
121
+ this.connections.assertOpen()
122
+
123
+ return this.connections.withAttachedRuntime(async (runtime) => {
124
+ const tableRef = await resolveDatasetTableRef(this.options, runtime, datasetId)
125
+ if (!tableRef) {
126
+ return null
127
+ }
128
+
129
+ return this.getLatestVersionForTableRef(runtime, tableRef)
130
+ })
131
+ }
132
+
133
+ async getVersion(datasetId: string, versionId: string): Promise<DatasetVersion | null> {
134
+ const snapshotId = parseVersionId(versionId)
135
+ this.connections.assertOpen()
136
+
137
+ return this.connections.withAttachedRuntime(async (runtime) => {
138
+ const tableRef = await resolveDatasetTableRef(this.options, runtime, datasetId)
139
+ if (!tableRef) {
140
+ return null
141
+ }
142
+
143
+ return this.getVersionForTableRef(runtime, tableRef, snapshotId)
144
+ })
145
+ }
146
+
147
+ /**
148
+ * Bulk catalog-summary read used by the dataset list view.
149
+ *
150
+ * Resolves materialized state and a lightweight latest-version summary for
151
+ * many datasets with a bounded number of metadata queries, never one snapshot
152
+ * scan per dataset and never a count(*) over dataset contents.
153
+ */
154
+ async listDatasetCatalogState(
155
+ datasetIds: readonly string[]
156
+ ): Promise<readonly DatasetCatalogState[]> {
157
+ this.connections.assertOpen()
158
+
159
+ if (datasetIds.length === 0) {
160
+ return []
161
+ }
162
+
163
+ return this.connections.withAttachedRuntime((runtime) =>
164
+ this.collectDatasetCatalogState(runtime, datasetIds)
165
+ )
166
+ }
167
+
168
+ private async collectDatasetCatalogState(
169
+ runtime: DuckDbQueryRuntime,
170
+ datasetIds: readonly string[]
171
+ ): Promise<readonly DatasetCatalogState[]> {
172
+ const uniqueIds = [...new Set(datasetIds)]
173
+ const tableIdsByDatasetId = await this.resolveDatasetTableIds(runtime, uniqueIds)
174
+ const latestRowByDatasetId = await this.resolveLatestSnapshotRows(runtime, tableIdsByDatasetId)
175
+
176
+ return uniqueIds.map((datasetId) => {
177
+ if (!tableIdsByDatasetId.has(datasetId)) {
178
+ return { datasetId, materialized: false, latestVersion: null }
179
+ }
180
+
181
+ const row = latestRowByDatasetId.get(datasetId)
182
+ return {
183
+ datasetId,
184
+ materialized: true,
185
+ latestVersion: row ? this.snapshotRowToSummary(datasetId, row) : null,
186
+ }
187
+ })
188
+ }
189
+
190
+ private snapshotRowToSummary(
191
+ datasetId: string,
192
+ row: DatasetSnapshotRow
193
+ ): DatasetLatestVersionSummary {
194
+ return {
195
+ datasetId,
196
+ versionId: toVersionId(row.snapshotId),
197
+ mode: row.mode,
198
+ createdAt: row.createdAt,
199
+ ...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
200
+ }
201
+ }
202
+
203
+ private async resolveDatasetTableIds(
204
+ runtime: DuckDbQueryRuntime,
205
+ datasetIds: readonly string[]
206
+ ): Promise<Map<string, bigint>> {
207
+ // Encoded table names are a collision-free bijection with dataset ids, so a
208
+ // single `ducklake_table` read maps every requested id to its current table.
209
+ const datasetIdByTableName = new Map<string, string>()
210
+ for (const datasetId of datasetIds) {
211
+ datasetIdByTableName.set(encodeDatasetTableName(datasetId), datasetId)
212
+ }
213
+
214
+ const ducklakeTable = duckLakeMetadataTableName(this.options, "ducklake_table")
215
+ const tableNameList = [...datasetIdByTableName.keys()]
216
+ .map((name) => quoteSqlString(name))
217
+ .join(", ")
218
+ const rows = await runtime.query(`
219
+ SELECT table_id, table_name
220
+ FROM ${ducklakeTable}
221
+ WHERE table_name IN (${tableNameList})
222
+ AND end_snapshot IS NULL
223
+ `)
224
+
225
+ const tableIdsByDatasetId = new Map<string, bigint>()
226
+ for (const row of rows) {
227
+ const datasetId = datasetIdByTableName.get(getString(row, "table_name"))
228
+ if (datasetId !== undefined) {
229
+ tableIdsByDatasetId.set(datasetId, getBigIntLike(row, "table_id"))
230
+ }
231
+ }
232
+
233
+ return tableIdsByDatasetId
234
+ }
235
+
236
+ /**
237
+ * Resolve each dataset's latest snapshot row with one shared descending walk.
238
+ *
239
+ * The walk reuses {@link candidateToSnapshotRow} so Sixb visibility, mode
240
+ * derivation, and the loud conflict rule match exact version hydration. Cost
241
+ * is bounded by the snapshot window, not by the number of datasets.
242
+ */
243
+ private async resolveLatestSnapshotRows(
244
+ runtime: DuckDbQueryRuntime,
245
+ tableIdsByDatasetId: ReadonlyMap<string, bigint>
246
+ ): Promise<Map<string, DatasetSnapshotRow>> {
247
+ const result = new Map<string, DatasetSnapshotRow>()
248
+ if (tableIdsByDatasetId.size === 0) {
249
+ return result
250
+ }
251
+
252
+ const unresolved = new Map<string, bigint>(tableIdsByDatasetId)
253
+ const tableIds = [...new Set(tableIdsByDatasetId.values())]
254
+
255
+ let beforeSnapshotId: string | undefined
256
+ let scanned = 0
257
+ while (unresolved.size > 0 && scanned < CATALOG_SNAPSHOT_SCAN_LIMIT) {
258
+ const candidates = await this.queryCatalogSnapshotBatch(
259
+ runtime,
260
+ beforeSnapshotId,
261
+ SNAPSHOT_ROW_BATCH_SIZE
262
+ )
263
+ if (candidates.length === 0) {
264
+ break
265
+ }
266
+
267
+ const fileFlags = await this.queryFileChangeFlags(
268
+ runtime,
269
+ candidates.map((candidate) => candidate.snapshotId),
270
+ tableIds
271
+ )
272
+
273
+ for (const candidate of candidates) {
274
+ scanned += 1
275
+ for (const [datasetId, tableId] of [...unresolved]) {
276
+ const flags = fileFlags.get(fileChangeKey(candidate.snapshotId, tableId))
277
+ const row = this.candidateToSnapshotRow(datasetId, tableId, {
278
+ snapshotId: candidate.snapshotId,
279
+ createdAt: candidate.createdAt,
280
+ changesMade: candidate.changesMade,
281
+ hasFileChange: flags?.hasFileChange ?? false,
282
+ hasFileDeleteChange: flags?.hasFileDeleteChange ?? false,
283
+ ...(candidate.metadata !== undefined ? { metadata: candidate.metadata } : {}),
284
+ })
285
+ if (row) {
286
+ result.set(datasetId, row)
287
+ unresolved.delete(datasetId)
288
+ }
289
+ }
290
+
291
+ if (unresolved.size === 0) {
292
+ break
293
+ }
294
+ }
295
+
296
+ if (candidates.length < SNAPSHOT_ROW_BATCH_SIZE) {
297
+ break
298
+ }
299
+ beforeSnapshotId = candidates[candidates.length - 1]?.snapshotId
300
+ }
301
+
302
+ return result
303
+ }
304
+
305
+ private async queryCatalogSnapshotBatch(
306
+ runtime: DuckDbQueryRuntime,
307
+ beforeSnapshotId: string | undefined,
308
+ limit: number
309
+ ): Promise<readonly CatalogScanSnapshot[]> {
310
+ const ducklakeSnapshot = duckLakeMetadataTableName(this.options, "ducklake_snapshot")
311
+ const ducklakeSnapshotChanges = duckLakeMetadataTableName(
312
+ this.options,
313
+ "ducklake_snapshot_changes"
314
+ )
315
+
316
+ let whereSql = ""
317
+ if (beforeSnapshotId !== undefined) {
318
+ assertDuckLakeSnapshotId(beforeSnapshotId)
319
+ whereSql = `WHERE snapshot.snapshot_id < ${beforeSnapshotId}`
320
+ }
321
+
322
+ const rows = await runtime.query(`
323
+ SELECT
324
+ snapshot.snapshot_id,
325
+ snapshot.snapshot_time,
326
+ changes.changes_made,
327
+ changes.commit_extra_info
328
+ FROM ${ducklakeSnapshot} snapshot
329
+ JOIN ${ducklakeSnapshotChanges} changes ON changes.snapshot_id = snapshot.snapshot_id
330
+ ${whereSql}
331
+ ORDER BY snapshot.snapshot_id DESC
332
+ LIMIT ${Math.max(0, Math.trunc(limit))}
333
+ `)
334
+
335
+ return rows.map((row) => {
336
+ const metadata = parseCommitMetadata(getOptionalString(row, "commit_extra_info"))
337
+ return {
338
+ snapshotId: String(getBigIntLike(row, "snapshot_id")),
339
+ createdAt: getDate(row, "snapshot_time"),
340
+ changesMade: getString(row, "changes_made"),
341
+ ...(metadata !== undefined ? { metadata } : {}),
342
+ }
343
+ })
344
+ }
345
+
346
+ private async queryFileChangeFlags(
347
+ runtime: DuckDbQueryRuntime,
348
+ snapshotIds: readonly string[],
349
+ tableIds: readonly bigint[]
350
+ ): Promise<Map<string, FileChangeFlags>> {
351
+ const flags = new Map<string, FileChangeFlags>()
352
+ if (snapshotIds.length === 0 || tableIds.length === 0) {
353
+ return flags
354
+ }
355
+
356
+ for (const snapshotId of snapshotIds) {
357
+ assertDuckLakeSnapshotId(snapshotId)
358
+ }
359
+
360
+ const snapshotIdList = snapshotIds.join(", ")
361
+ const tableIdList = tableIds.map((tableId) => tableId.toString()).join(", ")
362
+ const ducklakeDataFile = duckLakeMetadataTableName(this.options, "ducklake_data_file")
363
+ const ducklakeDeleteFile = duckLakeMetadataTableName(this.options, "ducklake_delete_file")
364
+
365
+ // Large tables keep their changes in data/delete files instead of inline
366
+ // `changes_made`, so merge file-level changes for the scanned snapshots.
367
+ const rows = await runtime.query(`
368
+ WITH file_changes AS (
369
+ SELECT begin_snapshot AS snapshot_id, table_id, false AS is_delete
370
+ FROM ${ducklakeDataFile}
371
+ WHERE table_id IN (${tableIdList}) AND begin_snapshot IN (${snapshotIdList})
372
+ UNION ALL
373
+ SELECT end_snapshot, table_id, true
374
+ FROM ${ducklakeDataFile}
375
+ WHERE table_id IN (${tableIdList}) AND end_snapshot IN (${snapshotIdList})
376
+ UNION ALL
377
+ SELECT begin_snapshot, table_id, true
378
+ FROM ${ducklakeDeleteFile}
379
+ WHERE table_id IN (${tableIdList}) AND begin_snapshot IN (${snapshotIdList})
380
+ UNION ALL
381
+ SELECT end_snapshot, table_id, true
382
+ FROM ${ducklakeDeleteFile}
383
+ WHERE table_id IN (${tableIdList}) AND end_snapshot IN (${snapshotIdList})
384
+ )
385
+ SELECT snapshot_id, table_id, bool_or(is_delete) AS has_delete
386
+ FROM file_changes
387
+ GROUP BY snapshot_id, table_id
388
+ `)
389
+
390
+ for (const row of rows) {
391
+ const key = fileChangeKey(
392
+ String(getBigIntLike(row, "snapshot_id")),
393
+ getBigIntLike(row, "table_id")
394
+ )
395
+ flags.set(key, { hasFileChange: true, hasFileDeleteChange: getBoolean(row, "has_delete") })
396
+ }
397
+
398
+ return flags
399
+ }
400
+
401
+ private assertNoMetadataConflict(
402
+ snapshotId: string,
403
+ datasetId: string,
404
+ metadata: SixbCommitMetadata | undefined
405
+ ): void {
406
+ if (metadata !== undefined && metadata.datasetId !== datasetId) {
407
+ throw new LakeStorageError(
408
+ `[SixbDuckLake] DuckLake snapshot '${snapshotId}' changed dataset '${datasetId}' but Sixb commit metadata references dataset '${metadata.datasetId}'.`
409
+ )
410
+ }
411
+ }
412
+
413
+ async getLatestVersionForDefinition(
414
+ runtime: DuckDbQueryRuntime,
415
+ dataset: DatasetDefinition
416
+ ): Promise<DatasetVersion | null> {
417
+ const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
418
+ return tableRef ? this.getLatestVersionForTableRef(runtime, tableRef) : null
419
+ }
420
+
421
+ async getLatestVersionRefForDefinition(
422
+ runtime: DuckDbQueryRuntime,
423
+ dataset: DatasetDefinition
424
+ ): Promise<DatasetVersionRef | null> {
425
+ const summary = await this.getLatestVersionSummaryForDefinition(runtime, dataset)
426
+ return summary ? { datasetId: summary.datasetId, versionId: summary.versionId } : null
427
+ }
428
+
429
+ async getLatestVersionSummaryForDefinition(
430
+ runtime: DuckDbQueryRuntime,
431
+ dataset: DatasetDefinition
432
+ ): Promise<DuckLakeVersionSummary | null> {
433
+ const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
434
+ if (!tableRef) {
435
+ return null
436
+ }
437
+
438
+ const row = await this.getLatestSnapshotRowForTableRef(runtime, tableRef)
439
+ if (!row) {
440
+ return null
441
+ }
442
+
443
+ return {
444
+ datasetId: tableRef.datasetId,
445
+ versionId: toVersionId(row.snapshotId),
446
+ ...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
447
+ }
448
+ }
449
+
450
+ async getVersionForSnapshot(
451
+ runtime: DuckDbQueryRuntime,
452
+ dataset: DatasetDefinition,
453
+ snapshotId: string
454
+ ): Promise<DatasetVersion | null> {
455
+ assertDuckLakeSnapshotId(snapshotId)
456
+
457
+ const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
458
+ return tableRef ? this.getVersionForTableRef(runtime, tableRef, snapshotId) : null
459
+ }
460
+
461
+ async getVersionForTableRef(
462
+ runtime: DuckDbQueryRuntime,
463
+ tableRef: DatasetTableRef,
464
+ snapshotId: string
465
+ ): Promise<DatasetVersion | null> {
466
+ const match = await this.getSnapshotRowForTableRef(runtime, tableRef, snapshotId, {
467
+ includeParent: true,
468
+ })
469
+ return match ? this.snapshotToVersion(runtime, tableRef, match) : null
470
+ }
471
+
472
+ async getVersionRefForSnapshot(
473
+ runtime: DuckDbQueryRuntime,
474
+ dataset: DatasetDefinition,
475
+ snapshotId: string
476
+ ): Promise<DatasetVersionRef | null> {
477
+ assertDuckLakeSnapshotId(snapshotId)
478
+
479
+ const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
480
+ if (!tableRef) {
481
+ return null
482
+ }
483
+
484
+ const row = await this.getSnapshotRowForTableRef(runtime, tableRef, snapshotId, {
485
+ includeParent: false,
486
+ })
487
+ return row ? { datasetId: tableRef.datasetId, versionId: toVersionId(row.snapshotId) } : null
488
+ }
489
+
490
+ async listVersionsForDefinition(
491
+ runtime: DuckDbQueryRuntime,
492
+ dataset: DatasetDefinition,
493
+ limit?: number
494
+ ): Promise<readonly DatasetVersion[]> {
495
+ const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
496
+ return tableRef ? this.listVersionsForTableRef(runtime, tableRef, limit) : []
497
+ }
498
+
499
+ private async listVersionsForTableRef(
500
+ runtime: DuckDbQueryRuntime,
501
+ tableRef: DatasetTableRef,
502
+ limit?: number
503
+ ): Promise<readonly DatasetVersion[]> {
504
+ const limitedRows = await this.getSnapshotRowsForTableRef(runtime, tableRef, limit)
505
+ const versions: DatasetVersion[] = []
506
+
507
+ for (const row of limitedRows) {
508
+ versions.push(await this.snapshotToVersion(runtime, tableRef, row))
509
+ }
510
+
511
+ return versions
512
+ }
513
+
514
+ async getLatestVersionForTableRef(
515
+ runtime: DuckDbQueryRuntime,
516
+ tableRef: DatasetTableRef
517
+ ): Promise<DatasetVersion | null> {
518
+ const [latest] = await this.listVersionsForTableRef(runtime, tableRef, 1)
519
+ return latest ?? null
520
+ }
521
+
522
+ private async getLatestSnapshotRowForTableRef(
523
+ runtime: DuckDbQueryRuntime,
524
+ tableRef: DatasetTableRef
525
+ ): Promise<DatasetSnapshotRow | null> {
526
+ const [row] = await this.collectVisibleSnapshotRows(runtime, {
527
+ datasetId: tableRef.datasetId,
528
+ tableId: tableRef.tableId,
529
+ visibleRowLimit: 1,
530
+ })
531
+ return row ?? null
532
+ }
533
+
534
+ private async getSnapshotRowForTableRef(
535
+ runtime: DuckDbQueryRuntime,
536
+ tableRef: DatasetTableRef,
537
+ snapshotId: string,
538
+ options: { readonly includeParent: boolean }
539
+ ): Promise<DatasetSnapshotRow | null> {
540
+ assertDuckLakeSnapshotId(snapshotId)
541
+
542
+ const [row] = await this.collectVisibleSnapshotRows(runtime, {
543
+ datasetId: tableRef.datasetId,
544
+ tableId: tableRef.tableId,
545
+ exactSnapshotId: snapshotId,
546
+ visibleRowLimit: 1,
547
+ })
548
+ if (!row) {
549
+ return null
550
+ }
551
+
552
+ if (!options.includeParent || (row.mode !== "append" && row.mode !== "schema")) {
553
+ return row
554
+ }
555
+
556
+ const [parent] = await this.collectVisibleSnapshotRows(runtime, {
557
+ datasetId: tableRef.datasetId,
558
+ tableId: tableRef.tableId,
559
+ beforeSnapshotId: snapshotId,
560
+ visibleRowLimit: 1,
561
+ })
562
+
563
+ return parent ? { ...row, parentSnapshotId: parent.snapshotId } : row
564
+ }
565
+
566
+ private async getSnapshotRowsForTableRef(
567
+ runtime: DuckDbQueryRuntime,
568
+ tableRef: DatasetTableRef,
569
+ limit?: number
570
+ ): Promise<readonly DatasetSnapshotRow[]> {
571
+ const visibleLimit = limit === undefined ? undefined : Math.max(0, limit)
572
+ if (visibleLimit === 0) {
573
+ return []
574
+ }
575
+
576
+ const rows = await this.collectVisibleSnapshotRows(runtime, {
577
+ datasetId: tableRef.datasetId,
578
+ tableId: tableRef.tableId,
579
+ visibleRowLimit: visibleLimit === undefined ? undefined : visibleLimit + 1,
580
+ })
581
+ const rowsWithParents = this.withParentSnapshotIds(rows)
582
+ return visibleLimit === undefined ? rowsWithParents : rowsWithParents.slice(0, visibleLimit)
583
+ }
584
+
585
+ private async collectVisibleSnapshotRows(
586
+ runtime: DuckDbQueryRuntime,
587
+ input: VisibleSnapshotRowsInput
588
+ ): Promise<readonly DatasetSnapshotRow[]> {
589
+ if (input.visibleRowLimit !== undefined && input.visibleRowLimit <= 0) {
590
+ return []
591
+ }
592
+
593
+ const snapshots: DatasetSnapshotRow[] = []
594
+ let beforeSnapshotId = input.beforeSnapshotId
595
+
596
+ while (input.visibleRowLimit === undefined || snapshots.length < input.visibleRowLimit) {
597
+ const candidates = await this.querySnapshotCandidates(runtime, {
598
+ tableId: input.tableId,
599
+ exactSnapshotId: input.exactSnapshotId,
600
+ beforeSnapshotId,
601
+ limit: input.exactSnapshotId === undefined ? SNAPSHOT_ROW_BATCH_SIZE : 1,
602
+ })
603
+ if (candidates.length === 0) {
604
+ break
605
+ }
606
+
607
+ for (const candidate of candidates) {
608
+ const snapshot = this.candidateToSnapshotRow(input.datasetId, input.tableId, candidate)
609
+ if (snapshot) {
610
+ snapshots.push(snapshot)
611
+ }
612
+
613
+ if (input.visibleRowLimit !== undefined && snapshots.length >= input.visibleRowLimit) {
614
+ break
615
+ }
616
+ }
617
+
618
+ if (input.exactSnapshotId !== undefined || candidates.length < SNAPSHOT_ROW_BATCH_SIZE) {
619
+ break
620
+ }
621
+
622
+ beforeSnapshotId = candidates[candidates.length - 1]?.snapshotId
623
+ }
624
+
625
+ return snapshots
626
+ }
627
+
628
+ private async querySnapshotCandidates(
629
+ runtime: DuckDbQueryRuntime,
630
+ input: SnapshotCandidateQueryInput
631
+ ): Promise<readonly DatasetSnapshotCandidateRow[]> {
632
+ if (input.limit !== undefined && input.limit <= 0) {
633
+ return []
634
+ }
635
+
636
+ const where: string[] = []
637
+ if (input.exactSnapshotId !== undefined) {
638
+ assertDuckLakeSnapshotId(input.exactSnapshotId)
639
+ where.push(`snapshot.snapshot_id = ${input.exactSnapshotId}`)
640
+ }
641
+ if (input.beforeSnapshotId !== undefined) {
642
+ assertDuckLakeSnapshotId(input.beforeSnapshotId)
643
+ where.push(`snapshot.snapshot_id < ${input.beforeSnapshotId}`)
644
+ }
645
+
646
+ const whereSql = where.length === 0 ? "" : `WHERE ${where.join(" AND ")}`
647
+ const limitSql =
648
+ input.limit === undefined ? "" : `LIMIT ${Math.max(0, Math.trunc(input.limit))}`
649
+ const ducklakeSnapshot = duckLakeMetadataTableName(this.options, "ducklake_snapshot")
650
+ const ducklakeSnapshotChanges = duckLakeMetadataTableName(
651
+ this.options,
652
+ "ducklake_snapshot_changes"
653
+ )
654
+ const ducklakeDataFile = duckLakeMetadataTableName(this.options, "ducklake_data_file")
655
+ const ducklakeDeleteFile = duckLakeMetadataTableName(this.options, "ducklake_delete_file")
656
+
657
+ const rows = await runtime.query(`
658
+ WITH candidate_snapshots AS (
659
+ -- Recent DuckLake snapshots to inspect; Sixb visibility is filtered later.
660
+ SELECT
661
+ snapshot.snapshot_id,
662
+ snapshot.snapshot_time,
663
+ changes.changes_made,
664
+ changes.commit_extra_info
665
+ FROM ${ducklakeSnapshot} snapshot
666
+ JOIN ${ducklakeSnapshotChanges} changes
667
+ ON changes.snapshot_id = snapshot.snapshot_id
668
+ ${whereSql}
669
+ ORDER BY snapshot.snapshot_id DESC
670
+ ${limitSql}
671
+ ),
672
+ file_changes AS (
673
+ -- File metadata tells whether this table changed in each candidate snapshot.
674
+ SELECT begin_snapshot AS snapshot_id, false AS is_delete_change
675
+ FROM ${ducklakeDataFile}
676
+ WHERE table_id = ${input.tableId}
677
+ AND begin_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
678
+ UNION ALL
679
+ SELECT end_snapshot AS snapshot_id, true AS is_delete_change
680
+ FROM ${ducklakeDataFile}
681
+ WHERE table_id = ${input.tableId}
682
+ AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
683
+ UNION ALL
684
+ SELECT begin_snapshot AS snapshot_id, true AS is_delete_change
685
+ FROM ${ducklakeDeleteFile}
686
+ WHERE table_id = ${input.tableId}
687
+ AND begin_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
688
+ UNION ALL
689
+ SELECT end_snapshot AS snapshot_id, true AS is_delete_change
690
+ FROM ${ducklakeDeleteFile}
691
+ WHERE table_id = ${input.tableId}
692
+ AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
693
+ ),
694
+ file_changes_by_snapshot AS (
695
+ -- Collapse file-level changes into one row per snapshot.
696
+ SELECT
697
+ snapshot_id,
698
+ count(*) > 0 AS has_file_change,
699
+ count(*) FILTER (WHERE is_delete_change) > 0 AS has_file_delete_change
700
+ FROM file_changes
701
+ GROUP BY snapshot_id
702
+ )
703
+ SELECT
704
+ -- Keep metadata-only candidates; Sixb filters them with commit_extra_info.
705
+ candidate.snapshot_id,
706
+ candidate.snapshot_time,
707
+ candidate.changes_made,
708
+ candidate.commit_extra_info,
709
+ coalesce(file_changes.has_file_change, false) AS has_file_change,
710
+ coalesce(file_changes.has_file_delete_change, false) AS has_file_delete_change
711
+ FROM candidate_snapshots candidate
712
+ LEFT JOIN file_changes_by_snapshot file_changes
713
+ ON file_changes.snapshot_id = candidate.snapshot_id
714
+ ORDER BY candidate.snapshot_id DESC
715
+ `)
716
+
717
+ return rows.map((row) => {
718
+ const metadata = parseCommitMetadata(row.commit_extra_info)
719
+ return {
720
+ snapshotId: String(getBigIntLike(row, "snapshot_id")),
721
+ createdAt: getDate(row, "snapshot_time"),
722
+ changesMade: getString(row, "changes_made"),
723
+ hasFileChange: getBoolean(row, "has_file_change"),
724
+ hasFileDeleteChange: getBoolean(row, "has_file_delete_change"),
725
+ ...(metadata !== undefined ? { metadata } : {}),
726
+ }
727
+ })
728
+ }
729
+
730
+ private candidateToSnapshotRow(
731
+ datasetId: string,
732
+ tableId: bigint,
733
+ candidate: DatasetSnapshotCandidateRow
734
+ ): DatasetSnapshotRow | null {
735
+ const inlineChange = parseInlineDataChange(candidate.changesMade, tableId)
736
+ const hasDataChange = inlineChange.hasDataChange || candidate.hasFileChange
737
+ const hasDeleteChange = inlineChange.hasDeleteChange || candidate.hasFileDeleteChange
738
+
739
+ // Metadata-only snapshots are common for table comments, schema changes,
740
+ // and other catalog operations. Treat them as dataset versions only when
741
+ // their Sixb metadata names this dataset.
742
+ if (!hasDataChange) {
743
+ const metadata = candidate.metadata
744
+ if (!metadata || metadata.datasetId !== datasetId) {
745
+ return null
746
+ }
747
+
748
+ return {
749
+ snapshotId: candidate.snapshotId,
750
+ tableId,
751
+ createdAt: candidate.createdAt,
752
+ mode: metadata.mode ?? "schema",
753
+ metadata,
754
+ }
755
+ }
756
+
757
+ // A real data-change snapshot belongs to this dataset because DuckLake's
758
+ // change metadata touched this table id. If Sixb metadata is present but
759
+ // points elsewhere, fail loudly rather than hydrating the wrong lineage.
760
+ this.assertNoMetadataConflict(candidate.snapshotId, datasetId, candidate.metadata)
761
+
762
+ return {
763
+ snapshotId: candidate.snapshotId,
764
+ tableId,
765
+ createdAt: candidate.createdAt,
766
+ mode: candidate.metadata?.mode ?? (hasDeleteChange ? "snapshot" : "append"),
767
+ ...(candidate.metadata !== undefined ? { metadata: candidate.metadata } : {}),
768
+ }
769
+ }
770
+
771
+ private async snapshotToVersion(
772
+ runtime: DuckDbQueryRuntime,
773
+ tableRef: DatasetTableRef,
774
+ row: DatasetSnapshotRow
775
+ ): Promise<DatasetVersion> {
776
+ const schemaAtSnapshot = await this.datasets.getDatasetSchemaAtSnapshot(
777
+ runtime,
778
+ tableRef.datasetId,
779
+ tableRef.tableName,
780
+ tableRef.tableId,
781
+ row.snapshotId
782
+ )
783
+
784
+ // DuckLake gives us version id, timestamp, and time travel. Sixb metadata
785
+ // fills in caller intent such as append vs snapshot and producer lineage.
786
+ // Version listing is a metadata path, so it only carries row counts already
787
+ // stored in Sixb commit metadata and never counts historical table contents.
788
+
789
+ return {
790
+ datasetId: tableRef.datasetId,
791
+ versionId: toVersionId(row.snapshotId),
792
+ parentVersionId:
793
+ row.parentSnapshotId === undefined ? undefined : toVersionId(row.parentSnapshotId),
794
+ mode: row.mode,
795
+ createdAt: row.createdAt,
796
+ schema: schemaAtSnapshot,
797
+ producer: row.metadata?.producer,
798
+ inputs: row.metadata?.inputs,
799
+ ...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
800
+ }
801
+ }
802
+
803
+ private withParentSnapshotIds(
804
+ snapshots: readonly DatasetSnapshotRow[]
805
+ ): readonly DatasetSnapshotRow[] {
806
+ return snapshots.map((snapshot, index) => {
807
+ // Parent ids track versions that reuse the previous dataset state.
808
+ // Snapshot versions replace the rows, so time travel stands on the snapshot id.
809
+ const parentSnapshotId =
810
+ snapshot.mode === "append" || snapshot.mode === "schema"
811
+ ? snapshots[index + 1]?.snapshotId
812
+ : undefined
813
+ return parentSnapshotId === undefined ? snapshot : { ...snapshot, parentSnapshotId }
814
+ })
815
+ }
816
+ }
817
+
818
+ function assertDuckLakeSnapshotId(snapshotId: string): void {
819
+ if (!/^\d+$/.test(snapshotId)) {
820
+ throw new LakeStorageError(`[SixbDuckLake] Invalid DuckLake snapshot id '${snapshotId}'.`)
821
+ }
822
+ }
823
+
824
+ function fileChangeKey(snapshotId: string, tableId: bigint): string {
825
+ return `${snapshotId}|${tableId}`
826
+ }