@sixb/ducklake 0.1.5 → 0.1.6

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.
@@ -0,0 +1,13 @@
1
+ import { type DatasetChanges, type ReadDatasetChangesInput } from "@sixb/core/lake-storage";
2
+ import type { DuckLakeStorageOptions } from "../types";
3
+ import type { DuckLakeConnectionManager } from "./ducklake-connection-manager";
4
+ import type { DuckLakeSnapshotReader } from "./ducklake-snapshot-reader";
5
+ /** Compare once in DuckDB, then page only the delta without holding the connection between yields. */
6
+ export declare class DuckLakeChangeReader {
7
+ private readonly options;
8
+ private readonly connections;
9
+ private readonly snapshots;
10
+ constructor(options: DuckLakeStorageOptions, connections: DuckLakeConnectionManager, snapshots: DuckLakeSnapshotReader);
11
+ readChanges(input: ReadDatasetChangesInput): Promise<DatasetChanges | null>;
12
+ }
13
+ //# sourceMappingURL=ducklake-change-reader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ducklake-change-reader.d.ts","sourceRoot":"","sources":["../../src/internal/ducklake-change-reader.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,cAAc,EAGnB,KAAK,uBAAuB,EAE7B,MAAM,yBAAyB,CAAA;AAChC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAA;AAGtD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAA;AAE9E,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAA;AAOxE,sGAAsG;AACtG,qBAAa,oBAAoB;IAE7B,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAFT,OAAO,EAAE,sBAAsB,EAC/B,WAAW,EAAE,yBAAyB,EACtC,SAAS,EAAE,sBAAsB;IAG9C,WAAW,CAAC,KAAK,EAAE,uBAAuB,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;CA2FlF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sixb/ducklake",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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-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",
39
+ "test:e2e:local": "bun test ./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",
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.9"
47
+ "@sixb/core": "^0.1.10"
48
48
  },
49
49
  "devDependencies": {
50
- "@sixb/core": "0.1.9",
50
+ "@sixb/core": "0.1.10",
51
51
  "@types/bun": "latest",
52
52
  "typescript": "^5.7.0"
53
53
  },
@@ -3,11 +3,14 @@ import type {
3
3
  BeginDatasetMergeInput,
4
4
  BeginDatasetWriteInput,
5
5
  DatasetCatalogState,
6
+ DatasetChanges,
6
7
  DatasetVersion,
7
8
  LakeMergeSession,
8
9
  LakeWriteSession,
10
+ ReadDatasetChangesInput,
9
11
  ReadDatasetRowsInput,
10
12
  } from "@sixb/core/lake-storage"
13
+ import { DuckLakeChangeReader } from "./internal/ducklake-change-reader"
11
14
  import { DuckLakeConnectionManager } from "./internal/ducklake-connection-manager"
12
15
  import { DuckLakeDatasetCatalog } from "./internal/ducklake-dataset-catalog"
13
16
  import { DuckLakeMaintenance } from "./internal/ducklake-maintenance"
