@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,116 @@
1
+ import type { DatasetColumnDefinition, DatasetSchema } from "@sixb/core"
2
+ import { LakeStorageError } from "@sixb/core/lake-storage"
3
+ import { duckDbTypeToDatasetColumnType } from "./schema"
4
+
5
+ export interface DuckLakeCatalogColumn {
6
+ readonly tableName: string
7
+ readonly columnId: bigint
8
+ readonly columnOrder: bigint
9
+ readonly columnName: string
10
+ readonly columnType: string
11
+ readonly nullsAllowed: boolean
12
+ readonly parentColumnId?: bigint
13
+ }
14
+
15
+ const FILE_REF_STRUCT_CHILDREN = [
16
+ { name: "blobId", type: "string" },
17
+ { name: "digest", type: "string" },
18
+ { name: "sizeBytes", type: "int64" },
19
+ { name: "fileName", type: "string" },
20
+ { name: "mediaType", type: "string" },
21
+ { name: "logicalPath", type: "string" },
22
+ ] satisfies readonly { readonly name: string; readonly type: DatasetColumnDefinition["type"] }[]
23
+
24
+ export function duckLakeCatalogColumnsToDatasetSchema(
25
+ tableName: string,
26
+ rows: readonly DuckLakeCatalogColumn[]
27
+ ): DatasetSchema {
28
+ const childrenByParentId = new Map<bigint, DuckLakeCatalogColumn[]>()
29
+ const topLevelColumns: DuckLakeCatalogColumn[] = []
30
+
31
+ for (const row of rows) {
32
+ if (row.parentColumnId === undefined) {
33
+ topLevelColumns.push(row)
34
+ continue
35
+ }
36
+
37
+ const children = childrenByParentId.get(row.parentColumnId) ?? []
38
+ children.push(row)
39
+ childrenByParentId.set(row.parentColumnId, children)
40
+ }
41
+
42
+ return {
43
+ columns: topLevelColumns
44
+ .sort(compareDuckLakeCatalogColumnOrder)
45
+ .map((column) => duckLakeCatalogColumnToDefinition(tableName, column, childrenByParentId)),
46
+ }
47
+ }
48
+
49
+ function duckLakeCatalogColumnToDefinition(
50
+ tableName: string,
51
+ column: DuckLakeCatalogColumn,
52
+ childrenByParentId: ReadonlyMap<bigint, readonly DuckLakeCatalogColumn[]>
53
+ ): DatasetColumnDefinition {
54
+ const children = [...(childrenByParentId.get(column.columnId) ?? [])].sort(
55
+ compareDuckLakeCatalogColumnOrder
56
+ )
57
+ return {
58
+ name: column.columnName,
59
+ type: duckLakeCatalogColumnTypeToDatasetType(tableName, column, children),
60
+ ...(column.nullsAllowed ? { nullable: true } : {}),
61
+ }
62
+ }
63
+
64
+ function duckLakeCatalogColumnTypeToDatasetType(
65
+ tableName: string,
66
+ column: DuckLakeCatalogColumn,
67
+ children: readonly DuckLakeCatalogColumn[]
68
+ ): DatasetColumnDefinition["type"] {
69
+ if (children.length === 0 && column.columnType.toLowerCase() !== "struct") {
70
+ return duckDbTypeToDatasetColumnType(column.columnType)
71
+ }
72
+
73
+ if (isFileRefStruct(children)) {
74
+ return "fileRef"
75
+ }
76
+
77
+ throw new LakeStorageError(
78
+ `[SixbDuckLake] DuckDB column type '${formatDuckLakeCatalogColumnType(
79
+ column,
80
+ children
81
+ )}' in dataset table '${tableName}' cannot be mapped to a Sixb dataset column type.`
82
+ )
83
+ }
84
+
85
+ function compareDuckLakeCatalogColumnOrder(
86
+ left: DuckLakeCatalogColumn,
87
+ right: DuckLakeCatalogColumn
88
+ ): number {
89
+ return Number(left.columnOrder - right.columnOrder)
90
+ }
91
+
92
+ function isFileRefStruct(children: readonly DuckLakeCatalogColumn[]): boolean {
93
+ if (children.length !== FILE_REF_STRUCT_CHILDREN.length) {
94
+ return false
95
+ }
96
+
97
+ return FILE_REF_STRUCT_CHILDREN.every((expected, index) => {
98
+ const child = children[index]
99
+ return (
100
+ child !== undefined &&
101
+ child.columnName === expected.name &&
102
+ duckDbTypeToDatasetColumnType(child.columnType) === expected.type
103
+ )
104
+ })
105
+ }
106
+
107
+ function formatDuckLakeCatalogColumnType(
108
+ column: DuckLakeCatalogColumn,
109
+ children: readonly DuckLakeCatalogColumn[]
110
+ ): string {
111
+ if (children.length === 0) {
112
+ return column.columnType
113
+ }
114
+
115
+ return `STRUCT(${children.map((child) => `${child.columnName} ${child.columnType}`).join(", ")})`
116
+ }
@@ -0,0 +1,510 @@
1
+ import { LakeStorageError } from "@sixb/core/lake-storage"
2
+ import type { DuckLakeStorageOptions } from "../types"
3
+ import { localCatalogCoordinationKey } from "./catalog-key"
4
+ import {
5
+ createDuckDbRuntime,
6
+ type DuckDbExclusiveRuntime,
7
+ type DuckDbQueryRuntime,
8
+ type DuckDbRuntime,
9
+ installDuckLakeExtensions,
10
+ setupDuckLake,
11
+ } from "./duckdb-runtime"
12
+ import {
13
+ buildAttachSql,
14
+ buildConfigurePostgresMetadataPoolSql,
15
+ duckLakeAlias,
16
+ quoteIdentifier,
17
+ } from "./sql"
18
+
19
+ export interface DuckLakeAttachedRuntimeLease {
20
+ readonly runtime: DuckDbRuntime
21
+ release(): Promise<void>
22
+ }
23
+
24
+ /**
25
+ * Owns DuckDB/DuckLake runtime lifecycle for DuckLakeStorage.
26
+ *
27
+ * A storage instance owns one DuckDB runtime and at most one DuckLake
28
+ * attachment. The runtime starts unattached so construction and non-lake setup
29
+ * do not immediately open DuckLake metadata connections; reads and commits
30
+ * attach lazily through `attachedRuntime()`.
31
+ */
32
+ export class DuckLakeConnectionManager {
33
+ private runtimePromise: Promise<DuckDbRuntime> | undefined
34
+ private installExtensionsPromise: Promise<void> | undefined
35
+ private attachPromise: Promise<void> | undefined
36
+ private refreshPromise: Promise<void> | undefined
37
+ // Local catalogs need DETACH/ATTACH to refresh metadata visibility. Keep
38
+ // those attachment changes outside whole read operations so a read cannot
39
+ // observe the DuckLake alias disappearing between its metadata queries.
40
+ private localAttachmentLock: Promise<void> = Promise.resolve()
41
+ private observedLocalCatalogGeneration: number
42
+ private attached = false
43
+ private closed = false
44
+ private runtimePoisoned = false
45
+
46
+ constructor(private readonly options: DuckLakeStorageOptions) {
47
+ this.observedLocalCatalogGeneration = currentLocalCatalogGeneration(options)
48
+ }
49
+
50
+ assertOpen(): void {
51
+ if (this.closed) {
52
+ throw new LakeStorageError("[SixbDuckLake] DuckLakeStorage is closed.")
53
+ }
54
+ }
55
+
56
+ async runtime(): Promise<DuckDbRuntime> {
57
+ this.assertOpen()
58
+
59
+ if (this.runtimePromise === undefined) {
60
+ const runtimePromise = this.createRuntime().catch((error) => {
61
+ if (this.runtimePromise === runtimePromise) {
62
+ this.runtimePromise = undefined
63
+ }
64
+ throw error
65
+ })
66
+ this.runtimePromise = runtimePromise
67
+ }
68
+
69
+ return this.runtimePromise
70
+ }
71
+
72
+ async attachedRuntime(): Promise<DuckDbRuntime> {
73
+ const runtime = await this.runtime()
74
+ await this.ensureAttached(runtime)
75
+ return runtime
76
+ }
77
+
78
+ async withAttachedRuntime<T>(run: (runtime: DuckDbRuntime) => Promise<T>): Promise<T> {
79
+ const lease = await this.acquireAttachedRuntime()
80
+ try {
81
+ return await run(lease.runtime)
82
+ } finally {
83
+ await lease.release()
84
+ }
85
+ }
86
+
87
+ async acquireAttachedRuntime(): Promise<DuckLakeAttachedRuntimeLease> {
88
+ this.assertOpen()
89
+
90
+ // The lease is intentionally wider than one DuckDB queue operation. It
91
+ // covers the full Sixb read/commit boundary for local catalogs while
92
+ // remaining a no-op for PostgreSQL catalogs.
93
+ const releaseLock = await this.acquireLocalAttachmentLock()
94
+ let released = false
95
+ const release = async () => {
96
+ if (released) {
97
+ return
98
+ }
99
+
100
+ released = true
101
+ try {
102
+ if (this.runtimePoisoned) {
103
+ this.runtimePoisoned = false
104
+ await this.discardRuntimeUnlocked()
105
+ } else {
106
+ await this.detachDuckDbCatalogAfterLease()
107
+ }
108
+ } finally {
109
+ releaseLock()
110
+ }
111
+ }
112
+
113
+ try {
114
+ await this.refreshForExternalChangesUnlocked()
115
+ const runtime = await this.attachedRuntime()
116
+ return { runtime, release }
117
+ } catch (error) {
118
+ await release()
119
+ throw error
120
+ }
121
+ }
122
+
123
+ async withExclusiveAttached<T>(run: (runtime: DuckDbExclusiveRuntime) => Promise<T>): Promise<T> {
124
+ const lease = await this.acquireAttachedRuntime()
125
+ try {
126
+ return await lease.runtime.withExclusive(run)
127
+ } finally {
128
+ await lease.release()
129
+ }
130
+ }
131
+
132
+ async stagingRuntime(): Promise<DuckDbRuntime> {
133
+ const runtime = await this.runtime()
134
+
135
+ if (this.options.catalog.type !== "postgres") {
136
+ // Local catalogs can keep stale attached metadata when another provider
137
+ // commits while this write session is open. Stage rows unattached so the
138
+ // later exclusive commit attaches fresh without losing temp tables.
139
+ await this.detachRuntime()
140
+ }
141
+
142
+ return runtime
143
+ }
144
+
145
+ /**
146
+ * Create the one runtime owned by this manager. Setup loads extensions,
147
+ * secrets, setup SQL, and pre-attach Postgres pool settings, but deliberately
148
+ * leaves DuckLake unattached until `attachedRuntime()` is needed.
149
+ */
150
+ async createRuntime(): Promise<DuckDbRuntime> {
151
+ this.assertOpen()
152
+
153
+ const runtime = await createDuckDbRuntime(this.options.duckdb)
154
+ try {
155
+ await this.ensureExtensionsInstalled(runtime)
156
+ await setupDuckLake(runtime, this.options, {
157
+ attach: false,
158
+ installExtensions: false,
159
+ })
160
+ // Projection checkpoints use physical offsets. Reapply this after caller setup SQL so it
161
+ // cannot weaken immutable snapshot ordering, including after runtime recreation.
162
+ await runtime.run("SET preserve_insertion_order = true")
163
+ return runtime
164
+ } catch (error) {
165
+ await this.closeRuntime(runtime, { detach: false })
166
+ throw error
167
+ }
168
+ }
169
+
170
+ async resetRuntime(): Promise<void> {
171
+ const releaseLock = await this.acquireLocalAttachmentLock()
172
+ try {
173
+ await this.resetRuntimeUnlocked()
174
+ } finally {
175
+ releaseLock()
176
+ }
177
+ }
178
+
179
+ private async resetRuntimeUnlocked(): Promise<void> {
180
+ const runtimePromise = this.runtimePromise
181
+ if (runtimePromise === undefined) {
182
+ return
183
+ }
184
+
185
+ if (this.attachPromise !== undefined) {
186
+ await this.attachPromise
187
+ }
188
+
189
+ if (!this.attached) {
190
+ return
191
+ }
192
+
193
+ let refreshedPromise: Promise<DuckDbRuntime>
194
+ refreshedPromise = runtimePromise
195
+ .then(async (runtime) => {
196
+ try {
197
+ await this.refreshAttachedRuntime(runtime)
198
+ this.attached = true
199
+ return runtime
200
+ } catch (error) {
201
+ this.attached = false
202
+ await this.closeRuntime(runtime, { detach: false })
203
+ throw error
204
+ }
205
+ })
206
+ .catch((error) => {
207
+ if (this.runtimePromise === refreshedPromise) {
208
+ this.runtimePromise = undefined
209
+ }
210
+ throw error
211
+ })
212
+
213
+ this.attached = false
214
+ this.runtimePromise = refreshedPromise
215
+ await refreshedPromise
216
+ }
217
+
218
+ async refreshForExternalChanges(): Promise<void> {
219
+ if (this.options.catalog.type === "postgres") {
220
+ return
221
+ }
222
+
223
+ if (!this.needsLocalCatalogRefresh()) {
224
+ return
225
+ }
226
+
227
+ if (this.refreshPromise === undefined) {
228
+ let refreshPromise: Promise<void>
229
+ refreshPromise = this.withLocalAttachmentLock(() =>
230
+ this.refreshForExternalChangesUnlocked()
231
+ ).finally(() => {
232
+ if (this.refreshPromise === refreshPromise) {
233
+ this.refreshPromise = undefined
234
+ }
235
+ })
236
+ this.refreshPromise = refreshPromise
237
+ }
238
+
239
+ await this.refreshPromise
240
+ }
241
+
242
+ markLocalCatalogChanged(): void {
243
+ if (this.options.catalog.type === "postgres") {
244
+ return
245
+ }
246
+
247
+ this.observedLocalCatalogGeneration = incrementLocalCatalogGeneration(this.options)
248
+ }
249
+
250
+ async detachLocalCatalogAfterCommit(runtime: DuckDbQueryRuntime): Promise<void> {
251
+ if (this.options.catalog.type === "postgres") {
252
+ return
253
+ }
254
+
255
+ try {
256
+ await runtime.run(`DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`)
257
+ this.attached = false
258
+ } catch {
259
+ // The commit already succeeded. Keep the original write result and let a
260
+ // later refresh/close retry detaching this local catalog if needed.
261
+ } finally {
262
+ this.markLocalCatalogChanged()
263
+ }
264
+ }
265
+
266
+ private async refreshForExternalChangesUnlocked(): Promise<void> {
267
+ const generation = currentLocalCatalogGeneration(this.options)
268
+ if (this.observedLocalCatalogGeneration === generation) {
269
+ return
270
+ }
271
+
272
+ await this.resetRuntimeUnlocked()
273
+
274
+ this.observedLocalCatalogGeneration = generation
275
+ }
276
+
277
+ private needsLocalCatalogRefresh(): boolean {
278
+ return this.observedLocalCatalogGeneration !== currentLocalCatalogGeneration(this.options)
279
+ }
280
+
281
+ async close(): Promise<void> {
282
+ if (this.closed) {
283
+ return
284
+ }
285
+
286
+ this.closed = true
287
+
288
+ const runtimePromise = this.runtimePromise
289
+ this.runtimePromise = undefined
290
+ await this.closeRuntimePromise(runtimePromise, {
291
+ // For Postgres catalogs, the DuckDB Postgres extension owns the attached
292
+ // pool. Explicit DETACH can briefly create or retain another backend;
293
+ // closing the DuckDB runtime is the tighter shutdown path.
294
+ detach: this.attached && this.options.catalog.type !== "postgres",
295
+ })
296
+ this.attached = false
297
+ }
298
+
299
+ private async ensureAttached(runtime: DuckDbRuntime): Promise<void> {
300
+ this.assertOpen()
301
+
302
+ if (this.attached) {
303
+ return
304
+ }
305
+
306
+ if (this.attachPromise === undefined) {
307
+ let attachPromise: Promise<void>
308
+ attachPromise = this.attachRuntime(runtime)
309
+ .then(() => {
310
+ this.attached = true
311
+ })
312
+ .catch(async (error) => {
313
+ if (this.attachPromise === attachPromise) {
314
+ this.runtimePromise = undefined
315
+ this.attached = false
316
+ }
317
+ await this.closeRuntime(runtime, { detach: false })
318
+ throw error
319
+ })
320
+ .finally(() => {
321
+ if (this.attachPromise === attachPromise) {
322
+ this.attachPromise = undefined
323
+ }
324
+ })
325
+ this.attachPromise = attachPromise
326
+ }
327
+
328
+ await this.attachPromise
329
+ }
330
+
331
+ private async ensureExtensionsInstalled(runtime: DuckDbRuntime): Promise<void> {
332
+ if (this.installExtensionsPromise === undefined) {
333
+ const installExtensionsPromise = installDuckLakeExtensions(runtime, this.options).catch(
334
+ (error) => {
335
+ if (this.installExtensionsPromise === installExtensionsPromise) {
336
+ this.installExtensionsPromise = undefined
337
+ }
338
+ throw error
339
+ }
340
+ )
341
+ this.installExtensionsPromise = installExtensionsPromise
342
+ }
343
+
344
+ await this.installExtensionsPromise
345
+ }
346
+
347
+ private async attachRuntime(runtime: DuckDbRuntime): Promise<void> {
348
+ await runtime.runStatements([
349
+ buildAttachSql(this.options),
350
+ ...(this.options.catalog.type === "postgres"
351
+ ? [buildConfigurePostgresMetadataPoolSql(this.options)]
352
+ : []),
353
+ ])
354
+ }
355
+
356
+ private async refreshAttachedRuntime(runtime: DuckDbRuntime): Promise<void> {
357
+ await runtime.runStatements([
358
+ `DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`,
359
+ buildAttachSql(this.options),
360
+ ...(this.options.catalog.type === "postgres"
361
+ ? [buildConfigurePostgresMetadataPoolSql(this.options)]
362
+ : []),
363
+ ])
364
+ }
365
+
366
+ private async detachRuntime(): Promise<void> {
367
+ await this.withLocalAttachmentLock(() => this.detachRuntimeUnlocked())
368
+ }
369
+
370
+ /**
371
+ * Mark the active runtime as unusable for reuse. The next lease release
372
+ * discards the connection so session state that could not be cleaned up
373
+ * (such as a SET pragma whose RESET failed) cannot reach a later operation.
374
+ */
375
+ poisonRuntime(): void {
376
+ this.runtimePoisoned = true
377
+ }
378
+
379
+ private async detachDuckDbCatalogAfterLease(): Promise<void> {
380
+ if (this.options.catalog.type !== "duckdb" || !this.attached) {
381
+ return
382
+ }
383
+
384
+ try {
385
+ await this.detachRuntimeUnlocked()
386
+ } catch {
387
+ // DuckDB-backed DuckLake catalogs are single-client local catalogs. If
388
+ // cleanup cannot detach, discard this runtime so the next operation
389
+ // starts from an unattached connection instead of keeping stale metadata.
390
+ await this.discardRuntimeUnlocked()
391
+ }
392
+ }
393
+
394
+ // Close and forget the current runtime so the next `runtime()` builds a fresh
395
+ // connection with default session state. Unlike `resetRuntimeUnlocked()`,
396
+ // which DETACH/ATTACHes the same connection, this drops the connection
397
+ // entirely -- the only way to clear a leaked session pragma.
398
+ private async discardRuntimeUnlocked(): Promise<void> {
399
+ const runtimePromise = this.runtimePromise
400
+ this.runtimePromise = undefined
401
+ this.attached = false
402
+ await this.closeRuntimePromise(runtimePromise, { detach: false }).catch(() => {})
403
+ }
404
+
405
+ private async detachRuntimeUnlocked(): Promise<void> {
406
+ if (this.attachPromise !== undefined) {
407
+ await this.attachPromise
408
+ }
409
+
410
+ if (!this.attached) {
411
+ return
412
+ }
413
+
414
+ const runtime = await this.runtime()
415
+ await runtime.run(`DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`)
416
+ this.attached = false
417
+ }
418
+
419
+ private async withLocalAttachmentLock<T>(run: () => Promise<T>): Promise<T> {
420
+ const releaseLock = await this.acquireLocalAttachmentLock()
421
+ try {
422
+ return await run()
423
+ } finally {
424
+ releaseLock()
425
+ }
426
+ }
427
+
428
+ private async acquireLocalAttachmentLock(): Promise<() => void> {
429
+ if (this.options.catalog.type === "postgres") {
430
+ return () => {}
431
+ }
432
+
433
+ const previous = this.localAttachmentLock.catch(() => {})
434
+ let release!: () => void
435
+ const current = new Promise<void>((resolve) => {
436
+ release = resolve
437
+ })
438
+ this.localAttachmentLock = previous.then(() => current)
439
+
440
+ await previous
441
+
442
+ let released = false
443
+ return () => {
444
+ if (released) {
445
+ return
446
+ }
447
+
448
+ released = true
449
+ release()
450
+ }
451
+ }
452
+
453
+ private async closeRuntimePromise(
454
+ runtimePromise: Promise<DuckDbRuntime> | undefined,
455
+ options: { readonly detach: boolean }
456
+ ): Promise<void> {
457
+ if (runtimePromise === undefined) {
458
+ return
459
+ }
460
+
461
+ let runtime: DuckDbRuntime
462
+ try {
463
+ runtime = await runtimePromise
464
+ } catch {
465
+ // createRuntime/attachRuntime already close partially-initialized
466
+ // runtimes before rejecting. Swallowing here keeps close/reset
467
+ // idempotent while preserving the original operation error.
468
+ return
469
+ }
470
+
471
+ await this.closeRuntime(runtime, options)
472
+ }
473
+
474
+ private async closeRuntime(
475
+ runtime: DuckDbRuntime,
476
+ options: { readonly detach: boolean }
477
+ ): Promise<void> {
478
+ if (options.detach) {
479
+ try {
480
+ await runtime.run(`DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`)
481
+ } catch {
482
+ // The runtime may already be detached, partially initialized, or in an
483
+ // error state. Closing the native connection is still required.
484
+ }
485
+ }
486
+
487
+ await runtime.close()
488
+ }
489
+ }
490
+
491
+ // Local catalog tests commonly create multiple DuckLakeStorage instances in one
492
+ // process. DuckLake/Postgres handles cross-connection visibility itself; local
493
+ // file catalogs need this in-process signal so peers refresh after Sixb writes.
494
+ const localCatalogGenerations = new Map<string, number>()
495
+
496
+ function currentLocalCatalogGeneration(options: DuckLakeStorageOptions): number {
497
+ const key = localCatalogCoordinationKey(options)
498
+ return key === undefined ? 0 : (localCatalogGenerations.get(key) ?? 0)
499
+ }
500
+
501
+ function incrementLocalCatalogGeneration(options: DuckLakeStorageOptions): number {
502
+ const key = localCatalogCoordinationKey(options)
503
+ if (key === undefined) {
504
+ return 0
505
+ }
506
+
507
+ const nextGeneration = (localCatalogGenerations.get(key) ?? 0) + 1
508
+ localCatalogGenerations.set(key, nextGeneration)
509
+ return nextGeneration
510
+ }