@sixb/ducklake 0.1.6 → 0.1.8

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 (35) hide show
  1. package/README.md +25 -27
  2. package/dist/ducklake-storage.d.ts +2 -1
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +736 -244
  5. package/dist/index.js.map +14 -12
  6. package/dist/internal/duckdb-reader.d.ts +28 -0
  7. package/dist/internal/duckdb-reader.d.ts.map +1 -0
  8. package/dist/internal/duckdb-runtime.d.ts +5 -3
  9. package/dist/internal/duckdb-runtime.d.ts.map +1 -1
  10. package/dist/internal/ducklake-connection-manager.d.ts +19 -3
  11. package/dist/internal/ducklake-connection-manager.d.ts.map +1 -1
  12. package/dist/internal/ducklake-read-gate.d.ts +11 -0
  13. package/dist/internal/ducklake-read-gate.d.ts.map +1 -0
  14. package/dist/internal/ducklake-row-reader.d.ts.map +1 -1
  15. package/dist/internal/ducklake-snapshot-reader.d.ts +26 -1
  16. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
  17. package/dist/internal/ducklake-write-coordinator.d.ts +2 -1
  18. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
  19. package/dist/internal/sql.d.ts +11 -2
  20. package/dist/internal/sql.d.ts.map +1 -1
  21. package/dist/types.d.ts +2 -0
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +4 -4
  24. package/src/ducklake-storage.ts +8 -0
  25. package/src/internal/duckdb-reader.ts +129 -0
  26. package/src/internal/duckdb-runtime.ts +75 -51
  27. package/src/internal/ducklake-connection-manager.ts +117 -33
  28. package/src/internal/ducklake-maintenance.ts +1 -1
  29. package/src/internal/ducklake-read-gate.ts +58 -0
  30. package/src/internal/ducklake-row-reader.ts +84 -63
  31. package/src/internal/ducklake-snapshot-reader.ts +331 -79
  32. package/src/internal/ducklake-write-coordinator.ts +56 -15
  33. package/src/internal/ducklake-write-session.ts +1 -0
  34. package/src/internal/sql.ts +35 -6
  35. package/src/types.ts +2 -0
@@ -2,6 +2,7 @@ import type { DatasetColumnDefinition, DatasetRow } from "@sixb/core"
2
2
  import type { DatasetVersion, ReadDatasetRowsInput } from "@sixb/core/lake-storage"
3
3
  import { LakeStorageError } from "@sixb/core/lake-storage"
4
4
  import type { DuckLakeStorageOptions } from "../types"
5
+ import type { DuckDbReader } from "./duckdb-reader"
5
6
  import { getBigIntLike } from "./duckdb-row"
6
7
  import type { DuckDbQueryRuntime } from "./duckdb-runtime"
7
8
  import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
@@ -11,10 +12,7 @@ import { normalizeReadValue } from "./schema"
11
12
  import { qualifiedTableName, quoteIdentifier } from "./sql"
12
13
  import { parseVersionId } from "./versions"
13
14
 
14
- // Large reads release the provider's single DuckDB queue slot between bounded pages. Besides
15
- // bounding converted-row memory, this lets a JavaScript pipeline feed one DuckLake dataset into
16
- // another: the destination appender can use the runtime after each page instead of waiting forever
17
- // behind its own still-open source stream.
15
+ // Bounded fallback for catalogs whose metadata transactions cannot stay open during a read.
18
16
  const READ_PAGE_ROWS = 5_000
19
17
  const PHYSICAL_ROW_ID_ALIAS = "__sixb_physical_row_id"
20
18
 
@@ -33,78 +31,88 @@ export class DuckLakeRowReader {
33
31
  ) {}
34
32
 
