@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,228 @@
1
+ import type { DatasetDefinition } from "@sixb/core"
2
+ import { LakeStorageError } from "@sixb/core/lake-storage"
3
+ import type { DuckLakeStorageOptions } from "../types"
4
+ import {
5
+ getBigIntLike,
6
+ getBoolean,
7
+ getOptionalBigIntLike,
8
+ getOptionalString,
9
+ getString,
10
+ } from "./duckdb-row"
11
+ import type { DuckDbQueryRuntime } from "./duckdb-runtime"
12
+ import {
13
+ type DuckLakeCatalogColumn,
14
+ duckLakeCatalogColumnsToDatasetSchema,
15
+ } from "./ducklake-catalog-schema"
16
+ import { decodeDatasetTableName, encodeDatasetTableName } from "./names"
17
+ import { duckLakeAlias, duckLakeMetadataTableName, quoteSqlString, quoteSqlStringList } from "./sql"
18
+
19
+ interface CurrentDatasetTable {
20
+ readonly datasetId: string
21
+ readonly tableName: string
22
+ readonly comment?: string
23
+ }
24
+
25
+ interface CurrentDatasetPartition {
26
+ readonly tableName: string
27
+ readonly columnName: string
28
+ readonly transform: string
29
+ }
30
+
31
+ export interface ReadCurrentDatasetDefinitionsInput {
32
+ readonly options: DuckLakeStorageOptions
33
+ readonly runtime: DuckDbQueryRuntime
34
+ readonly datasetIds?: readonly string[]
35
+ }
36
+
37
+ export async function readCurrentDatasetDefinitions(
38
+ input: ReadCurrentDatasetDefinitionsInput
39
+ ): Promise<Map<string, DatasetDefinition>> {
40
+ if (input.datasetIds?.length === 0) {
41
+ return new Map()
42
+ }
43
+
44
+ const tables = await readCurrentDatasetTables(input, input.datasetIds)
45
+ if (tables.length === 0) {
46
+ return new Map()
47
+ }
48
+
49
+ const tableNames = tables.map((table) => table.tableName)
50
+ const columnsByTableName = groupColumnsByTableName(
51
+ await readCurrentDatasetColumns(input, tableNames)
52
+ )
53
+ const partitionByTableName = groupPartitionsByTableName(
54
+ await readCurrentDatasetPartitions(input, tableNames)
55
+ )
56
+ const definitions = new Map<string, DatasetDefinition>()
57
+
58
+ for (const table of tables) {
59
+ const partitionBy = partitionByTableName.get(table.tableName) ?? []
60
+ definitions.set(table.datasetId, {
61
+ kind: "dataset",
62
+ id: table.datasetId,
63
+ schema: duckLakeCatalogColumnsToDatasetSchema(
64
+ table.tableName,
65
+ columnsByTableName.get(table.tableName) ?? []
66
+ ),
67
+ ...(partitionBy.length > 0 ? { partitionBy } : {}),
68
+ ...(table.comment !== undefined ? { description: table.comment } : {}),
69
+ })
70
+ }
71
+
72
+ return definitions
73
+ }
74
+
75
+ async function readCurrentDatasetTables(
76
+ input: ReadCurrentDatasetDefinitionsInput,
77
+ datasetIds: readonly string[] | undefined
78
+ ): Promise<readonly CurrentDatasetTable[]> {
79
+ if (datasetIds !== undefined && datasetIds.length === 0) {
80
+ return []
81
+ }
82
+
83
+ const tableNameFilter =
84
+ datasetIds === undefined
85
+ ? ""
86
+ : `AND table_name IN (${quoteSqlStringList(
87
+ [...new Set(datasetIds)].map((datasetId) => encodeDatasetTableName(datasetId))
88
+ )})`
89
+
90
+ const rows = await input.runtime.query(
91
+ `SELECT table_name, comment FROM duckdb_tables() WHERE database_name = ${quoteSqlString(
92
+ duckLakeAlias(input.options)
93
+ )} AND schema_name = 'main' ${tableNameFilter} AND NOT internal ORDER BY table_name`
94
+ )
95
+
96
+ const tables: CurrentDatasetTable[] = []
97
+ for (const row of rows) {
98
+ const tableName = getString(row, "table_name")
99
+ const datasetId = decodeDatasetTableName(tableName)
100
+ if (datasetId === null) {
101
+ continue
102
+ }
103
+
104
+ tables.push({
105
+ datasetId,
106
+ tableName,
107
+ comment: getOptionalString(row, "comment"),
108
+ })
109
+ }
110
+
111
+ return tables
112
+ }
113
+
114
+ async function readCurrentDatasetColumns(
115
+ input: ReadCurrentDatasetDefinitionsInput,
116
+ tableNames: readonly string[]
117
+ ): Promise<readonly DuckLakeCatalogColumn[]> {
118
+ if (tableNames.length === 0) {
119
+ return []
120
+ }
121
+
122
+ const ducklakeTable = duckLakeMetadataTableName(input.options, "ducklake_table")
123
+ const ducklakeColumn = duckLakeMetadataTableName(input.options, "ducklake_column")
124
+ const rows = await input.runtime.query(`
125
+ SELECT
126
+ table_meta.table_name,
127
+ column_meta.column_id,
128
+ column_meta.column_order,
129
+ column_meta.column_name,
130
+ column_meta.column_type,
131
+ CAST(column_meta.nulls_allowed AS BOOLEAN) AS nulls_allowed,
132
+ column_meta.parent_column
133
+ FROM ${ducklakeTable} table_meta
134
+ JOIN ${ducklakeColumn} column_meta
135
+ ON column_meta.table_id = table_meta.table_id
136
+ AND column_meta.end_snapshot IS NULL
137
+ WHERE table_meta.end_snapshot IS NULL
138
+ AND table_meta.table_name IN (${quoteSqlStringList(tableNames)})
139
+ ORDER BY table_meta.table_name, column_meta.column_order
140
+ `)
141
+
142
+ return rows.map((row) => ({
143
+ tableName: getString(row, "table_name"),
144
+ columnId: getBigIntLike(row, "column_id"),
145
+ columnOrder: getBigIntLike(row, "column_order"),
146
+ columnName: getString(row, "column_name"),
147
+ columnType: getString(row, "column_type"),
148
+ nullsAllowed: getBoolean(row, "nulls_allowed"),
149
+ parentColumnId: getOptionalBigIntLike(row, "parent_column"),
150
+ }))
151
+ }
152
+
153
+ async function readCurrentDatasetPartitions(
154
+ input: ReadCurrentDatasetDefinitionsInput,
155
+ tableNames: readonly string[]
156
+ ): Promise<readonly CurrentDatasetPartition[]> {
157
+ if (tableNames.length === 0) {
158
+ return []
159
+ }
160
+
161
+ const ducklakeTable = duckLakeMetadataTableName(input.options, "ducklake_table")
162
+ const ducklakePartitionInfo = duckLakeMetadataTableName(input.options, "ducklake_partition_info")
163
+ const ducklakePartitionColumn = duckLakeMetadataTableName(
164
+ input.options,
165
+ "ducklake_partition_column"
166
+ )
167
+ const ducklakeColumn = duckLakeMetadataTableName(input.options, "ducklake_column")
168
+ const rows = await input.runtime.query(`
169
+ SELECT
170
+ table_meta.table_name,
171
+ column_meta.column_name,
172
+ partition_column.transform
173
+ FROM ${ducklakeTable} table_meta
174
+ JOIN ${ducklakePartitionInfo} partition_info
175
+ ON partition_info.table_id = table_meta.table_id
176
+ AND partition_info.end_snapshot IS NULL
177
+ JOIN ${ducklakePartitionColumn} partition_column
178
+ ON partition_column.table_id = table_meta.table_id
179
+ AND partition_column.partition_id = partition_info.partition_id
180
+ JOIN ${ducklakeColumn} column_meta
181
+ ON column_meta.table_id = table_meta.table_id
182
+ AND column_meta.column_id = partition_column.column_id
183
+ AND column_meta.end_snapshot IS NULL
184
+ WHERE table_meta.end_snapshot IS NULL
185
+ AND table_meta.table_name IN (${quoteSqlStringList(tableNames)})
186
+ ORDER BY table_meta.table_name, partition_column.partition_key_index
187
+ `)
188
+
189
+ return rows.map((row) => {
190
+ const partition = {
191
+ tableName: getString(row, "table_name"),
192
+ columnName: getString(row, "column_name"),
193
+ transform: getString(row, "transform"),
194
+ } satisfies CurrentDatasetPartition
195
+
196
+ if (partition.transform !== "identity") {
197
+ throw new LakeStorageError(
198
+ `[SixbDuckLake] Dataset table '${partition.tableName}' uses unsupported DuckLake partition transform '${partition.transform}'.`
199
+ )
200
+ }
201
+
202
+ return partition
203
+ })
204
+ }
205
+
206
+ function groupColumnsByTableName(
207
+ rows: readonly DuckLakeCatalogColumn[]
208
+ ): Map<string, DuckLakeCatalogColumn[]> {
209
+ const result = new Map<string, DuckLakeCatalogColumn[]>()
210
+ for (const row of rows) {
211
+ const tableRows = result.get(row.tableName) ?? []
212
+ tableRows.push(row)
213
+ result.set(row.tableName, tableRows)
214
+ }
215
+ return result
216
+ }
217
+
218
+ function groupPartitionsByTableName(
219
+ rows: readonly CurrentDatasetPartition[]
220
+ ): Map<string, string[]> {
221
+ const result = new Map<string, string[]>()
222
+ for (const row of rows) {
223
+ const tablePartitions = result.get(row.tableName) ?? []
224
+ tablePartitions.push(row.columnName)
225
+ result.set(row.tableName, tablePartitions)
226
+ }
227
+ return result
228
+ }
@@ -0,0 +1,363 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import type { DatasetDefinition, DatasetSchema } from "@sixb/core"
3
+ import type { DatasetDefinitionUpdatePlan, DatasetSchemaUpdatePlan } from "@sixb/core/lake-storage"
4
+ import { LakeStorageError, planDatasetDefinitionUpdate } from "@sixb/core/lake-storage"
5
+ import type { DuckLakeStorageOptions } from "../types"
6
+ import { getBigIntLike, getBoolean, getOptionalBigIntLike, getString } from "./duckdb-row"
7
+ import type { DuckDbQueryRuntime } from "./duckdb-runtime"
8
+ import {
9
+ type DuckLakeCatalogColumn,
10
+ duckLakeCatalogColumnsToDatasetSchema,
11
+ } from "./ducklake-catalog-schema"
12
+ import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
13
+ import { readCurrentDatasetDefinitions } from "./ducklake-current-dataset-definitions"
14
+ import { encodeDatasetTableName } from "./names"
15
+ import { datasetColumnToDuckDbSql, datasetSchemaToDuckDbColumnsSql } from "./schema"
16
+ import {
17
+ duckLakeAlias,
18
+ duckLakeMetadataTableName,
19
+ qualifiedTableName,
20
+ quoteIdentifier,
21
+ quoteSqlString,
22
+ } from "./sql"
23
+
24
+ /**
25
+ * Translates between Sixb dataset definitions and DuckLake table metadata.
26
+ *
27
+ * DuckLake stays the source of truth for the physical table, schema, comments,
28
+ * and partitioning. Sixb only decides which definition changes are safe to
29
+ * translate into DuckLake DDL.
30
+ */
31
+ export class DuckLakeDatasetCatalog {
32
+ constructor(
33
+ private readonly options: DuckLakeStorageOptions,
34
+ private readonly connections: DuckLakeConnectionManager
35
+ ) {}
36
+
37
+ async createDataset(definition: DatasetDefinition): Promise<DatasetDefinition> {
38
+ this.assertSchema(definition)
39
+
40
+ // Step 1: read the current DuckLake table back into Sixb shape. The
41
+ // existing definition is DuckLake's catalog state; the next definition is
42
+ // the developer's current source definition.
43
+ const existing = await this.getDataset(definition.id)
44
+
45
+ if (existing) {
46
+ const plan = planDatasetDefinitionUpdate(existing, definition)
47
+ if (plan.changed) {
48
+ await this.applyExistingDatasetUpdate(plan)
49
+ this.connections.markLocalCatalogChanged()
50
+ await this.connections.resetRuntime()
51
+ }
52
+
53
+ return (await this.getDataset(definition.id)) ?? plan.definition
54
+ }
55
+
56
+ // New datasets materialize as one DuckLake table with native columns. The
57
+ // table name is reversible, so no sidecar mapping is needed.
58
+ const tableName = encodeDatasetTableName(definition.id)
59
+ const qualifiedName = qualifiedTableName(this.options, tableName)
60
+
61
+ // Creating a dataset can be several catalog DDL statements. Keep them in
62
+ // one runtime slot so writes cannot observe a half-created table.
63
+ await this.connections.withExclusiveAttached(async (runtime) => {
64
+ await runtime.run(
65
+ `CREATE TABLE ${qualifiedName} (${datasetSchemaToDuckDbColumnsSql(definition.schema)})`
66
+ )
67
+
68
+ if (definition.description !== undefined) {
69
+ await runtime.run(
70
+ `COMMENT ON TABLE ${qualifiedName} IS ${quoteSqlString(definition.description)}`
71
+ )
72
+ }
73
+
74
+ await this.applyPartitionBy(runtime, qualifiedName, definition)
75
+ })
76
+ this.connections.markLocalCatalogChanged()
77
+
78
+ return structuredClone(definition)
79
+ }
80
+
81
+ async assertDatasetDefinitionsCompatible(
82
+ definitions: readonly DatasetDefinition[]
83
+ ): Promise<void> {
84
+ const failures: string[] = []
85
+ const checkedDefinitions: DatasetDefinition[] = []
86
+
87
+ for (const definition of definitions) {
88
+ try {
89
+ this.assertSchema(definition)
90
+ checkedDefinitions.push(definition)
91
+ } catch (error) {
92
+ const message = error instanceof Error ? error.message : String(error)
93
+ failures.push(`- ${definition.id}: ${message}`)
94
+ }
95
+ }
96
+
97
+ const existingById = await this.readCurrentDatasetDefinitions({
98
+ datasetIds: checkedDefinitions.map((definition) => definition.id),
99
+ })
100
+
101
+ for (const definition of checkedDefinitions) {
102
+ const existing = existingById.get(definition.id)
103
+ if (!existing) {
104
+ continue
105
+ }
106
+
107
+ try {
108
+ planDatasetDefinitionUpdate(existing, definition)
109
+ } catch (error) {
110
+ const message = error instanceof Error ? error.message : String(error)
111
+ failures.push(`- ${definition.id}: ${message}`)
112
+ }
113
+ }
114
+
115
+ if (failures.length > 0) {
116
+ const details = failures.join("\n")
117
+ throw new LakeStorageError(
118
+ `[SixbLake] Lake dataset definition check failed for ${failures.length} dataset(s).\n${details}`
119
+ )
120
+ }
121
+ }
122
+
123
+ async getDataset(datasetId: string): Promise<DatasetDefinition | null> {
124
+ this.connections.assertOpen()
125
+
126
+ return this.connections.withAttachedRuntime((runtime) =>
127
+ this.getDatasetOnRuntime(runtime, datasetId)
128
+ )
129
+ }
130
+
131
+ async getDatasetOnRuntime(
132
+ runtime: DuckDbQueryRuntime,
133
+ datasetId: string
134
+ ): Promise<DatasetDefinition | null> {
135
+ return (
136
+ (
137
+ await this.readCurrentDatasetDefinitionsOnRuntime(runtime, {
138
+ datasetIds: [datasetId],
139
+ })
140
+ ).get(datasetId) ?? null
141
+ )
142
+ }
143
+
144
+ async listDatasets(): Promise<readonly DatasetDefinition[]> {
145
+ this.connections.assertOpen()
146
+
147
+ return this.connections.withAttachedRuntime(async (runtime) => {
148
+ return [...(await this.readCurrentDatasetDefinitionsOnRuntime(runtime)).values()].sort(
149
+ (left, right) => left.id.localeCompare(right.id)
150
+ )
151
+ })
152
+ }
153
+
154
+ private async readCurrentDatasetDefinitions(options?: {
155
+ readonly datasetIds?: readonly string[]
156
+ }): Promise<Map<string, DatasetDefinition>> {
157
+ if (options?.datasetIds?.length === 0) {
158
+ return new Map()
159
+ }
160
+
161
+ return this.connections.withAttachedRuntime((runtime) =>
162
+ this.readCurrentDatasetDefinitionsOnRuntime(runtime, options)
163
+ )
164
+ }
165
+
166
+ private async readCurrentDatasetDefinitionsOnRuntime(
167
+ runtime: DuckDbQueryRuntime,
168
+ options?: { readonly datasetIds?: readonly string[] }
169
+ ): Promise<Map<string, DatasetDefinition>> {
170
+ return readCurrentDatasetDefinitions({
171
+ options: this.options,
172
+ runtime,
173
+ datasetIds: options?.datasetIds,
174
+ })
175
+ }
176
+
177
+ async getDatasetSchemaAtSnapshot(
178
+ runtime: DuckDbQueryRuntime,
179
+ datasetId: string,
180
+ tableName: string,
181
+ tableId: bigint,
182
+ snapshotId: string
183
+ ): Promise<DatasetSchema> {
184
+ assertDuckLakeSnapshotId(snapshotId)
185
+
186
+ const columns = await this.readDatasetColumnsAtSnapshot(runtime, tableName, tableId, snapshotId)
187
+ if (columns.length === 0) {
188
+ throw new LakeStorageError(
189
+ `[SixbDuckLake] Could not reconstruct schema for dataset '${datasetId}' at DuckLake snapshot '${snapshotId}'.`
190
+ )
191
+ }
192
+
193
+ return duckLakeCatalogColumnsToDatasetSchema(tableName, columns)
194
+ }
195
+
196
+ assertSchema(definition: DatasetDefinition): void {
197
+ if (!definition.schema) {
198
+ throw new LakeStorageError(
199
+ `[SixbDuckLake] Dataset '${definition.id}' requires a schema for DuckLake storage.`
200
+ )
201
+ }
202
+ }
203
+
204
+ private async applyExistingDatasetUpdate(plan: DatasetDefinitionUpdatePlan): Promise<void> {
205
+ const tableName = encodeDatasetTableName(plan.definition.id)
206
+ const qualifiedName = qualifiedTableName(this.options, tableName)
207
+
208
+ await this.connections.withExclusiveAttached(async (runtime) => {
209
+ if (plan.schema.kind === "none") {
210
+ await this.applyMetadataDelta(runtime, qualifiedName, plan)
211
+ return
212
+ }
213
+
214
+ // Schema-only DuckLake snapshots still matter to Sixb. Apply schema and
215
+ // compatible metadata DDL atomically, then tag the commit so versions can
216
+ // treat it as this dataset's schema-change version.
217
+ await runtime.run("BEGIN TRANSACTION")
218
+ let committed = false
219
+ try {
220
+ await this.applySchemaEvolutionDdl(runtime, qualifiedName, plan.schema)
221
+ await this.applyMetadataDelta(runtime, qualifiedName, plan)
222
+ await this.setSchemaEvolutionCommitMetadata(runtime, plan.definition.id, plan.schema)
223
+
224
+ await runtime.run("COMMIT")
225
+ committed = true
226
+ } catch (error) {
227
+ if (!committed) {
228
+ await this.rollbackTransaction(runtime)
229
+ }
230
+ throw error
231
+ }
232
+ })
233
+ }
234
+
235
+ private async applySchemaEvolutionDdl(
236
+ runtime: DuckDbQueryRuntime,
237
+ qualifiedName: string,
238
+ plan: Extract<DatasetSchemaUpdatePlan, { readonly kind: "addNullableColumns" }>
239
+ ): Promise<void> {
240
+ for (const column of plan.columns) {
241
+ await runtime.run(
242
+ `ALTER TABLE ${qualifiedName} ADD COLUMN ${datasetColumnToDuckDbSql(column)}`
243
+ )
244
+ }
245
+ }
246
+
247
+ private async applyMetadataDelta(
248
+ runtime: DuckDbQueryRuntime,
249
+ qualifiedName: string,
250
+ plan: DatasetDefinitionUpdatePlan
251
+ ): Promise<void> {
252
+ // A compatible merge may add optional metadata after first creation. Apply
253
+ // those deltas explicitly so createDataset never silently accepts a change
254
+ // that DuckLake did not persist.
255
+ if (plan.metadata.descriptionChanged && plan.definition.description !== undefined) {
256
+ await runtime.run(
257
+ `COMMENT ON TABLE ${qualifiedName} IS ${quoteSqlString(plan.definition.description)}`
258
+ )
259
+ }
260
+
261
+ if (plan.metadata.partitionByChanged) {
262
+ await this.applyPartitionBy(runtime, qualifiedName, plan.definition)
263
+ }
264
+ }
265
+
266
+ private async applyPartitionBy(
267
+ runtime: DuckDbQueryRuntime,
268
+ qualifiedName: string,
269
+ definition: DatasetDefinition
270
+ ): Promise<void> {
271
+ if (definition.partitionBy === undefined || definition.partitionBy.length === 0) {
272
+ return
273
+ }
274
+
275
+ // Partitioning is DuckLake physical metadata. If DuckLake cannot apply it,
276
+ // fail with the original driver message instead of returning stale
277
+ // partition metadata.
278
+ try {
279
+ await runtime.run(
280
+ `ALTER TABLE ${qualifiedName} SET PARTITIONED BY (${definition.partitionBy
281
+ .map((columnName) => quoteIdentifier(columnName))
282
+ .join(", ")})`
283
+ )
284
+ } catch (error) {
285
+ const message = error instanceof Error ? error.message : String(error)
286
+ throw new LakeStorageError(
287
+ `[SixbDuckLake] Dataset '${definition.id}' cannot apply partitionBy because DuckLake rejected ALTER TABLE SET PARTITIONED BY: ${message}`
288
+ )
289
+ }
290
+ }
291
+
292
+ private async setSchemaEvolutionCommitMetadata(
293
+ runtime: DuckDbQueryRuntime,
294
+ datasetId: string,
295
+ plan: Extract<DatasetSchemaUpdatePlan, { readonly kind: "addNullableColumns" }>
296
+ ): Promise<void> {
297
+ await runtime.run(
298
+ `CALL ${quoteIdentifier(duckLakeAlias(this.options))}.set_commit_message(${quoteSqlString(
299
+ "Sixb"
300
+ )}, ${quoteSqlString(`evolve dataset ${datasetId} schema`)}, extra_info => ${quoteSqlString(
301
+ JSON.stringify({
302
+ sixb: {
303
+ kind: "datasetVersion",
304
+ datasetId,
305
+ commitId: randomUUID(),
306
+ mode: "schema",
307
+ schemaChange: {
308
+ addColumns: plan.columns.map((column) => column.name),
309
+ },
310
+ },
311
+ })
312
+ )})`
313
+ )
314
+ }
315
+
316
+ private async rollbackTransaction(runtime: DuckDbQueryRuntime): Promise<void> {
317
+ try {
318
+ await runtime.run("ROLLBACK")
319
+ } catch {
320
+ // Preserve the original DDL failure. DuckDB may already have closed the
321
+ // transaction if COMMIT itself failed.
322
+ }
323
+ }
324
+
325
+ private async readDatasetColumnsAtSnapshot(
326
+ runtime: DuckDbQueryRuntime,
327
+ tableName: string,
328
+ tableId: bigint,
329
+ snapshotId: string
330
+ ): Promise<readonly DuckLakeCatalogColumn[]> {
331
+ const ducklakeColumn = duckLakeMetadataTableName(this.options, "ducklake_column")
332
+ const rows = await runtime.query(`
333
+ SELECT
334
+ column_id,
335
+ column_order,
336
+ column_name,
337
+ column_type,
338
+ CAST(nulls_allowed AS BOOLEAN) AS nulls_allowed,
339
+ parent_column
340
+ FROM ${ducklakeColumn}
341
+ WHERE table_id = ${tableId}
342
+ AND begin_snapshot <= ${snapshotId}
343
+ AND (end_snapshot IS NULL OR end_snapshot > ${snapshotId})
344
+ ORDER BY column_order
345
+ `)
346
+
347
+ return rows.map((row) => ({
348
+ tableName,
349
+ columnId: getBigIntLike(row, "column_id"),
350
+ columnOrder: getBigIntLike(row, "column_order"),
351
+ columnName: getString(row, "column_name"),
352
+ columnType: getString(row, "column_type"),
353
+ nullsAllowed: getBoolean(row, "nulls_allowed"),
354
+ parentColumnId: getOptionalBigIntLike(row, "parent_column"),
355
+ }))
356
+ }
357
+ }
358
+
359
+ function assertDuckLakeSnapshotId(snapshotId: string): void {
360
+ if (!/^\d+$/.test(snapshotId)) {
361
+ throw new LakeStorageError(`[SixbDuckLake] Invalid DuckLake snapshot id '${snapshotId}'.`)
362
+ }
363
+ }
@@ -0,0 +1,35 @@
1
+ import type { DuckLakeStorageOptions } from "../types"
2
+ import { getBigIntLike } from "./duckdb-row"
3
+ import type { DuckDbQueryRuntime } from "./duckdb-runtime"
4
+ import { encodeDatasetTableName } from "./names"
5
+ import { duckLakeMetadataTableName, quoteSqlString } from "./sql"
6
+
7
+ export interface DatasetTableRef {
8
+ readonly datasetId: string
9
+ readonly tableName: string
10
+ readonly tableId: bigint
11
+ }
12
+
13
+ export async function resolveDatasetTableRef(
14
+ options: DuckLakeStorageOptions,
15
+ runtime: DuckDbQueryRuntime,
16
+ datasetId: string
17
+ ): Promise<DatasetTableRef | null> {
18
+ const tableName = encodeDatasetTableName(datasetId)
19
+ const ducklakeTable = duckLakeMetadataTableName(options, "ducklake_table")
20
+ const [row] = await runtime.query(`
21
+ SELECT table_id
22
+ FROM ${ducklakeTable}
23
+ WHERE table_name = ${quoteSqlString(tableName)}
24
+ AND end_snapshot IS NULL
25
+ LIMIT 1
26
+ `)
27
+
28
+ return row === undefined
29
+ ? null
30
+ : {
31
+ datasetId,
32
+ tableName,
33
+ tableId: getBigIntLike(row, "table_id"),
34
+ }
35
+ }