@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,128 @@
|
|
|
1
|
+
import type { DatasetDefinition } from "@sixb/core"
|
|
2
|
+
import { type DatasetWriteMode, LakeStorageError } from "@sixb/core/lake-storage"
|
|
3
|
+
import type { DuckLakeStorageOptions } from "../types"
|
|
4
|
+
import { getBigIntLike } from "./duckdb-row"
|
|
5
|
+
import type { DuckDbQueryRuntime } from "./duckdb-runtime"
|
|
6
|
+
import { encodeDatasetTableName } from "./names"
|
|
7
|
+
import { datasetSchemaColumnNamesSql } from "./schema"
|
|
8
|
+
import { qualifiedTableName } from "./sql"
|
|
9
|
+
|
|
10
|
+
export type CommitRowCount =
|
|
11
|
+
| { readonly kind: "exact"; readonly value: number }
|
|
12
|
+
| { readonly kind: "unknown" }
|
|
13
|
+
|
|
14
|
+
export interface ApplyDatasetRowsResult {
|
|
15
|
+
readonly dataChangeExpected: boolean
|
|
16
|
+
readonly sourceRowCount: number
|
|
17
|
+
readonly resultingRowCount: CommitRowCount
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function assertDatasetWriteMode(
|
|
21
|
+
mode: unknown,
|
|
22
|
+
operation: string
|
|
23
|
+
): asserts mode is DatasetWriteMode {
|
|
24
|
+
if (mode !== "snapshot" && mode !== "append") {
|
|
25
|
+
throw new LakeStorageError(
|
|
26
|
+
`[SixbDuckLake] Invalid ${operation} mode '${String(mode)}'. Expected 'snapshot' or 'append'.`
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function applyDatasetRowsFromRelation(input: {
|
|
32
|
+
readonly options: DuckLakeStorageOptions
|
|
33
|
+
readonly runtime: DuckDbQueryRuntime
|
|
34
|
+
readonly dataset: DatasetDefinition
|
|
35
|
+
readonly mode: DatasetWriteMode
|
|
36
|
+
/** Provider-rendered relation SQL, such as a quoted staging or temp table. */
|
|
37
|
+
readonly sourceRelationSql: string
|
|
38
|
+
readonly previousRowCount?: number
|
|
39
|
+
}): Promise<ApplyDatasetRowsResult> {
|
|
40
|
+
const tableName = encodeDatasetTableName(input.dataset.id)
|
|
41
|
+
const table = qualifiedTableName(input.options, tableName)
|
|
42
|
+
const columnsSql = datasetSchemaColumnNamesSql(input.dataset.schema)
|
|
43
|
+
|
|
44
|
+
if (input.mode === "snapshot") {
|
|
45
|
+
const source = await fingerprintRelation(input.runtime, input.sourceRelationSql, columnsSql)
|
|
46
|
+
const target = await fingerprintRelation(input.runtime, table, columnsSql)
|
|
47
|
+
|
|
48
|
+
if (sameFingerprint(source, target)) {
|
|
49
|
+
return {
|
|
50
|
+
dataChangeExpected: false,
|
|
51
|
+
sourceRowCount: source.rowCount,
|
|
52
|
+
resultingRowCount: { kind: "exact", value: target.rowCount },
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await input.runtime.run(`DELETE FROM ${table}`)
|
|
57
|
+
await input.runtime.run(
|
|
58
|
+
`INSERT INTO ${table} (${columnsSql}) SELECT ${columnsSql} FROM ${input.sourceRelationSql}`
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
dataChangeExpected: source.rowCount > 0 || target.rowCount > 0,
|
|
63
|
+
sourceRowCount: source.rowCount,
|
|
64
|
+
resultingRowCount: { kind: "exact", value: source.rowCount },
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sourceRowCount = await countRows(input.runtime, input.sourceRelationSql)
|
|
69
|
+
await input.runtime.run(
|
|
70
|
+
`INSERT INTO ${table} (${columnsSql}) SELECT ${columnsSql} FROM ${input.sourceRelationSql}`
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
dataChangeExpected: sourceRowCount > 0,
|
|
75
|
+
sourceRowCount,
|
|
76
|
+
resultingRowCount:
|
|
77
|
+
input.previousRowCount === undefined
|
|
78
|
+
? { kind: "unknown" }
|
|
79
|
+
: { kind: "exact", value: input.previousRowCount + sourceRowCount },
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface RelationFingerprint {
|
|
84
|
+
readonly rowCount: number
|
|
85
|
+
readonly hashSum: string
|
|
86
|
+
readonly hashXor: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function fingerprintRelation(
|
|
90
|
+
runtime: DuckDbQueryRuntime,
|
|
91
|
+
relationSql: string,
|
|
92
|
+
columnsSql: string
|
|
93
|
+
): Promise<RelationFingerprint> {
|
|
94
|
+
const [row] = await runtime.query(`
|
|
95
|
+
SELECT
|
|
96
|
+
count(*) AS row_count,
|
|
97
|
+
coalesce(sum(hash(${columnsSql})::HUGEINT), 0::HUGEINT) AS hash_sum,
|
|
98
|
+
coalesce(bit_xor(hash('sixb:snapshot:v1', ${columnsSql})), 0::UBIGINT) AS hash_xor
|
|
99
|
+
FROM ${relationSql}
|
|
100
|
+
`)
|
|
101
|
+
|
|
102
|
+
if (row === undefined) {
|
|
103
|
+
return { rowCount: 0, hashSum: "0", hashXor: "0" }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
rowCount: Number(getBigIntLike(row, "row_count")),
|
|
108
|
+
hashSum: getBigIntLike(row, "hash_sum").toString(),
|
|
109
|
+
hashXor: getBigIntLike(row, "hash_xor").toString(),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sameFingerprint(left: RelationFingerprint, right: RelationFingerprint): boolean {
|
|
114
|
+
return (
|
|
115
|
+
left.rowCount === right.rowCount &&
|
|
116
|
+
left.hashSum === right.hashSum &&
|
|
117
|
+
left.hashXor === right.hashXor
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function countRows(runtime: DuckDbQueryRuntime, relationSql: string): Promise<number> {
|
|
122
|
+
const [row] = await runtime.query(`SELECT count(*) AS row_count FROM ${relationSql}`)
|
|
123
|
+
if (row === undefined) {
|
|
124
|
+
return 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return Number(getBigIntLike(row, "row_count"))
|
|
128
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { DatasetColumnType } from "@sixb/core"
|
|
2
|
+
|
|
3
|
+
interface DuckDbColumnTypeMapping {
|
|
4
|
+
readonly sql: string
|
|
5
|
+
readonly matches: (typeSql: string) => boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface DuckDbDecimalTypeMapping extends DuckDbColumnTypeMapping {
|
|
9
|
+
readonly precision: number
|
|
10
|
+
readonly scale: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeDuckDbTypeSql(typeSql: string): string {
|
|
14
|
+
return typeSql
|
|
15
|
+
.trim()
|
|
16
|
+
.toUpperCase()
|
|
17
|
+
.replace(/\s+/g, " ")
|
|
18
|
+
.replace(/\s*([(),])\s*/g, "$1")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function defineDuckDbColumnType<const Sql extends string>(
|
|
22
|
+
sql: Sql,
|
|
23
|
+
aliases: readonly string[] = []
|
|
24
|
+
): DuckDbColumnTypeMapping & { readonly sql: Sql } {
|
|
25
|
+
const acceptedTypes = new Set([sql, ...aliases].map(normalizeDuckDbTypeSql))
|
|
26
|
+
return {
|
|
27
|
+
sql,
|
|
28
|
+
matches: (typeSql) => acceptedTypes.has(normalizeDuckDbTypeSql(typeSql)),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defineDuckDbDecimalType(precision: number, scale: number): DuckDbDecimalTypeMapping {
|
|
33
|
+
return {
|
|
34
|
+
...defineDuckDbColumnType(`DECIMAL(${precision}, ${scale})`),
|
|
35
|
+
precision,
|
|
36
|
+
scale,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* DuckDB representations used for every Sixb dataset column type.
|
|
42
|
+
*
|
|
43
|
+
* Each entry owns both its CREATE TABLE representation and the metadata
|
|
44
|
+
* spellings accepted when reconstructing a Sixb dataset schema.
|
|
45
|
+
*/
|
|
46
|
+
export const DUCKDB_COLUMN_TYPES = {
|
|
47
|
+
string: defineDuckDbColumnType("VARCHAR"),
|
|
48
|
+
boolean: defineDuckDbColumnType("BOOLEAN"),
|
|
49
|
+
int64: defineDuckDbColumnType("BIGINT", ["INT64"]),
|
|
50
|
+
float64: defineDuckDbColumnType("DOUBLE", ["FLOAT64"]),
|
|
51
|
+
decimal: defineDuckDbDecimalType(38, 9),
|
|
52
|
+
date: defineDuckDbColumnType("DATE"),
|
|
53
|
+
timestamp: defineDuckDbColumnType("TIMESTAMPTZ", ["TIMESTAMP WITH TIME ZONE"]),
|
|
54
|
+
json: defineDuckDbColumnType("JSON"),
|
|
55
|
+
fileRef: defineDuckDbColumnType(
|
|
56
|
+
"STRUCT(blobId VARCHAR, digest VARCHAR, sizeBytes BIGINT, fileName VARCHAR, mediaType VARCHAR, logicalPath VARCHAR)"
|
|
57
|
+
),
|
|
58
|
+
} satisfies Record<DatasetColumnType, DuckDbColumnTypeMapping>
|
|
59
|
+
|
|
60
|
+
export function findDatasetColumnTypeForDuckDbSql(typeSql: string): DatasetColumnType | undefined {
|
|
61
|
+
for (const type of Object.keys(DUCKDB_COLUMN_TYPES) as DatasetColumnType[]) {
|
|
62
|
+
if (DUCKDB_COLUMN_TYPES[type].matches(typeSql)) {
|
|
63
|
+
return type
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type DecimalValue, normalizeDecimalValue } from "@sixb/core"
|
|
2
|
+
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
3
|
+
import { DUCKDB_COLUMN_TYPES } from "./duckdb-column-types"
|
|
4
|
+
|
|
5
|
+
const decimalType = DUCKDB_COLUMN_TYPES.decimal
|
|
6
|
+
const maxIntegerDigits = decimalType.precision - decimalType.scale
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Normalize a dataset decimal and reject values DuckDB would round or overflow.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeDuckDbDecimalValue(value: unknown, columnName: string): DecimalValue {
|
|
12
|
+
if (typeof value !== "string") {
|
|
13
|
+
throw new LakeStorageError(
|
|
14
|
+
`[SixbDuckLake] Decimal column '${columnName}' must be an exact decimal string.`
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let normalized: DecimalValue
|
|
19
|
+
try {
|
|
20
|
+
normalized = normalizeDecimalValue(value)
|
|
21
|
+
} catch {
|
|
22
|
+
throw new LakeStorageError(
|
|
23
|
+
`[SixbDuckLake] Decimal column '${columnName}' must be an exact decimal string.`
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const unsigned = normalized.startsWith("-") ? normalized.slice(1) : normalized
|
|
28
|
+
const [integer, fraction = ""] = unsigned.split(".")
|
|
29
|
+
if (integer.length > maxIntegerDigits || fraction.length > decimalType.scale) {
|
|
30
|
+
throw new LakeStorageError(
|
|
31
|
+
`[SixbDuckLake] Decimal column '${columnName}' cannot be represented exactly as ${decimalType.sql}; expected at most ${maxIntegerDigits} integer digits and ${decimalType.scale} fractional digits.`
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return normalized
|
|
36
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
2
|
+
|
|
3
|
+
export type DuckDbRow = Readonly<Record<string, unknown>>
|
|
4
|
+
|
|
5
|
+
export function getString(row: DuckDbRow, key: string): string {
|
|
6
|
+
const value = row[key]
|
|
7
|
+
if (typeof value !== "string") {
|
|
8
|
+
throw new LakeStorageError(
|
|
9
|
+
`[SixbDuckLake] Expected DuckDB column '${key}' to be a string, got ${typeof value}.`
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getOptionalString(row: DuckDbRow, key: string): string | undefined {
|
|
17
|
+
const value = row[key]
|
|
18
|
+
if (value === null || value === undefined) {
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof value !== "string") {
|
|
23
|
+
throw new LakeStorageError(
|
|
24
|
+
`[SixbDuckLake] Expected DuckDB column '${key}' to be a string, got ${typeof value}.`
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getBoolean(row: DuckDbRow, key: string): boolean {
|
|
32
|
+
const value = row[key]
|
|
33
|
+
if (typeof value !== "boolean") {
|
|
34
|
+
throw new LakeStorageError(
|
|
35
|
+
`[SixbDuckLake] Expected DuckDB column '${key}' to be a boolean, got ${typeof value}.`
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getBigIntLike(row: DuckDbRow, key: string): bigint {
|
|
43
|
+
const value = row[key]
|
|
44
|
+
if (typeof value === "bigint") {
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
49
|
+
return BigInt(value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof value === "string" && /^-?\d+$/.test(value)) {
|
|
53
|
+
return BigInt(value)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw new LakeStorageError(
|
|
57
|
+
`[SixbDuckLake] Expected DuckDB column '${key}' to be an integer, got ${typeof value}.`
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getOptionalBigIntLike(row: DuckDbRow, key: string): bigint | undefined {
|
|
62
|
+
const value = row[key]
|
|
63
|
+
if (value === null || value === undefined) {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return getBigIntLike(row, key)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getDate(row: DuckDbRow, key: string): Date {
|
|
71
|
+
const value = row[key]
|
|
72
|
+
if (value instanceof Date) {
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof value === "string") {
|
|
77
|
+
return new Date(value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new LakeStorageError(
|
|
81
|
+
`[SixbDuckLake] Expected DuckDB column '${key}' to be a date, got ${typeof value}.`
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DuckDBConnection,
|
|
3
|
+
DuckDBInstance,
|
|
4
|
+
type DuckDBValue,
|
|
5
|
+
type DuckDBAppender as NodeDuckDBAppender,
|
|
6
|
+
} from "@duckdb/node-api"
|
|
7
|
+
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
8
|
+
import type { DuckDbRuntimeOptions, DuckLakeStorageOptions } from "../types"
|
|
9
|
+
import { sixbDuckDbValueConverter } from "./duckdb-value-converter"
|
|
10
|
+
import {
|
|
11
|
+
buildAttachSql,
|
|
12
|
+
buildConfigurePostgresMetadataPoolSql,
|
|
13
|
+
buildCreateSecretSql,
|
|
14
|
+
buildSetPostgresPoolSql,
|
|
15
|
+
quoteIdentifier,
|
|
16
|
+
requiredExtensions,
|
|
17
|
+
} from "./sql"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Narrow adapter around `@duckdb/node-api`.
|
|
21
|
+
*
|
|
22
|
+
* Keeping the public provider behind this interface confines the driver types to
|
|
23
|
+
* this module instead of leaking them into `DuckLakeStorage`. Calls on a runtime
|
|
24
|
+
* are serialized through one queue because a runtime owns one DuckDB connection.
|
|
25
|
+
*/
|
|
26
|
+
export interface DuckDbQueryRuntime {
|
|
27
|
+
run(sql: string, values?: readonly DuckDBValue[]): Promise<void>
|
|
28
|
+
runStatements(statements: readonly string[]): Promise<void>
|
|
29
|
+
query(sql: string, values?: readonly DuckDBValue[]): Promise<readonly Record<string, unknown>[]>
|
|
30
|
+
withAppender<T>(
|
|
31
|
+
tableName: string,
|
|
32
|
+
useAppender: (appender: DuckDbAppender) => T | Promise<T>
|
|
33
|
+
): Promise<T>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DuckDbRuntime extends DuckDbQueryRuntime {
|
|
37
|
+
streamRows(sql: string, values?: readonly DuckDBValue[]): AsyncIterable<Record<string, unknown>>
|
|
38
|
+
/**
|
|
39
|
+
* Reserve the runtime queue for a multi-statement critical section.
|
|
40
|
+
*
|
|
41
|
+
* Use this for sequences such as BEGIN/read metadata/write/COMMIT where an
|
|
42
|
+
* interleaved read or write would observe the wrong connection state.
|
|
43
|
+
*/
|
|
44
|
+
withExclusive<T>(useRuntime: (runtime: DuckDbExclusiveRuntime) => Promise<T>): Promise<T>
|
|
45
|
+
close(): Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Runtime surface available while the caller already owns the queue slot.
|
|
50
|
+
*
|
|
51
|
+
* This is the query/appender subset only: no streaming, no close, and no nested
|
|
52
|
+
* exclusive block.
|
|
53
|
+
*/
|
|
54
|
+
export type DuckDbExclusiveRuntime = DuckDbQueryRuntime
|
|
55
|
+
|
|
56
|
+
export interface DuckDbAppender {
|
|
57
|
+
appendNull(): void
|
|
58
|
+
appendBoolean(value: boolean): void
|
|
59
|
+
appendVarchar(value: string): void
|
|
60
|
+
appendBigInt(value: bigint): void
|
|
61
|
+
appendDouble(value: number): void
|
|
62
|
+
appendStruct(value: Readonly<Record<string, DuckDBValue>>): void
|
|
63
|
+
endRow(): void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface SetupDuckLakeOptions {
|
|
67
|
+
readonly installExtensions?: boolean
|
|
68
|
+
readonly attach?: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class NodeDuckDbRuntime implements DuckDbRuntime {
|
|
72
|
+
private closing = false
|
|
73
|
+
private closed = false
|
|
74
|
+
// One DuckDBConnection is shared by the runtime. Serialize calls so request
|
|
75
|
+
// handlers cannot interleave statements or close/reset an active query.
|
|
76
|
+
private operations: Promise<void> = Promise.resolve()
|
|
77
|
+
|
|
78
|
+
constructor(
|
|
79
|
+
private readonly instance: DuckDBInstance,
|
|
80
|
+
private readonly connection: DuckDBConnection
|
|
81
|
+
) {}
|
|
82
|
+
|
|
83
|
+
async run(sql: string, values?: readonly DuckDBValue[]): Promise<void> {
|
|
84
|
+
await this.enqueue(async () => {
|
|
85
|
+
await this.connection.run(sql, values === undefined ? undefined : [...values])
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async runStatements(statements: readonly string[]): Promise<void> {
|
|
90
|
+
await this.enqueue(async () => {
|
|
91
|
+
for (const sql of statements) {
|
|
92
|
+
await this.connection.run(sql)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async query(
|
|
98
|
+
sql: string,
|
|
99
|
+
values?: readonly DuckDBValue[]
|
|
100
|
+
): Promise<readonly Record<string, unknown>[]> {
|
|
101
|
+
return this.enqueue(async () => {
|
|
102
|
+
const reader = await this.connection.runAndReadAll(
|
|
103
|
+
sql,
|
|
104
|
+
values === undefined ? undefined : [...values]
|
|
105
|
+
)
|
|
106
|
+
return reader.convertRowObjects(sixbDuckDbValueConverter)
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async *streamRows(
|
|
111
|
+
sql: string,
|
|
112
|
+
values?: readonly DuckDBValue[]
|
|
113
|
+
): AsyncIterable<Record<string, unknown>> {
|
|
114
|
+
this.assertAcceptingOperations()
|
|
115
|
+
|
|
116
|
+
// A stream holds its queue slot until the consumer finishes or abandons the
|
|
117
|
+
// iterator. This keeps later writes from running while rows are still being
|
|
118
|
+
// read from the same DuckDB connection.
|
|
119
|
+
let releaseOperation: (() => void) | undefined
|
|
120
|
+
const operationFinished = new Promise<void>((resolve) => {
|
|
121
|
+
releaseOperation = resolve
|
|
122
|
+
})
|
|
123
|
+
const waitForTurn = this.operations.then(() => {
|
|
124
|
+
this.assertNotClosed()
|
|
125
|
+
})
|
|
126
|
+
this.operations = waitForTurn
|
|
127
|
+
.then(() => operationFinished)
|
|
128
|
+
.then(
|
|
129
|
+
() => {},
|
|
130
|
+
() => {}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await waitForTurn
|
|
135
|
+
const result = await this.connection.stream(
|
|
136
|
+
sql,
|
|
137
|
+
values === undefined ? undefined : [...values]
|
|
138
|
+
)
|
|
139
|
+
for await (const batch of result.yieldConvertedRowObjects(sixbDuckDbValueConverter)) {
|
|
140
|
+
for (const row of batch) {
|
|
141
|
+
yield row as Record<string, unknown>
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
releaseOperation?.()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async withAppender<T>(
|
|
150
|
+
tableName: string,
|
|
151
|
+
useAppender: (appender: DuckDbAppender) => T | Promise<T>
|
|
152
|
+
): Promise<T> {
|
|
153
|
+
return this.enqueue(async () => {
|
|
154
|
+
const appender = await this.connection.createAppender(tableName)
|
|
155
|
+
try {
|
|
156
|
+
return await useAppender(new NodeDuckDbAppenderAdapter(appender))
|
|
157
|
+
} finally {
|
|
158
|
+
appender.closeSync()
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async withExclusive<T>(useRuntime: (runtime: DuckDbExclusiveRuntime) => Promise<T>): Promise<T> {
|
|
164
|
+
return this.enqueue(async () => {
|
|
165
|
+
// The exclusive adapter talks directly to the underlying connection while
|
|
166
|
+
// this queued operation is active. Do not pass `this` into the callback.
|
|
167
|
+
return useRuntime(new NodeDuckDbExclusiveRuntime(this.connection))
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async close(): Promise<void> {
|
|
172
|
+
if (this.closed) {
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.closing = true
|
|
177
|
+
// resetRuntime()/close() can run while an API read is still using this
|
|
178
|
+
// runtime. Wait for accepted work before closing the native connection.
|
|
179
|
+
await this.operations.catch(() => {})
|
|
180
|
+
this.closed = true
|
|
181
|
+
this.connection.closeSync()
|
|
182
|
+
this.instance.closeSync()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
|
|
186
|
+
this.assertAcceptingOperations()
|
|
187
|
+
|
|
188
|
+
// Keep the chain alive after failures so one rejected query does not block
|
|
189
|
+
// all later work on this runtime.
|
|
190
|
+
const result = this.operations.then(async () => {
|
|
191
|
+
this.assertNotClosed()
|
|
192
|
+
return operation()
|
|
193
|
+
})
|
|
194
|
+
this.operations = result.then(
|
|
195
|
+
() => {},
|
|
196
|
+
() => {}
|
|
197
|
+
)
|
|
198
|
+
return result
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private assertAcceptingOperations(): void {
|
|
202
|
+
if (this.closing || this.closed) {
|
|
203
|
+
throw new LakeStorageError("[SixbDuckLake] DuckDB runtime is closed.")
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private assertNotClosed(): void {
|
|
208
|
+
if (this.closed) {
|
|
209
|
+
throw new LakeStorageError("[SixbDuckLake] DuckDB runtime is closed.")
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
class NodeDuckDbExclusiveRuntime implements DuckDbExclusiveRuntime {
|
|
215
|
+
constructor(private readonly connection: DuckDBConnection) {}
|
|
216
|
+
|
|
217
|
+
async run(sql: string, values?: readonly DuckDBValue[]): Promise<void> {
|
|
218
|
+
await this.connection.run(sql, values === undefined ? undefined : [...values])
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async runStatements(statements: readonly string[]): Promise<void> {
|
|
222
|
+
for (const sql of statements) {
|
|
223
|
+
await this.connection.run(sql)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async query(
|
|
228
|
+
sql: string,
|
|
229
|
+
values?: readonly DuckDBValue[]
|
|
230
|
+
): Promise<readonly Record<string, unknown>[]> {
|
|
231
|
+
const reader = await this.connection.runAndReadAll(
|
|
232
|
+
sql,
|
|
233
|
+
values === undefined ? undefined : [...values]
|
|
234
|
+
)
|
|
235
|
+
return reader.convertRowObjects(sixbDuckDbValueConverter)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async withAppender<T>(
|
|
239
|
+
tableName: string,
|
|
240
|
+
useAppender: (appender: DuckDbAppender) => T | Promise<T>
|
|
241
|
+
): Promise<T> {
|
|
242
|
+
const appender = await this.connection.createAppender(tableName)
|
|
243
|
+
try {
|
|
244
|
+
return await useAppender(new NodeDuckDbAppenderAdapter(appender))
|
|
245
|
+
} finally {
|
|
246
|
+
appender.closeSync()
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
class NodeDuckDbAppenderAdapter implements DuckDbAppender {
|
|
252
|
+
constructor(private readonly appender: NodeDuckDBAppender) {}
|
|
253
|
+
|
|
254
|
+
appendNull(): void {
|
|
255
|
+
this.appender.appendNull()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
appendBoolean(value: boolean): void {
|
|
259
|
+
this.appender.appendBoolean(value)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
appendVarchar(value: string): void {
|
|
263
|
+
this.appender.appendVarchar(value)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
appendBigInt(value: bigint): void {
|
|
267
|
+
this.appender.appendBigInt(value)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
appendDouble(value: number): void {
|
|
271
|
+
this.appender.appendDouble(value)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
appendStruct(value: Readonly<Record<string, DuckDBValue>>): void {
|
|
275
|
+
this.appender.appendStruct(value)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
endRow(): void {
|
|
279
|
+
this.appender.endRow()
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Create the DuckDB runtime used by the provider.
|
|
285
|
+
*/
|
|
286
|
+
export async function createDuckDbRuntime(
|
|
287
|
+
options: DuckDbRuntimeOptions = {}
|
|
288
|
+
): Promise<DuckDbRuntime> {
|
|
289
|
+
const instance = await DuckDBInstance.create(options.path ?? ":memory:", options.config ?? {})
|
|
290
|
+
return new NodeDuckDbRuntime(instance, await instance.connect())
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Install DuckLake and any catalog/data-path extensions into DuckDB's local
|
|
295
|
+
* extension directory. Installation is idempotent and can be shared by many
|
|
296
|
+
* short-lived runtimes created by one provider instance.
|
|
297
|
+
*/
|
|
298
|
+
export async function installDuckLakeExtensions(
|
|
299
|
+
runtime: DuckDbRuntime,
|
|
300
|
+
options: DuckLakeStorageOptions
|
|
301
|
+
): Promise<void> {
|
|
302
|
+
await runtime.run("INSTALL ducklake")
|
|
303
|
+
|
|
304
|
+
for (const extension of requiredExtensions(options)) {
|
|
305
|
+
await runtime.run(`INSTALL ${quoteIdentifier(extension)}`)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Load DuckLake, load catalog extensions, run caller setup SQL, and optionally
|
|
311
|
+
* attach the configured DuckLake catalog.
|
|
312
|
+
*/
|
|
313
|
+
export async function setupDuckLake(
|
|
314
|
+
runtime: DuckDbRuntime,
|
|
315
|
+
options: DuckLakeStorageOptions,
|
|
316
|
+
setupOptions: SetupDuckLakeOptions = {}
|
|
317
|
+
): Promise<void> {
|
|
318
|
+
if (setupOptions.installExtensions ?? true) {
|
|
319
|
+
await installDuckLakeExtensions(runtime, options)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
await runtime.run("LOAD ducklake")
|
|
323
|
+
|
|
324
|
+
for (const extension of requiredExtensions(options)) {
|
|
325
|
+
await runtime.run(`LOAD ${quoteIdentifier(extension)}`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for (const sql of options.setupSql ?? []) {
|
|
329
|
+
await runtime.run(sql)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (options.catalog.type === "postgres") {
|
|
333
|
+
await runtime.runStatements(buildSetPostgresPoolSql(options))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const secret of options.secrets ?? []) {
|
|
337
|
+
await runtime.run(buildCreateSecretSql(secret))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (setupOptions.attach ?? true) {
|
|
341
|
+
await runtime.run(buildAttachSql(options))
|
|
342
|
+
|
|
343
|
+
if (options.catalog.type === "postgres") {
|
|
344
|
+
await runtime.run(buildConfigurePostgresMetadataPoolSql(options))
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DuckDBDecimalValue,
|
|
3
|
+
DuckDBTypeId,
|
|
4
|
+
type DuckDBValueConverter,
|
|
5
|
+
type JS,
|
|
6
|
+
JSDuckDBValueConverter,
|
|
7
|
+
} from "@duckdb/node-api"
|
|
8
|
+
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Preserve DuckDB decimals as exact strings while retaining the driver's normal
|
|
12
|
+
* JavaScript conversions for every other scalar and nested value.
|
|
13
|
+
*/
|
|
14
|
+
export const sixbDuckDbValueConverter: DuckDBValueConverter<JS> = (value, type, converter) => {
|
|
15
|
+
if (value === null) {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (type.typeId === DuckDBTypeId.DECIMAL) {
|
|
20
|
+
if (!(value instanceof DuckDBDecimalValue)) {
|
|
21
|
+
throw new LakeStorageError("[SixbDuckLake] DuckDB returned an invalid DECIMAL value.")
|
|
22
|
+
}
|
|
23
|
+
return value.toString()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return JSDuckDBValueConverter(value, type, converter)
|
|
27
|
+
}
|