@sixb/ducklake 0.1.7 → 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.
@@ -1,5 +1,5 @@
1
1
  import type { DatasetDefinition } from "@sixb/core";
2
- import type { DatasetCatalogState, DatasetVersion, DatasetVersionRef } from "@sixb/core/lake-storage";
2
+ import type { DatasetCatalogState, DatasetVersion, DatasetVersionRef, LatestVersionsSince, ListLatestVersionsSinceInput } from "@sixb/core/lake-storage";
3
3
  import type { DuckLakeStorageOptions } from "../types";
4
4
  import type { DuckDbQueryRuntime } from "./duckdb-runtime";
5
5
  import type { DuckLakeConnectionManager } from "./ducklake-connection-manager";
@@ -41,6 +41,21 @@ export declare class DuckLakeSnapshotReader {
41
41
  */
42
42
  listDatasetCatalogState(datasetIds: readonly string[]): Promise<readonly DatasetCatalogState[]>;
43
43
  private collectDatasetCatalogState;
44
+ /**
45
+ * Change feed over DuckLake's snapshot table, which is an append-only commit log: snapshot ids
46
+ * only increase, and a commit becomes visible only after every lower id has (concurrent commits
47
+ * that pick the same id conflict, and the loser retries with the next one). The cursor is the
48
+ * last snapshot id read. Every query is bounded by a snapshot-id range, so an idle read is one
49
+ * indexed query that returns the cursor's own snapshot, whatever the history length.
50
+ */
51
+ listLatestVersionsSince(input: ListLatestVersionsSinceInput): Promise<LatestVersionsSince | null>;
52
+ private queryHeadSnapshotId;
53
+ /** The cursor's snapshot and up to one feed page of snapshots after it, oldest first. */
54
+ private querySnapshotsFrom;
55
+ /** Current dataset tables, with the first snapshot each table id existed in. */
56
+ private resolveFeedTables;
57
+ /** Folds a page of snapshots, oldest first, into each dataset's newest data version. */
58
+ private collectFeedPage;
44
59
  private snapshotRowToSummary;
45
60
  private resolveDatasetTableIds;
46
61
  /**
@@ -67,6 +82,16 @@ export declare class DuckLakeSnapshotReader {
67
82
  private getSnapshotRowForTableRef;
68
83
  private getSnapshotRowsForTableRef;
69
84
  private collectVisibleSnapshotRows;
85
+ /**
86
+ * Hydrates, newest first, the snapshots that may hold a version of one dataset table: those
87
+ * that added or removed its data or delete files, that DuckLake recorded as changing it inline,
88
+ * or whose Sixb commit metadata names the dataset. The text matches are a superset;
89
+ * candidateToSnapshotRow decides visibility. Only this table's snapshots are read, so the cost
90
+ * follows the dataset's own history rather than every commit in the catalog.
91
+ *
92
+ * Returns one row per candidate id, including ids whose snapshot no longer exists, so the caller
93
+ * can tell a short batch from the end of the history.
94
+ */
70
95
  private querySnapshotCandidates;
71
96
  private candidateToSnapshotRow;
72
97
  private snapshotToVersion;
@@ -1 +1 @@
1
- {"version":3,"file":"ducklake-snapshot-reader.d.ts","sourceRoot":"","sources":["../../src/internal/ducklake-snapshot-reader.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ducklake-snapshot-reader.d.ts","sourceRoot":"","sources":["../../src/internal/ducklake-snapshot-reader.ts"],"names":[],"mappings":"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"}
@@ -19,10 +19,19 @@ export declare function buildSetPostgresPoolSql(options: Pick<DuckLakeStorageOpt
19
19
  * Render a fully-qualified DuckLake metadata table path.
20
20
  *
21
21
  * PostgreSQL catalogs expose DuckLake metadata tables under the configured
22
- * metadata schema inside the attached metadata catalog. Local catalogs use
23
- * DuckDB's default `main` schema.
22
+ * metadata schema (PostgreSQL's `public` by default) inside the attached
23
+ * metadata catalog. Local catalogs use DuckDB's default `main` schema.
24
24
  */
25
25
  export declare function duckLakeMetadataTableName(options: Pick<DuckLakeStorageOptions, "alias" | "catalog">, tableName: string): string;
26
+ /**
27
+ * Render a metadata query that runs where the catalog lives.
28
+ *
29
+ * DuckDB's PostgreSQL scanner pushes comparisons and constant IN lists down to PostgreSQL, but not
30
+ * string matching, so a query that filters on text would copy the whole column into DuckDB. On a
31
+ * PostgreSQL catalog the query therefore runs server-side through `postgres_query`; elsewhere the
32
+ * catalog is local and DuckDB runs it. `build` must produce SQL that both dialects accept.
33
+ */
34
+ export declare function buildDuckLakeMetadataQuery(options: Pick<DuckLakeStorageOptions, "alias" | "catalog">, build: (table: (tableName: string) => string) => string): string;
26
35
  /**
27
36
  * Quote a SQL identifier after validating it is already identifier-shaped.
28
37
  *
@@ -1 +1 @@
1
- {"version":3,"file":"sql.d.ts","sourceRoot":"","sources":["../../src/internal/sql.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAEV,mBAAmB,EACnB,sBAAsB,EACtB,sBAAsB,EAEtB,8BAA8B,EAG/B,MAAM,UAAU,CAAA;AAIjB;;GAEG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,OAAO,CAAC,GAAG,MAAM,CAEpF;AAUD;;;;;GAKG;AACH,wBAAgB,qCAAqC,CACnD,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,OAAO,GAAG,cAAc,CAAC,GAC9D,MAAM,CAOR;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,cAAc,CAAC,GACpD,SAAS,MAAM,EAAE,CAInB;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,OAAO,GAAG,SAAS,CAAC,EAC1D,SAAS,EAAE,MAAM,GAChB,MAAM,CAOR;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAM1D;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAEpE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,OAAO,CAAC,EAC9C,SAAS,EAAE,MAAM,GAChB,MAAM,CAER;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,sBAAsB,GAAG,MAAM,CAWlE;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,8BAA8B,GAAG,MAAM,CAuBxF;AAkBD;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,sBAAsB,GAAG,SAAS,MAAM,EAAE,CAcrF;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,mBAAmB,GAAG,MAAM,CAIxE;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,sBAAsB,GAAG,MAAM,CA+BtE"}
1
+ {"version":3,"file":"sql.d.ts","sourceRoot":"","sources":["../../src/internal/sql.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAEV,mBAAmB,EACnB,sBAAsB,EACtB,sBAAsB,EAEtB,8BAA8B,EAG/B,MAAM,UAAU,CAAA;AAIjB;;GAEG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,OAAO,CAAC,GAAG,MAAM,CAEpF;AAUD;;;;;GAKG;AACH,wBAAgB,qCAAqC,CACnD,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,OAAO,GAAG,cAAc,CAAC,GAC9D,MAAM,CAOR;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,cAAc,CAAC,GACpD,SAAS,MAAM,EAAE,CAInB;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,OAAO,GAAG,SAAS,CAAC,EAC1D,SAAS,EAAE,MAAM,GAChB,MAAM,CAIR;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,OAAO,GAAG,SAAS,CAAC,EAC1D,KAAK,EAAE,CAAC,KAAK,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,KAAK,MAAM,GACtD,MAAM,CAaR;AAQD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAM1D;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAEpE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,IAAI,CAAC,sBAAsB,EAAE,OAAO,CAAC,EAC9C,SAAS,EAAE,MAAM,GAChB,MAAM,CAER;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,sBAAsB,GAAG,MAAM,CAWlE;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,8BAA8B,GAAG,MAAM,CAuBxF;AAkBD;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,sBAAsB,GAAG,SAAS,MAAM,EAAE,CAcrF;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,mBAAmB,GAAG,MAAM,CAIxE;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,sBAAsB,GAAG,MAAM,CA+BtE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sixb/ducklake",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "DuckDB/DuckLake lake storage provider for Sixb",
5
5
  "keywords": [
6
6
  "sixb",
@@ -36,7 +36,7 @@
36
36
  "typecheck": "tsc --noEmit",
37
37
  "test": "bun test",
38
38
  "test:e2e": "bun run test:e2e:local && bun run test:e2e:remote",
39
- "test:e2e:local": "bun test ./tests/ducklake-reader.e2e.ts ./tests/ducklake-changes.e2e.ts ./tests/ducklake-contract.e2e.ts ./tests/ducklake-file-refs.e2e.ts ./tests/ducklake-catalog-state.e2e.ts ./tests/ducklake-catalogs.e2e.ts ./tests/ducklake-concurrency.e2e.ts ./tests/ducklake-datasets.e2e.ts ./tests/ducklake-driver.e2e.ts ./tests/ducklake-maintenance.e2e.ts ./tests/ducklake-merges.e2e.ts ./tests/ducklake-sql-transforms.e2e.ts ./tests/ducklake-versions.e2e.ts ./tests/ducklake-writes.e2e.ts ./tests/ducklake-sqlite-writes.e2e.ts",
39
+ "test:e2e:local": "bun test ./tests/ducklake-reader.e2e.ts ./tests/ducklake-changes.e2e.ts ./tests/ducklake-contract.e2e.ts ./tests/ducklake-file-refs.e2e.ts ./tests/ducklake-catalog-state.e2e.ts ./tests/ducklake-catalogs.e2e.ts ./tests/ducklake-concurrency.e2e.ts ./tests/ducklake-datasets.e2e.ts ./tests/ducklake-driver.e2e.ts ./tests/ducklake-maintenance.e2e.ts ./tests/ducklake-merges.e2e.ts ./tests/ducklake-sql-transforms.e2e.ts ./tests/ducklake-versions.e2e.ts ./tests/ducklake-long-history.e2e.ts ./tests/ducklake-writes.e2e.ts ./tests/ducklake-sqlite-writes.e2e.ts",
40
40
  "test:e2e:remote": "bun test --preload ./tests/setup.ts ./tests/ducklake-remote.e2e.ts",
41
41
  "prepack": "bun run build"
42
42
  },
@@ -44,10 +44,10 @@
44
44
  "@duckdb/node-api": "^1.5.2-r.1"
45
45
  },
46
46
  "peerDependencies": {
47
- "@sixb/core": "^0.1.11"
47
+ "@sixb/core": "^0.1.17"
48
48
  },
49
49
  "devDependencies": {
50
- "@sixb/core": "0.1.11",
50
+ "@sixb/core": "0.1.17",
51
51
  "@types/bun": "latest",
52
52
  "typescript": "^5.7.0"
53
53
  },
@@ -7,6 +7,8 @@ import type {
7
7
  DatasetVersion,
8
8
  LakeMergeSession,
9
9
  LakeWriteSession,
10
+ LatestVersionsSince,
11
+ ListLatestVersionsSinceInput,
10
12
  ReadDatasetChangesInput,
11
13
  ReadDatasetRowsInput,
12
14
  } from "@sixb/core/lake-storage"
@@ -127,6 +129,12 @@ export class DuckLakeStorage implements LakeStorageWithSql<"duckdb"> {
127
129
  return this.snapshotReader.getVersion(datasetId, versionId)
128
130
  }
129
131
 
132
+ async listLatestVersionsSince(
133
+ input: ListLatestVersionsSinceInput
134
+ ): Promise<LatestVersionsSince | null> {
135
+ return this.snapshotReader.listLatestVersionsSince(input)
136
+ }
137
+
130
138
  readRows(input: ReadDatasetRowsInput): AsyncIterable<DatasetRow> {
131
139
  return this.rows.readRows(input)
132
140
  }
@@ -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,104 +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
- // A dataset cannot have a version before its table exists. Use the earliest table record:
646
- // a rename may create a newer record with the same table id and must not hide older versions.
647
- const ducklakeTable = duckLakeMetadataTableName(this.options, "ducklake_table")
648
- const where = [
649
- `snapshot.snapshot_id >= (SELECT min(begin_snapshot) FROM ${ducklakeTable} WHERE table_id = ${input.tableId})`,
650
- ]
651
863
  if (input.exactSnapshotId !== undefined) {
652
864
  assertDuckLakeSnapshotId(input.exactSnapshotId)
653
- where.push(`snapshot.snapshot_id = ${input.exactSnapshotId}`)
654
865
  }
655
866
  if (input.beforeSnapshotId !== undefined) {
656
867
  assertDuckLakeSnapshotId(input.beforeSnapshotId)
657
- where.push(`snapshot.snapshot_id < ${input.beforeSnapshotId}`)
658
868
  }
659
869
 
660
- const whereSql = `WHERE ${where.join(" AND ")}`
661
- const limitSql =
662
- input.limit === undefined ? "" : `LIMIT ${Math.max(0, Math.trunc(input.limit))}`
663
- const ducklakeSnapshot = duckLakeMetadataTableName(this.options, "ducklake_snapshot")
664
- const ducklakeSnapshotChanges = duckLakeMetadataTableName(
665
- this.options,
666
- "ducklake_snapshot_changes"
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
+ })
667
971
  )
668
- const ducklakeDataFile = duckLakeMetadataTableName(this.options, "ducklake_data_file")
669
- const ducklakeDeleteFile = duckLakeMetadataTableName(this.options, "ducklake_delete_file")
670
-
671
- const rows = await runtime.query(`
672
- WITH candidate_snapshots AS (
673
- -- Recent DuckLake snapshots to inspect; Sixb visibility is filtered later.
674
- SELECT
675
- snapshot.snapshot_id,
676
- snapshot.snapshot_time,
677
- changes.changes_made,
678
- changes.commit_extra_info
679
- FROM ${ducklakeSnapshot} snapshot
680
- JOIN ${ducklakeSnapshotChanges} changes
681
- ON changes.snapshot_id = snapshot.snapshot_id
682
- ${whereSql}
683
- ORDER BY snapshot.snapshot_id DESC
684
- ${limitSql}
685
- ),
686
- file_changes AS (
687
- -- File metadata tells whether this table changed in each candidate snapshot.
688
- SELECT begin_snapshot AS snapshot_id, false AS is_delete_change
689
- FROM ${ducklakeDataFile}
690
- WHERE table_id = ${input.tableId}
691
- AND begin_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
692
- UNION ALL
693
- SELECT end_snapshot AS snapshot_id, true AS is_delete_change
694
- FROM ${ducklakeDataFile}
695
- WHERE table_id = ${input.tableId}
696
- AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
697
- UNION ALL
698
- SELECT begin_snapshot AS snapshot_id, true AS is_delete_change
699
- FROM ${ducklakeDeleteFile}
700
- WHERE table_id = ${input.tableId}
701
- AND begin_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
702
- UNION ALL
703
- SELECT end_snapshot AS snapshot_id, true AS is_delete_change
704
- FROM ${ducklakeDeleteFile}
705
- WHERE table_id = ${input.tableId}
706
- AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
707
- ),
708
- file_changes_by_snapshot AS (
709
- -- Collapse file-level changes into one row per snapshot.
710
- SELECT
711
- snapshot_id,
712
- count(*) > 0 AS has_file_change,
713
- count(*) FILTER (WHERE is_delete_change) > 0 AS has_file_delete_change
714
- FROM file_changes
715
- GROUP BY snapshot_id
716
- )
717
- SELECT
718
- -- Keep metadata-only candidates; Sixb filters them with commit_extra_info.
719
- candidate.snapshot_id,
720
- candidate.snapshot_time,
721
- candidate.changes_made,
722
- candidate.commit_extra_info,
723
- coalesce(file_changes.has_file_change, false) AS has_file_change,
724
- coalesce(file_changes.has_file_delete_change, false) AS has_file_delete_change
725
- FROM candidate_snapshots candidate
726
- LEFT JOIN file_changes_by_snapshot file_changes
727
- ON file_changes.snapshot_id = candidate.snapshot_id
728
- ORDER BY candidate.snapshot_id DESC
729
- `)
730
972
 
731
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
+ }
732
979
  const metadata = parseCommitMetadata(row.commit_extra_info)
733
980
  return {
734
- snapshotId: String(getBigIntLike(row, "snapshot_id")),
981
+ snapshotId,
735
982
  createdAt: getDate(row, "snapshot_time"),
736
983
  changesMade: getString(row, "changes_made"),
737
984
  hasFileChange: getBoolean(row, "has_file_change"),