@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,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
+ }