@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.
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/dist/ducklake-storage.d.ts +42 -0
- package/dist/ducklake-storage.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3112 -0
- package/dist/index.js.map +34 -0
- package/dist/internal/catalog-key.d.ts +11 -0
- package/dist/internal/catalog-key.d.ts.map +1 -0
- package/dist/internal/dataset-row-commit.d.ts +26 -0
- package/dist/internal/dataset-row-commit.d.ts.map +1 -0
- package/dist/internal/duckdb-column-types.d.ts +45 -0
- package/dist/internal/duckdb-column-types.d.ts.map +1 -0
- package/dist/internal/duckdb-decimal.d.ts +6 -0
- package/dist/internal/duckdb-decimal.d.ts.map +1 -0
- package/dist/internal/duckdb-row.d.ts +8 -0
- package/dist/internal/duckdb-row.d.ts.map +1 -0
- package/dist/internal/duckdb-runtime.d.ts +63 -0
- package/dist/internal/duckdb-runtime.d.ts.map +1 -0
- package/dist/internal/duckdb-value-converter.d.ts +7 -0
- package/dist/internal/duckdb-value-converter.d.ts.map +1 -0
- package/dist/internal/ducklake-catalog-schema.d.ts +12 -0
- package/dist/internal/ducklake-catalog-schema.d.ts.map +1 -0
- package/dist/internal/ducklake-connection-manager.d.ts +67 -0
- package/dist/internal/ducklake-connection-manager.d.ts.map +1 -0
- package/dist/internal/ducklake-current-dataset-definitions.d.ts +10 -0
- package/dist/internal/ducklake-current-dataset-definitions.d.ts.map +1 -0
- package/dist/internal/ducklake-dataset-catalog.d.ts +33 -0
- package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -0
- package/dist/internal/ducklake-dataset-table-ref.d.ts +9 -0
- package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -0
- package/dist/internal/ducklake-maintenance.d.ts +9 -0
- package/dist/internal/ducklake-maintenance.d.ts.map +1 -0
- package/dist/internal/ducklake-row-reader.d.ts +24 -0
- package/dist/internal/ducklake-row-reader.d.ts.map +1 -0
- package/dist/internal/ducklake-snapshot-reader.d.ts +73 -0
- package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -0
- package/dist/internal/ducklake-sql-executor.d.ts +33 -0
- package/dist/internal/ducklake-sql-executor.d.ts.map +1 -0
- package/dist/internal/ducklake-write-coordinator.d.ts +53 -0
- package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -0
- package/dist/internal/ducklake-write-session.d.ts +17 -0
- package/dist/internal/ducklake-write-session.d.ts.map +1 -0
- package/dist/internal/names.d.ts +13 -0
- package/dist/internal/names.d.ts.map +1 -0
- package/dist/internal/row-appender.d.ts +7 -0
- package/dist/internal/row-appender.d.ts.map +1 -0
- package/dist/internal/schema.d.ts +35 -0
- package/dist/internal/schema.d.ts.map +1 -0
- package/dist/internal/sql-transform-relations.d.ts +13 -0
- package/dist/internal/sql-transform-relations.d.ts.map +1 -0
- package/dist/internal/sql.d.ts +62 -0
- package/dist/internal/sql.d.ts.map +1 -0
- package/dist/internal/versions.d.ts +22 -0
- package/dist/internal/versions.d.ts.map +1 -0
- package/dist/tsconfig.json +5 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/ducklake-storage.ts +130 -0
- package/src/index.ts +14 -0
- package/src/internal/catalog-key.ts +28 -0
- package/src/internal/dataset-row-commit.ts +128 -0
- package/src/internal/duckdb-column-types.ts +68 -0
- package/src/internal/duckdb-decimal.ts +36 -0
- package/src/internal/duckdb-row.ts +83 -0
- package/src/internal/duckdb-runtime.ts +347 -0
- package/src/internal/duckdb-value-converter.ts +27 -0
- package/src/internal/ducklake-catalog-schema.ts +116 -0
- package/src/internal/ducklake-connection-manager.ts +510 -0
- package/src/internal/ducklake-current-dataset-definitions.ts +228 -0
- package/src/internal/ducklake-dataset-catalog.ts +363 -0
- package/src/internal/ducklake-dataset-table-ref.ts +35 -0
- package/src/internal/ducklake-maintenance.ts +116 -0
- package/src/internal/ducklake-row-reader.ts +143 -0
- package/src/internal/ducklake-snapshot-reader.ts +826 -0
- package/src/internal/ducklake-sql-executor.ts +334 -0
- package/src/internal/ducklake-write-coordinator.ts +532 -0
- package/src/internal/ducklake-write-session.ts +170 -0
- package/src/internal/names.ts +111 -0
- package/src/internal/row-appender.ts +73 -0
- package/src/internal/schema.ts +149 -0
- package/src/internal/sql-transform-relations.ts +99 -0
- package/src/internal/sql.ts +440 -0
- package/src/internal/versions.ts +140 -0
- 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
|
+
}
|