35
33
  async *readRows(input: ReadDatasetRowsInput): AsyncIterable<DatasetRow> {
36
- this.connections.assertOpen()
37
-
38
- // Keep the attachment lease across pages so local catalogs do not pay DETACH/ATTACH for every
39
- // page. Individual query() calls still release the DuckDB queue before rows are yielded.
40
- const lease = await this.connections.acquireAttachedRuntime()
34
+ const read = await this.connections.acquireRead(input.signal)
35
+ let reader: DuckDbReader | null = null
41
36
  try {
42
- const runtime = lease.runtime
43
- // Resolve latest exactly once so a paged read stays pinned even if another writer commits
44
- // between pages. DuckLake snapshots are immutable; every page names this snapshot.
45
- const tableRef = await resolveDatasetTableRef(this.options, runtime, input.datasetId)
46
- if (!tableRef) {
47
- throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.datasetId}'.`)
37
+ const prepared = await this.connections.withAttachedRuntime(async (runtime) => {
38
+ read.signal.throwIfAborted()
39
+ const tableRef = await resolveDatasetTableRef(this.options, runtime, input.datasetId)
40
+ if (!tableRef) {
41
+ throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.datasetId}'.`)
42
+ }
43
+ // Resolve latest once, before yielding any rows. Every query names the same snapshot.
44
+ const version = await this.resolveVersion(runtime, tableRef, input.versionId)
45
+ const columns = this.resolveReadColumns(tableRef.datasetId, version.schema, input.columns)
46
+ const names = new Set(version.schema.columns.map((column) => column.name.toLowerCase()))
47
+ const physicalOrder = !names.has("rowid") && !names.has(PHYSICAL_ROW_ID_ALIAS)
48
+ const relation = `${qualifiedTableName(this.options, tableRef.tableName)} AT (VERSION => ${parseVersionId(version.versionId)})`
49
+ const columnsSql = columns.map((column) => quoteIdentifier(column.name)).join(", ")
50
+ const offset = Math.max(0, Math.trunc(input.offset ?? 0))
51
+ const limit = input.limit === undefined ? undefined : Math.max(0, Math.trunc(input.limit))
52
+ const sql = `SELECT ${columnsSql} FROM ${relation}${physicalOrder ? " ORDER BY rowid" : ""}${limit === undefined ? "" : ` LIMIT ${limit}`}${offset > 0 ? ` OFFSET ${offset}` : ""}`
53
+ const cursor =
54
+ limit === undefined || limit > READ_PAGE_ROWS
55
+ ? await this.connections.openReader(runtime, sql, read.signal, read.release)
56
+ : null
57
+ const query = { columnsSql, relation, physicalOrder }
58
+ const firstPage =
59
+ cursor || limit === 0
60
+ ? []
61
+ : await runtime.query(
62
+ pageSql(query, Math.min(limit ?? READ_PAGE_ROWS, READ_PAGE_ROWS), offset, null)
63
+ )
64
+ return { columns, ...query, offset, limit, cursor, firstPage }
65
+ })
66
+ reader = prepared.cursor
67
+ // A paused paged reader owns no native query. Cancellation can release its read lease without
68
+ // waiting for the consumer to call next()/return(); native readers release after cleanup.
69
+ if (!reader) read.signal.addEventListener("abort", read.release, { once: true })
70
+ read.signal.throwIfAborted()
71
+ if (reader) {
72
+ yield* this.normalizeRows(reader.rows(), prepared.columns, read.signal)
73
+ return
48
74
  }
49
- const version = await this.resolveVersion(runtime, tableRef, input.versionId)
50
- const selectedColumns = this.resolveReadColumns(
51
- tableRef.datasetId,
52
- version.schema,
53
- input.columns
54
- )
55
- const snapshotId = parseVersionId(version.versionId)
56
- const columnsSql = selectedColumns.map((column) => quoteIdentifier(column.name)).join(", ")
57
- const tableSql = qualifiedTableName(this.options, tableRef.tableName)
58
- // A user column named `rowid` shadows DuckDB's virtual row id. In that edge case the
59
- // connection-level invariant still preserves insertion order; otherwise make it explicit.
60
- const schemaNames = new Set(version.schema.columns.map((column) => column.name.toLowerCase()))
61
- const canUsePhysicalRowId =
62
- !schemaNames.has("rowid") && !schemaNames.has(PHYSICAL_ROW_ID_ALIAS.toLowerCase())
63
- const orderSql = canUsePhysicalRowId ? " ORDER BY rowid" : ""
64
- const baseSql = `SELECT ${columnsSql} FROM ${tableSql} AT (VERSION => ${snapshotId})`
65
- const requestedOffset = Math.max(0, Math.trunc(input.offset ?? 0))
66
- let remaining = input.limit === undefined ? undefined : Math.max(0, Math.trunc(input.limit))
67
- if (remaining === 0) return
68
75
 
69
- // Materialize one bounded page and release its queue operation before yielding it. A
70
- // consumer may enqueue another DuckLake operation while processing the yielded rows.
71
- // Holding a native stream open here would make that operation wait behind itself.
72
- let offset = requestedOffset
73
- let physicalCursor: bigint | null = null
74
- while (true) {
75
- const pageRows =
76
- remaining === undefined ? READ_PAGE_ROWS : Math.min(READ_PAGE_ROWS, remaining)
77
- const pageSql = canUsePhysicalRowId
78
- ? `SELECT rowid AS ${quoteIdentifier(PHYSICAL_ROW_ID_ALIAS)}, ${columnsSql}
79
- FROM ${tableSql} AT (VERSION => ${snapshotId})
80
- ${physicalCursor === null ? "" : `WHERE rowid > ${physicalCursor}`}
81
- ORDER BY rowid LIMIT ${pageRows}
82
- ${physicalCursor === null && offset > 0 ? `OFFSET ${offset}` : ""}`
83
- : `${baseSql}${orderSql} LIMIT ${pageRows} OFFSET ${offset}`
84
- const rows = await runtime.query(pageSql)
85
- if (rows.length === 0) return
86
-
87
- yield* this.normalizeRows(rows, selectedColumns)
76
+ // Each bounded query releases both the runtime queue and attachment lease before yielding.
77
+ // SQLite metadata locks and PostgreSQL pool slots are released before consumer operations.
78
+ let offset = prepared.offset
79
+ let remaining = prepared.limit
80
+ let cursor: bigint | null = null
81
+ let page: readonly Record<string, unknown>[] | undefined = prepared.firstPage
82
+ while (remaining !== 0) {
83
+ read.signal.throwIfAborted()
84
+ const pageSize = Math.min(remaining ?? READ_PAGE_ROWS, READ_PAGE_ROWS)
85
+ const rows: readonly Record<string, unknown>[] =
86
+ page ??
87
+ (await this.connections.withAttachedRuntime((runtime) =>
88
+ runtime.query(pageSql(prepared, pageSize, offset, cursor))
89
+ ))
90
+ page = undefined
91
+ read.signal.throwIfAborted()
92
+ yield* this.normalizeRows(rows, prepared.columns, read.signal)
93
+ if (remaining !== undefined) remaining -= rows.length
94
+ if (rows.length < pageSize) return
95
+ if (prepared.physicalOrder)
96
+ cursor = getBigIntLike(rows[rows.length - 1]!, PHYSICAL_ROW_ID_ALIAS)
88
97
  offset += rows.length
89
- if (remaining !== undefined) {
90
- remaining -= rows.length
91
- if (remaining === 0) return
92
- }
93
- if (canUsePhysicalRowId) {
94
- physicalCursor = getBigIntLike(rows[rows.length - 1]!, PHYSICAL_ROW_ID_ALIAS)
95
- }
96
- if (rows.length < pageRows) return
97
98
  }
98
99
  } finally {
99
- await lease.release()
100
+ read.signal.removeEventListener("abort", read.release)
101
+ try {
102
+ await reader?.close()
103
+ } finally {
104
+ read.release()
105
+ }
100
106
  }
101
107
  }