@@ -36,6 +39,7 @@ export class DuckLakeStorage implements LakeStorageWithSql<"duckdb"> {
36
39
  private readonly datasets: DuckLakeDatasetCatalog
37
40
  private readonly maintenance: DuckLakeMaintenance
38
41
  private readonly rows: DuckLakeRowReader
42
+ private readonly changes: DuckLakeChangeReader
39
43
  private readonly snapshotReader: DuckLakeSnapshotReader
40
44
  private readonly writes: DuckLakeWriteCoordinator
41
45
 
@@ -59,6 +63,11 @@ export class DuckLakeStorage implements LakeStorageWithSql<"duckdb"> {
59
63
  this.datasets
60
64
  )
61
65
  this.rows = new DuckLakeRowReader(normalizedOptions, this.connections, this.snapshotReader)
66
+ this.changes = new DuckLakeChangeReader(
67
+ normalizedOptions,
68
+ this.connections,
69
+ this.snapshotReader
70
+ )
62
71
  this.writes = new DuckLakeWriteCoordinator(
63
72
  normalizedOptions,
64
73
  this.connections,
@@ -122,6 +131,10 @@ export class DuckLakeStorage implements LakeStorageWithSql<"duckdb"> {
122
131
  return this.rows.readRows(input)
123
132
  }
124
133
 
134
+ readChanges(input: ReadDatasetChangesInput): Promise<DatasetChanges | null> {
135
+ return this.changes.readChanges(input)
136
+ }
137
+
125
138
  async runMaintenance(options?: DuckLakeMaintenanceOptions): Promise<DuckLakeMaintenanceReport> {
126
139
  return this.maintenance.runMaintenance(options)
127
140
  }
@@ -0,0 +1,157 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import type { DatasetColumnDefinition, DatasetRow } from "@sixb/core"
3
+ import {
4
+ type DatasetChanges,
5
+ type DatasetRowChange,
6
+ LakeStorageError,
7
+ type ReadDatasetChangesInput,
8
+ resolveDatasetChangeColumns,
9
+ } from "@sixb/core/lake-storage"
10
+ import type { DuckLakeStorageOptions } from "../types"
11
+ import { getBigIntLike } from "./duckdb-row"
12
+ import type { DuckDbQueryRuntime } from "./duckdb-runtime"
13
+ import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
14
+ import { resolveDatasetTableRef } from "./ducklake-dataset-table-ref"
15
+ import type { DuckLakeSnapshotReader } from "./ducklake-snapshot-reader"
16
+ import { normalizeReadValue } from "./schema"
17
+ import { qualifiedTableName, quoteIdentifier } from "./sql"
18
+ import { parseVersionId } from "./versions"
19
+
20
+ const PAGE_ROWS = 5_000
21
+
22
+ /** Compare once in DuckDB, then page only the delta without holding the connection between yields. */
23
+ export class DuckLakeChangeReader {
24
+ constructor(
25
+ private readonly options: DuckLakeStorageOptions,
26
+ private readonly connections: DuckLakeConnectionManager,
27
+ private readonly snapshots: DuckLakeSnapshotReader
28
+ ) {}
29
+
30
+ async readChanges(input: ReadDatasetChangesInput): Promise<DatasetChanges | null> {
31
+ input.signal?.throwIfAborted()
32
+ const before = await this.snapshots.getVersion(input.datasetId, input.fromVersionId)
33
+ const after = await this.snapshots.getVersion(input.datasetId, input.toVersionId)
34
+ const columns = resolveDatasetChangeColumns(input, before, after)
35
+ if (!columns) return null
36
+ const lease = await this.connections.acquireAttachedRuntime()
37
+ const runtime = lease.runtime
38
+ const temporary = quoteIdentifier(`sixb_delta_${randomUUID().replaceAll("-", "")}`)
39
+ let retained = false
40
+ let created = false
41
+ try {
42
+ const ref = await resolveDatasetTableRef(this.options, runtime, input.datasetId)
43
+ if (!ref) return null
44
+ const table = qualifiedTableName(this.options, ref.tableName)
45
+ const oldRelation = `${table} AT (VERSION => ${parseVersionId(input.fromVersionId)})`
46
+ const newRelation = `${table} AT (VERSION => ${parseVersionId(input.toVersionId)})`
47
+ const previousCount = await uniqueRowCount(runtime, oldRelation, input.keyColumns)
48
+ const nextCount = await uniqueRowCount(runtime, newRelation, input.keyColumns)
49
+ if (previousCount === null || nextCount === null) return null
50
+ input.signal?.throwIfAborted()
51
+ const key = quoteIdentifier(input.keyColumns[0]!)
52
+ const values = columns.flatMap((column, index) => [
53
+ `b.${quoteIdentifier(column.name)} AS b${index}`,
54
+ `a.${quoteIdentifier(column.name)} AS a${index}`,
55
+ ])
56
+ const join = input.keyColumns
57
+ .map((name) => `b.${quoteIdentifier(name)} = a.${quoteIdentifier(name)}`)
58
+ .join(" AND ")
59
+ const changed = columns
60
+ .map(
61
+ (column) =>
62
+ `b.${quoteIdentifier(column.name)} IS DISTINCT FROM a.${quoteIdentifier(column.name)}`
63
+ )
64
+ .join(" OR ")
65
+ // The native change feed reflects DELETE/INSERT writes: snapshot ingestion can produce 2N
66
+ // physical changes for one logical edit. Compare the pinned states instead of replaying it.
67
+ await runtime.run(`CREATE TEMP TABLE ${temporary} AS
68
+ SELECT b.${key} IS NOT NULL AS before_present, a.${key} IS NOT NULL AS after_present,
69
+ ${values.join(", ")}
70
+ FROM (SELECT * FROM ${oldRelation}) b
71
+ FULL OUTER JOIN (SELECT * FROM ${newRelation}) a ON ${join}
72
+ WHERE b.${key} IS NULL OR a.${key} IS NULL OR ${changed}`)
73
+ created = true
74
+ const [count] = await runtime.query(`SELECT count(*) AS count FROM ${temporary}`)
75
+ const changeCount = safeCount(count!, "count")
76
+ input.signal?.throwIfAborted()
77
+ let closePromise: Promise<void> | undefined
78
+ const close = () => {
79
+ closePromise ??= runtime.run(`DROP TABLE IF EXISTS ${temporary}`)
80
+ return closePromise
81
+ }
82
+ const changes = (async function* (): AsyncIterable<DatasetRowChange> {
83
+ try {
84
+ let cursor = -1n
85
+ let delivered = 0
86
+ while (delivered < changeCount) {
87
+ input.signal?.throwIfAborted()
88
+ if (closePromise) throw new LakeStorageError("[SixbDuckLake] Change reader is closed.")
89
+ const page = await runtime.query(`SELECT rowid AS position, * FROM ${temporary}
90
+ WHERE rowid > ${cursor} ORDER BY rowid LIMIT ${PAGE_ROWS}`)
91
+ if (page.length === 0)
92
+ throw new LakeStorageError(
93
+ "[SixbDuckLake] Change read ended before its declared count."
94
+ )
95
+ for (const row of page) {
96
+ input.signal?.throwIfAborted()
97
+ delivered++
98
+ yield {
99
+ before: row.before_present === true ? rowImage(row, "b", columns) : null,
100
+ after: row.after_present === true ? rowImage(row, "a", columns) : null,
101
+ }
102
+ }
103
+ cursor = getBigIntLike(page[page.length - 1]!, "position")
104
+ }
105
+ } finally {
106
+ await close()
107
+ }
108
+ })()
109
+ retained = true
110
+ return { fromRowCount: previousCount, toRowCount: nextCount, changeCount, changes, close }
111
+ } finally {
112
+ // The delta is a DuckDB temp table, independent of the attached DuckLake catalog. Release
113
+ // the local attachment lock before returning it, otherwise consumer lake operations deadlock.
114
+ try {
115
+ if (!retained && created) await runtime.run(`DROP TABLE IF EXISTS ${temporary}`)
116
+ } finally {
117
+ await lease.release()
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ async function uniqueRowCount(
124
+ runtime: DuckDbQueryRuntime,
125
+ relation: string,
126
+ keys: readonly string[]
127
+ ): Promise<number | null> {
128
+ const names = keys.map(quoteIdentifier)
129
+ const invalid = names.map((name) => `${name} IS NULL OR trim(${name}) = ''`).join(" OR ")
130
+ const [row] = await runtime.query(`SELECT count(*) AS total,
131
+ count(DISTINCT (${names.join(", ")})) AS unique_keys,
132
+ count(*) FILTER (WHERE ${invalid}) AS invalid_keys FROM ${relation}`)
133
+ const count = safeCount(row!, "total")
134
+ return safeCount(row!, "unique_keys") === count && safeCount(row!, "invalid_keys") === 0
135
+ ? count
136
+ : null
137
+ }
138
+
139
+ function safeCount(row: Readonly<Record<string, unknown>>, name: string): number {
140
+ const count = Number(getBigIntLike(row, name))
141
+ if (!Number.isSafeInteger(count) || count < 0)
142
+ throw new LakeStorageError("[SixbDuckLake] Change count exceeds the supported integer range.")
143
+ return count
144
+ }
145
+
146
+ function rowImage(
147
+ row: Readonly<Record<string, unknown>>,
148
+ prefix: "a" | "b",
149
+ columns: readonly DatasetColumnDefinition[]
150
+ ): DatasetRow {
151
+ return Object.fromEntries(
152
+ columns.map((column, index) => [
153
+ column.name,
154
+ normalizeReadValue(row[`${prefix}${index}`], column),
155
+ ])
156
+ )
157
+ }