102
108
 
103
109
  private async *normalizeRows(
104
110
  rows: Iterable<Record<string, unknown>> | AsyncIterable<Record<string, unknown>>,
105
- selectedColumns: readonly DatasetColumnDefinition[]
111
+ selectedColumns: readonly DatasetColumnDefinition[],
112
+ signal: AbortSignal
106
113
  ): AsyncIterable<DatasetRow> {
107
114
  for await (const row of rows) {
115
+ signal.throwIfAborted()
108
116
  const output: Record<string, unknown> = {}
109
117
  for (const column of selectedColumns) {
110
118
  output[column.name] = normalizeReadValue(row[column.name], column)
@@ -165,3 +173,16 @@ export class DuckLakeRowReader {
165
173
  )
166
174
  }
167
175
  }
176
+
177
+ function pageSql(
178
+ read: { readonly columnsSql: string; readonly relation: string; readonly physicalOrder: boolean },
179
+ limit: number,
180
+ offset: number,
181
+ cursor: bigint | null
182
+ ): string {
183
+ return read.physicalOrder
184
+ ? `SELECT rowid AS ${quoteIdentifier(PHYSICAL_ROW_ID_ALIAS)}, ${read.columnsSql}
185
+ FROM ${read.relation} ${cursor === null ? "" : `WHERE rowid > ${cursor}`}
186
+ ORDER BY rowid LIMIT ${limit} ${cursor === null && offset > 0 ? `OFFSET ${offset}` : ""}`
187
+ : `SELECT ${read.columnsSql} FROM ${read.relation} LIMIT ${limit} OFFSET ${offset}`
188
+ }
@@ -5,6 +5,8 @@ import type {
5
5
  DatasetVersion,
6
6
  DatasetVersionMode,
7
7
  DatasetVersionRef,
8
+ LatestVersionsSince,
9
+ ListLatestVersionsSinceInput,
8
10
  } from "@sixb/core/lake-storage"
9
11
  import { LakeStorageError } from "@sixb/core/lake-storage"
10
12
  import type { DuckLakeStorageOptions } from "../types"
@@ -13,8 +15,8 @@ import type { DuckDbQueryRuntime } from "./duckdb-runtime"
13
15
  import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
14
16
  import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
15
17
  import { type DatasetTableRef, resolveDatasetTableRef } from "./ducklake-dataset-table-ref"
16
- import { encodeDatasetTableName } from "./names"
17
- import { duckLakeMetadataTableName, quoteSqlString } from "./sql"
18
+ import { decodeDatasetTableName, encodeDatasetTableName } from "./names"
19
+ import { buildDuckLakeMetadataQuery, duckLakeMetadataTableName, quoteSqlString } from "./sql"
18
20
  import {
19
21
  parseCommitMetadata,
20
22
  parseInlineDataChange,
@@ -41,11 +43,24 @@ interface DatasetSnapshotCandidateRow {
41
43
  readonly metadata?: SixbCommitMetadata
42
44
  }
43
45
 
46
+ interface FeedTable {
47
+ readonly datasetId: string
48
+ readonly tableId: bigint
49
+ /** A dataset cannot have a version before its table exists. */
50
+ readonly startSnapshotId: bigint
51
+ }
52
+
53
+ interface MissingSnapshotCandidate {
54
+ readonly snapshotId: string
55
+ readonly missing: true
56
+ }
57
+
44
58
  interface SnapshotCandidateQueryInput {
59
+ readonly datasetId: string
45
60
  readonly tableId: bigint
46
61
  readonly exactSnapshotId?: string
47
62
  readonly beforeSnapshotId?: string
48
- readonly limit?: number
63
+ readonly limit: number
49
64
  }
50
65
 
51
66
  interface VisibleSnapshotRowsInput {
@@ -79,6 +94,8 @@ interface FileChangeFlags {
79
94
  }
80
95
 
81
96
  const SNAPSHOT_ROW_BATCH_SIZE = 128
97
+ // One feed read covers at most this many commits; the next read continues from its cursor.
98
+ const FEED_PAGE_SIZE = 512
82
99
 
83
100
  // The bulk catalog scan shares one descending walk of recent snapshots across
84
101
  // all requested datasets, so its cost is bounded by this window rather than by
@@ -189,6 +206,192 @@ export class DuckLakeSnapshotReader {
189
206
  })
190
207
  }
191
208
 
209
+ /**
210
+ * Change feed over DuckLake's snapshot table, which is an append-only commit log: snapshot ids
211
+ * only increase, and a commit becomes visible only after every lower id has (concurrent commits
212
+ * that pick the same id conflict, and the loser retries with the next one). The cursor is the
213
+ * last snapshot id read. Every query is bounded by a snapshot-id range, so an idle read is one
214
+ * indexed query that returns the cursor's own snapshot, whatever the history length.
215
+ */
216
+ async listLatestVersionsSince(
217
+ input: ListLatestVersionsSinceInput
218
+ ): Promise<LatestVersionsSince | null> {
219
+ this.connections.assertOpen()
220
+
221
+ return this.connections.withAttachedRuntime(async (runtime) => {
222
+ if (input.cursor === null) {
223
+ return { cursor: await this.queryHeadSnapshotId(runtime), versions: [] }
224
+ }
225
+ if (!/^\d+$/.test(input.cursor)) {
226
+ return null
227
+ }
228
+
229
+ const snapshots = await this.querySnapshotsFrom(runtime, input.cursor)
230
+ // The cursor's own snapshot proves the cursor is still a position in this lake's history.
231
+ if (snapshots[0]?.snapshotId !== input.cursor) {
232
+ return null
233
+ }
234
+ const newSnapshots = snapshots.slice(1)
235
+ if (newSnapshots.length === 0) {
236
+ return { cursor: input.cursor, versions: [] }
237
+ }
238
+
239
+ const tables = await this.resolveFeedTables(runtime, input.datasetIds)
240
+ const latest = await this.collectFeedPage(runtime, tables, newSnapshots)
241
+ return {
242
+ cursor: newSnapshots[newSnapshots.length - 1]?.snapshotId ?? input.cursor,
243
+ versions: [...latest].map(([datasetId, row]) => this.snapshotRowToSummary(datasetId, row)),
244
+ }
245
+ })
246
+ }
247
+
248
+ private async queryHeadSnapshotId(runtime: DuckDbQueryRuntime): Promise<string> {
249
+ const [row] = await runtime.query(
250
+ buildDuckLakeMetadataQuery(
251
+ this.options,
252
+ (table) => `SELECT max(snapshot_id) AS snapshot_id FROM ${table("ducklake_snapshot")}`
253
+ )
254
+ )
255
+ if (row?.snapshot_id === null || row?.snapshot_id === undefined) {
256
+ throw new LakeStorageError("[SixbDuckLake] DuckLake catalog has no snapshots.")
257
+ }
258
+ return String(getBigIntLike(row, "snapshot_id"))
259
+ }
260
+
261
+ /** The cursor's snapshot and up to one feed page of snapshots after it, oldest first. */
262
+ private async querySnapshotsFrom(
263
+ runtime: DuckDbQueryRuntime,
264
+ cursor: string
265
+ ): Promise<readonly { readonly snapshotId: string; readonly createdAt: Date }[]> {
266
+ const rows = await runtime.query(
267
+ buildDuckLakeMetadataQuery(
268
+ this.options,
269
+ (table) => `
270
+ SELECT snapshot_id, snapshot_time
271
+ FROM ${table("ducklake_snapshot")}
272
+ WHERE snapshot_id >= ${cursor}
273
+ ORDER BY snapshot_id
274
+ LIMIT ${FEED_PAGE_SIZE + 1}
275
+ `
276
+ )
277
+ )
278
+ return rows.map((row) => ({
279
+ snapshotId: String(getBigIntLike(row, "snapshot_id")),
280
+ createdAt: getDate(row, "snapshot_time"),
281
+ }))
282
+ }
283
+
284
+ /** Current dataset tables, with the first snapshot each table id existed in. */
285
+ private async resolveFeedTables(
286
+ runtime: DuckDbQueryRuntime,
287
+ datasetIds: readonly string[] | undefined
288
+ ): Promise<readonly FeedTable[]> {
289
+ const requested = datasetIds === undefined ? null : new Set(datasetIds)
290
+ const rows = await runtime.query(`
291
+ SELECT table_id, table_name, begin_snapshot, end_snapshot
292
+ FROM ${duckLakeMetadataTableName(this.options, "ducklake_table")}
293
+ `)
294
+
295
+ const startByTableId = new Map<bigint, bigint>()
296
+ const current: { datasetId: string; tableId: bigint }[] = []
297
+ for (const row of rows) {
298
+ const tableId = getBigIntLike(row, "table_id")
299
+ const begin = getBigIntLike(row, "begin_snapshot")
300
+ const start = startByTableId.get(tableId)
301
+ if (start === undefined || begin < start) {
302
+ startByTableId.set(tableId, begin)
303
+ }
304
+ if (row.end_snapshot !== null && row.end_snapshot !== undefined) {
305
+ continue
306
+ }
307
+ const datasetId = decodeDatasetTableName(getString(row, "table_name"))
308
+ if (datasetId !== null && (requested === null || requested.has(datasetId))) {
309
+ current.push({ datasetId, tableId })
310
+ }
311
+ }
312
+
313
+ return current.map((table) => ({
314
+ ...table,
315
+ startSnapshotId: startByTableId.get(table.tableId) ?? 0n,
316
+ }))
317
+ }
318
+
319
+ /** Folds a page of snapshots, oldest first, into each dataset's newest data version. */
320
+ private async collectFeedPage(
321
+ runtime: DuckDbQueryRuntime,
322
+ tables: readonly FeedTable[],
323
+ snapshots: readonly { readonly snapshotId: string; readonly createdAt: Date }[]
324
+ ): Promise<Map<string, DatasetSnapshotRow>> {
325
+ const latest = new Map<string, DatasetSnapshotRow>()
326
+ const first = snapshots[0]?.snapshotId
327
+ const last = snapshots[snapshots.length - 1]?.snapshotId
328
+ if (first === undefined || last === undefined || tables.length === 0) {
329
+ return latest
330
+ }
331
+
332
+ const changeRows = await runtime.query(
333
+ buildDuckLakeMetadataQuery(
334
+ this.options,
335
+ (table) => `
336
+ SELECT snapshot_id, changes_made, commit_extra_info
337
+ FROM ${table("ducklake_snapshot_changes")}
338
+ WHERE snapshot_id >= ${first} AND snapshot_id <= ${last}
339
+ `
340
+ )
341
+ )
342
+ const fileFlags = await this.queryFileChangeFlags(
343
+ runtime,
344
+ snapshots.map((snapshot) => snapshot.snapshotId),
345
+ tables.map((table) => table.tableId)
346
+ )
347
+ const changesBySnapshotId = new Map(
348
+ changeRows.map((row) => [String(getBigIntLike(row, "snapshot_id")), row])
349
+ )
350
+ const tablesByTableId = new Map(tables.map((table) => [table.tableId.toString(), table]))
351
+ const tablesByDatasetId = new Map(tables.map((table) => [table.datasetId, table]))
352
+
353
+ for (const snapshot of snapshots) {
354
+ const changes = changesBySnapshotId.get(snapshot.snapshotId)
355
+ if (changes === undefined) {
356
+ continue
357
+ }
358
+ const changesMade = getString(changes, "changes_made")
359
+ const metadata = parseCommitMetadata(changes.commit_extra_info)
360
+
361
+ // Only tables this snapshot names, touches through files, or tags can change visibility.
362
+ const touched = new Set<FeedTable>()
363
+ for (const change of changesMade.split(",")) {
364
+ const table = tablesByTableId.get(change.split(":")[1] ?? "")
365
+ if (table) touched.add(table)
366
+ }
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
+
373
+ for (const table of touched) {
374
+ if (BigInt(snapshot.snapshotId) < table.startSnapshotId) {
375
+ continue
376
+ }
377
+ const flags = fileFlags.get(fileChangeKey(snapshot.snapshotId, table.tableId))
378
+ const row = this.candidateToSnapshotRow(table.datasetId, table.tableId, {
379
+ snapshotId: snapshot.snapshotId,
380
+ createdAt: snapshot.createdAt,
381
+ changesMade,
382
+ hasFileChange: flags?.hasFileChange ?? false,
383
+ hasFileDeleteChange: flags?.hasFileDeleteChange ?? false,
384
+ ...(metadata !== undefined ? { metadata } : {}),
385
+ })
386
+ if (row && row.mode !== "schema") {
387
+ latest.set(table.datasetId, row)
388
+ }
389
+ }
390
+ }
391
+
392
+ return latest
393
+ }
394
+
192
395
  private snapshotRowToSummary(
193
396
  datasetId: string,
194
397
  row: DatasetSnapshotRow
@@ -604,6 +807,7 @@ export class DuckLakeSnapshotReader {
604
807
 
605
808
  while (input.visibleRowLimit === undefined || snapshots.length < input.visibleRowLimit) {
606
809
  const candidates = await this.querySnapshotCandidates(runtime, {
810
+ datasetId: input.datasetId,
607
811
  tableId: input.tableId,
608
812
  exactSnapshotId: input.exactSnapshotId,
609
813
  beforeSnapshotId,
@@ -614,6 +818,9 @@ export class DuckLakeSnapshotReader {
614
818
  }
615
819
 
616
820
  for (const candidate of candidates) {
821
+ if ("missing" in candidate) {
822
+ continue
823
+ }
617
824
  const snapshot = this.candidateToSnapshotRow(input.datasetId, input.tableId, candidate)
618
825
  if (snapshot) {
619
826
  snapshots.push(snapshot)
@@ -634,99 +841,144 @@ export class DuckLakeSnapshotReader {
634
841
  return snapshots
635
842
  }
636
843
 
844
+ /**
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.
850
+ *
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.
853
+ */
637
854
  private async querySnapshotCandidates(
638
855
  runtime: DuckDbQueryRuntime,
639
856
  input: SnapshotCandidateQueryInput
640
- ): Promise<readonly DatasetSnapshotCandidateRow[]> {
641
- if (input.limit !== undefined && input.limit <= 0) {
857
+ ): Promise<readonly (DatasetSnapshotCandidateRow | MissingSnapshotCandidate)[]> {
858
+ const tableId = input.tableId.toString()
859
+ const limit = Math.max(0, Math.trunc(input.limit))
860
+ if (limit === 0) {
642
861
  return []
643
862
  }
644
-
645
- const where: string[] = []
646
863
  if (input.exactSnapshotId !== undefined) {
647
864
  assertDuckLakeSnapshotId(input.exactSnapshotId)
648
- where.push(`snapshot.snapshot_id = ${input.exactSnapshotId}`)
649
865
  }
650
866
  if (input.beforeSnapshotId !== undefined) {
651
867
  assertDuckLakeSnapshotId(input.beforeSnapshotId)
652
- where.push(`snapshot.snapshot_id < ${input.beforeSnapshotId}`)
653
868
  }
654
869
 
655
- const whereSql = where.length === 0 ? "" : `WHERE ${where.join(" AND ")}`
656
- const limitSql =
657
- input.limit === undefined ? "" : `LIMIT ${Math.max(0, Math.trunc(input.limit))}`
658
- const ducklakeSnapshot = duckLakeMetadataTableName(this.options, "ducklake_snapshot")
659
- const ducklakeSnapshotChanges = duckLakeMetadataTableName(
660
- this.options,
661
- "ducklake_snapshot_changes"
870
+ const rows = await runtime.query(
871
+ buildDuckLakeMetadataQuery(this.options, (table) => {
872
+ // A dataset cannot have a version before its table exists. Use the earliest table record:
873
+ // a rename may create a newer record with the same table id and must not hide older
874
+ // 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}`
908
+
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
954
+ )
955
+ 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
969
+ `
970
+ })
662
971
  )
663
- const ducklakeDataFile = duckLakeMetadataTableName(this.options, "ducklake_data_file")
664
- const ducklakeDeleteFile = duckLakeMetadataTableName(this.options, "ducklake_delete_file")
665
-
666
- const rows = await runtime.query(`
667
- WITH candidate_snapshots AS (
668
- -- Recent DuckLake snapshots to inspect; Sixb visibility is filtered later.
669
- SELECT
670
- snapshot.snapshot_id,
671
- snapshot.snapshot_time,
672
- changes.changes_made,
673
- changes.commit_extra_info
674
- FROM ${ducklakeSnapshot} snapshot
675
- JOIN ${ducklakeSnapshotChanges} changes
676
- ON changes.snapshot_id = snapshot.snapshot_id
677
- ${whereSql}
678
- ORDER BY snapshot.snapshot_id DESC
679
- ${limitSql}
680
- ),
681
- file_changes AS (
682
- -- File metadata tells whether this table changed in each candidate snapshot.
683
- SELECT begin_snapshot AS snapshot_id, false AS is_delete_change
684
- FROM ${ducklakeDataFile}
685
- WHERE table_id = ${input.tableId}
686
- AND begin_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
687
- UNION ALL
688
- SELECT end_snapshot AS snapshot_id, true AS is_delete_change
689
- FROM ${ducklakeDataFile}
690
- WHERE table_id = ${input.tableId}
691
- AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
692
- UNION ALL
693
- SELECT begin_snapshot AS snapshot_id, true AS is_delete_change
694
- FROM ${ducklakeDeleteFile}
695
- WHERE table_id = ${input.tableId}
696
- AND begin_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
697
- UNION ALL
698
- SELECT end_snapshot AS snapshot_id, true AS is_delete_change
699
- FROM ${ducklakeDeleteFile}
700
- WHERE table_id = ${input.tableId}
701
- AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
702
- ),
703
- file_changes_by_snapshot AS (
704
- -- Collapse file-level changes into one row per snapshot.
705
- SELECT
706
- snapshot_id,
707
- count(*) > 0 AS has_file_change,
708
- count(*) FILTER (WHERE is_delete_change) > 0 AS has_file_delete_change
709
- FROM file_changes
710
- GROUP BY snapshot_id
711
- )
712
- SELECT
713
- -- Keep metadata-only candidates; Sixb filters them with commit_extra_info.
714
- candidate.snapshot_id,
715
- candidate.snapshot_time,
716
- candidate.changes_made,
717
- candidate.commit_extra_info,
718
- coalesce(file_changes.has_file_change, false) AS has_file_change,
719
- coalesce(file_changes.has_file_delete_change, false) AS has_file_delete_change
720
- FROM candidate_snapshots candidate
721
- LEFT JOIN file_changes_by_snapshot file_changes
722
- ON file_changes.snapshot_id = candidate.snapshot_id
723
- ORDER BY candidate.snapshot_id DESC
724
- `)
725
972
 
726
973
  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
+ }
727
979
  const metadata = parseCommitMetadata(row.commit_extra_info)
728
980
  return {
729
- snapshotId: String(getBigIntLike(row, "snapshot_id")),
981
+ snapshotId,
730
982
  createdAt: getDate(row, "snapshot_time"),
731
983
  changesMade: getString(row, "changes_made"),
732
984
  hasFileChange: getBoolean(row, "has_file_change"),