@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,532 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import type { DatasetDefinition } from "@sixb/core"
3
+ import type {
4
+ BeginDatasetWriteInput,
5
+ DatasetVersion,
6
+ DatasetWriteCommitResult,
7
+ DatasetWriteMode,
8
+ LakeWriteSession,
9
+ } from "@sixb/core/lake-storage"
10
+ import { LakeStorageError } from "@sixb/core/lake-storage"
11
+ import type { DuckLakeStorageOptions } from "../types"
12
+ import { localCatalogCoordinationKey } from "./catalog-key"
13
+ import {
14
+ type ApplyDatasetRowsResult,
15
+ applyDatasetRowsFromRelation,
16
+ assertDatasetWriteMode,
17
+ type CommitRowCount,
18
+ } from "./dataset-row-commit"
19
+ import { getBigIntLike } from "./duckdb-row"
20
+ import type { DuckDbQueryRuntime } from "./duckdb-runtime"
21
+ import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
22
+ import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
23
+ import type { DuckLakeSnapshotReader, DuckLakeVersionSummary } from "./ducklake-snapshot-reader"
24
+ import { createDuckLakeWriteSession, type DuckLakeCommitWriteInput } from "./ducklake-write-session"
25
+ import { duckLakeAlias, duckLakeMetadataTableName, quoteIdentifier, quoteSqlString } from "./sql"
26
+ import { parseCommitMetadata, type SixbCommitMetadata } from "./versions"
27
+
28
+ export interface DuckLakeCommitDatasetVersionInput {
29
+ readonly dataset: DatasetDefinition
30
+ readonly mode: DatasetWriteMode
31
+ readonly expectedLatestVersionId?: string
32
+ readonly commitMessage: string
33
+ readonly producer?: BeginDatasetWriteInput["producer"]
34
+ readonly inputs?: BeginDatasetWriteInput["inputs"]
35
+ applyChanges(
36
+ runtime: DuckDbQueryRuntime,
37
+ context: DuckLakeApplyChangesContext
38
+ ): Promise<ApplyDatasetRowsResult>
39
+ }
40
+
41
+ interface DuckLakeCommitDatasetVersionRuntimeInput extends DuckLakeCommitDatasetVersionInput {
42
+ readonly runtime: DuckDbQueryRuntime
43
+ }
44
+
45
+ export interface DuckLakeApplyChangesContext {
46
+ readonly previousRowCount?: number
47
+ }
48
+
49
+ /**
50
+ * Owns the durable DuckLake transaction for a staged Sixb write.
51
+ *
52
+ * Write sessions keep validation and temp staging table mechanics close to the
53
+ * caller. This coordinator handles everything that changes shared DuckLake
54
+ * state: attachment refresh, optimistic guards, row replacement/append,
55
+ * commit_extra_info, no-op detection, and DatasetVersion hydration.
56
+ */
57
+ export class DuckLakeWriteCoordinator {
58
+ constructor(
59
+ private readonly options: DuckLakeStorageOptions,
60
+ private readonly connections: DuckLakeConnectionManager,
61
+ private readonly datasets: DuckLakeDatasetCatalog,
62
+ private readonly snapshots: DuckLakeSnapshotReader
63
+ ) {}
64
+
65
+ async beginWrite(input: BeginDatasetWriteInput): Promise<LakeWriteSession> {
66
+ this.connections.assertOpen()
67
+
68
+ const definition = await this.datasets.getDataset(input.dataset.id)
69
+ if (!definition) {
70
+ throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.dataset.id}'.`)
71
+ }
72
+
73
+ this.datasets.assertSchema(definition)
74
+ const mode = input.mode ?? "snapshot"
75
+ assertDatasetWriteMode(mode, "write")
76
+
77
+ // Staging tables are connection-scoped in DuckDB. In the single-runtime
78
+ // model every active write session creates a uniquely named temp table on
79
+ // the shared runtime; the durable commit later runs on that same
80
+ // connection under an exclusive queue slot.
81
+ const runtime = await this.connections.stagingRuntime()
82
+ return createDuckLakeWriteSession({
83
+ commitWrite: (options) => this.commitWrite(options),
84
+ runtime,
85
+ write: {
86
+ ...input,
87
+ dataset: definition,
88
+ mode,
89
+ },
90
+ })
91
+ }
92
+
93
+ private async commitWrite(input: DuckLakeCommitWriteInput): Promise<DatasetWriteCommitResult> {
94
+ const definition = await this.datasets.getDataset(input.write.dataset.id)
95
+ if (!definition) {
96
+ throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.write.dataset.id}'.`)
97
+ }
98
+
99
+ const mode = input.write.mode ?? "snapshot"
100
+ assertDatasetWriteMode(mode, "write")
101
+
102
+ return this.commitDatasetVersion({
103
+ dataset: definition,
104
+ mode,
105
+ expectedLatestVersionId: input.commit?.expectedLatestVersionId,
106
+ commitMessage: input.commit?.commitMessage ?? `write dataset ${input.write.dataset.id}`,
107
+ producer: input.write.producer,
108
+ inputs: input.write.inputs,
109
+ applyChanges: (runtime, context) =>
110
+ this.applyStagedRows({
111
+ ...input,
112
+ runtime,
113
+ previousRowCount: context.previousRowCount,
114
+ }),
115
+ })
116
+ }
117
+
118
+ async commitDatasetVersion(
119
+ input: DuckLakeCommitDatasetVersionInput
120
+ ): Promise<DatasetWriteCommitResult> {
121
+ return this.withCommitRuntime((runtime) =>
122
+ this.commitDatasetVersionOnExclusiveRuntime(runtime, input)
123
+ )
124
+ }
125
+
126
+ async withCommitRuntime<T>(run: (runtime: DuckDbQueryRuntime) => Promise<T>): Promise<T> {
127
+ return withCatalogCommitLock(this.options, async () => {
128
+ // `withExclusiveAttached` refreshes local catalogs before the exclusive
129
+ // slot, then holds the local attachment boundary through the commit.
130
+ return this.connections.withExclusiveAttached(run)
131
+ })
132
+ }
133
+
134
+ async commitDatasetVersionOnExclusiveRuntime(
135
+ runtime: DuckDbQueryRuntime,
136
+ input: DuckLakeCommitDatasetVersionInput
137
+ ): Promise<DatasetWriteCommitResult> {
138
+ const runtimeInput = { ...input, runtime }
139
+ return this.withGuardedRetrySetting(runtimeInput, () =>
140
+ this.commitDatasetVersionUnlocked(runtimeInput)
141
+ )
142
+ }
143
+
144
+ private async withGuardedRetrySetting<T>(
145
+ input: DuckLakeCommitDatasetVersionRuntimeInput,
146
+ run: () => Promise<T>
147
+ ): Promise<T> {
148
+ if (input.expectedLatestVersionId === undefined) {
149
+ return run()
150
+ }
151
+
152
+ // DuckLake can retry transactions internally. Guarded Sixb commits need a
153
+ // strict compare-and-swap check, so disable retries only for this exclusive
154
+ // commit and always restore the runtime setting before releasing the queue.
155
+ await input.runtime.run("SET ducklake_max_retry_count = 0")
156
+ let outcome:
157
+ | { readonly kind: "success"; readonly value: T }
158
+ | {
159
+ readonly kind: "error"
160
+ readonly error: unknown
161
+ }
162
+
163
+ try {
164
+ outcome = { kind: "success", value: await run() }
165
+ } catch (error) {
166
+ outcome = { kind: "error", error }
167
+ }
168
+
169
+ try {
170
+ await input.runtime.run("RESET ducklake_max_retry_count")
171
+ } catch {
172
+ // The retry count is session state on a shared, long-lived connection.
173
+ // If it cannot be restored, recycle the connection so a leftover value of
174
+ // 0 cannot silently disable DuckLake auto-retry for a later unguarded
175
+ // commit. The commit already succeeded or failed durably, so this cleanup
176
+ // must never change the outcome reported to the caller.
177
+ this.connections.poisonRuntime()
178
+ }
179
+
180
+ if (outcome.kind === "error") {
181
+ throw outcome.error
182
+ }
183
+
184
+ return outcome.value
185
+ }
186
+
187
+ private async commitDatasetVersionUnlocked(
188
+ input: DuckLakeCommitDatasetVersionRuntimeInput
189
+ ): Promise<DatasetWriteCommitResult> {
190
+ // Capture the write connection's last committed DuckLake snapshot before
191
+ // and after COMMIT. The commitId in DuckLake commit_extra_info proves
192
+ // which snapshot belongs to this transaction.
193
+ const previousWriteSnapshotId = await this.lastCommittedSnapshotId(input.runtime)
194
+
195
+ await input.runtime.run("BEGIN TRANSACTION")
196
+ let committed = false
197
+ try {
198
+ // Guarded Sixb commits need compare-and-swap semantics. DuckLake can
199
+ // automatically retry non-conflicting commits, so guarded transactions
200
+ // disable retries and re-check the dataset head after BEGIN.
201
+ const latestVersion = await this.latestVersionForCommit(input)
202
+ if (input.expectedLatestVersionId !== undefined) {
203
+ this.assertExpectedLatestVersion({
204
+ datasetId: input.dataset.id,
205
+ expectedLatestVersionId: input.expectedLatestVersionId,
206
+ actualLatestVersionId: latestVersion?.versionId ?? null,
207
+ })
208
+ }
209
+
210
+ const changeResult = await input.applyChanges(input.runtime, {
211
+ previousRowCount: latestVersion?.rowCount,
212
+ })
213
+ const commitId = randomUUID()
214
+ await this.setCommitMetadata(
215
+ input,
216
+ commitId,
217
+ commitRowCount(input, changeResult, latestVersion)
218
+ )
219
+
220
+ await input.runtime.run("COMMIT")
221
+ committed = true
222
+
223
+ const committedWriteSnapshotId = await this.lastCommittedSnapshotId(input.runtime)
224
+ try {
225
+ return await this.versionForCommittedWrite({
226
+ ...input,
227
+ commitId,
228
+ previousWriteSnapshotId,
229
+ committedWriteSnapshotId,
230
+ changeResult,
231
+ })
232
+ } finally {
233
+ await this.connections.detachLocalCatalogAfterCommit(input.runtime)
234
+ }
235
+ } catch (error) {
236
+ if (!committed) {
237
+ await this.rollbackTransaction(input.runtime)
238
+ }
239
+ throw error
240
+ }
241
+ }
242
+
243
+ private async versionForCommittedWrite(
244
+ input: DuckLakeCommitDatasetVersionRuntimeInput & {
245
+ readonly commitId: string
246
+ readonly previousWriteSnapshotId: string | null
247
+ readonly committedWriteSnapshotId: string | null
248
+ readonly changeResult: ApplyDatasetRowsResult
249
+ }
250
+ ): Promise<DatasetWriteCommitResult> {
251
+ // Hydrate the committed version on the same exclusive runtime. That keeps
252
+ // snapshot matching and row-count fallback inside the same serialized
253
+ // connection state that just committed.
254
+ const ownSnapshotId = await this.findOwnSnapshotId(
255
+ input.runtime,
256
+ input.dataset.id,
257
+ input.commitId,
258
+ input.previousWriteSnapshotId,
259
+ input.committedWriteSnapshotId
260
+ )
261
+
262
+ if (ownSnapshotId !== null) {
263
+ const version = await this.snapshots.getVersionForSnapshot(
264
+ input.runtime,
265
+ input.dataset,
266
+ ownSnapshotId
267
+ )
268
+ if (version) {
269
+ return { ...version, outcome: "created" }
270
+ }
271
+
272
+ throw new LakeStorageError(
273
+ `[SixbDuckLake] DuckLake committed snapshot '${ownSnapshotId}' for dataset '${input.dataset.id}', but Sixb could not hydrate it as a dataset version.`
274
+ )
275
+ }
276
+
277
+ if (!input.changeResult.dataChangeExpected) {
278
+ return {
279
+ ...(await this.versionForNoOpCommit(input.runtime, input.dataset)),
280
+ outcome: "unchanged",
281
+ }
282
+ }
283
+
284
+ if (input.committedWriteSnapshotId === input.previousWriteSnapshotId) {
285
+ throw new LakeStorageError(
286
+ `[SixbDuckLake] DuckLake commit for dataset '${input.dataset.id}' completed without producing a snapshot for this non-empty write.`
287
+ )
288
+ }
289
+
290
+ throw new LakeStorageError(
291
+ `[SixbDuckLake] DuckLake commit for dataset '${input.dataset.id}' completed, but Sixb could not find a matching snapshot for this write.`
292
+ )
293
+ }
294
+
295
+ private async applyStagedRows(
296
+ input: DuckLakeCommitWriteInput & {
297
+ readonly runtime: DuckDbQueryRuntime
298
+ readonly previousRowCount?: number
299
+ }
300
+ ): Promise<ApplyDatasetRowsResult> {
301
+ const mode = input.write.mode ?? "snapshot"
302
+ const result = await applyDatasetRowsFromRelation({
303
+ options: this.options,
304
+ runtime: input.runtime,
305
+ dataset: input.write.dataset,
306
+ mode,
307
+ sourceRelationSql: quoteIdentifier(input.stagingTableName),
308
+ previousRowCount: input.previousRowCount,
309
+ })
310
+
311
+ if (result.sourceRowCount !== input.rowsWritten) {
312
+ throw new LakeStorageError(
313
+ `[SixbDuckLake] Staged write for dataset '${input.write.dataset.id}' accepted ${input.rowsWritten} row(s), but DuckLake saw ${result.sourceRowCount} source row(s) at commit time.`
314
+ )
315
+ }
316
+
317
+ return result
318
+ }
319
+
320
+ private async setCommitMetadata(
321
+ input: DuckLakeCommitDatasetVersionRuntimeInput,
322
+ commitId: string,
323
+ rowCount: CommitRowCount
324
+ ): Promise<void> {
325
+ // DuckLake stores this JSON on the snapshot it creates for the current
326
+ // transaction. Version id, timestamp, and data-change detection still come
327
+ // from DuckLake itself. commitId is only a transaction correlation token so
328
+ // concurrent writers do not accidentally hydrate each other's snapshots.
329
+ const metadata: SixbCommitMetadata = {
330
+ kind: "datasetVersion",
331
+ datasetId: input.dataset.id,
332
+ commitId,
333
+ mode: input.mode,
334
+ producer: input.producer ? structuredClone(input.producer) : undefined,
335
+ inputs: input.inputs ? structuredClone(input.inputs) : undefined,
336
+ ...(rowCount.kind === "exact" ? { rowCount: rowCount.value } : {}),
337
+ }
338
+
339
+ await input.runtime.run(
340
+ `CALL ${quoteIdentifier(duckLakeAlias(this.options))}.set_commit_message(${quoteSqlString(
341
+ "Sixb"
342
+ )}, ${quoteSqlString(input.commitMessage)}, extra_info => ${quoteSqlString(
343
+ JSON.stringify({ sixb: metadata })
344
+ )})`
345
+ )
346
+ }
347
+
348
+ private async versionForNoOpCommit(
349
+ runtime: DuckDbQueryRuntime,
350
+ definition: DatasetDefinition
351
+ ): Promise<DatasetVersion> {
352
+ const latestVersion = await this.snapshots.getLatestVersionForDefinition(runtime, definition)
353
+ if (latestVersion) {
354
+ return latestVersion
355
+ }
356
+
357
+ throw new LakeStorageError(
358
+ `[SixbDuckLake] No DuckLake changes were committed for dataset '${definition.id}', and no previous version exists.`
359
+ )
360
+ }
361
+
362
+ private async rollbackTransaction(runtime: DuckDbQueryRuntime): Promise<void> {
363
+ try {
364
+ await runtime.run("ROLLBACK")
365
+ } catch {
366
+ // Preserve the original write failure. DuckDB may already have closed
367
+ // the transaction if COMMIT itself failed.
368
+ }
369
+ }
370
+
371
+ private async latestVersionForCommit(
372
+ input: DuckLakeCommitDatasetVersionRuntimeInput
373
+ ): Promise<DuckLakeVersionSummary | null> {
374
+ if (input.expectedLatestVersionId === undefined && input.mode !== "append") {
375
+ return null
376
+ }
377
+
378
+ return this.snapshots.getLatestVersionSummaryForDefinition(input.runtime, input.dataset)
379
+ }
380
+
381
+ private async lastCommittedSnapshotId(runtime: DuckDbQueryRuntime): Promise<string | null> {
382
+ // DuckLake reports the last snapshot committed by this connection.
383
+ const [row] = await runtime.query(
384
+ `SELECT id FROM ${quoteIdentifier(duckLakeAlias(this.options))}.last_committed_snapshot()`
385
+ )
386
+ if (row === undefined || row.id === null || row.id === undefined) {
387
+ return null
388
+ }
389
+
390
+ return String(getBigIntLike(row, "id"))
391
+ }
392
+
393
+ private async findSnapshotIdByCommitId(
394
+ runtime: DuckDbQueryRuntime,
395
+ datasetId: string,
396
+ commitId: string,
397
+ snapshotId?: string
398
+ ): Promise<string | null> {
399
+ if (snapshotId !== undefined) {
400
+ assertDuckLakeSnapshotId(snapshotId)
401
+ }
402
+
403
+ const ducklakeSnapshotChanges = duckLakeMetadataTableName(
404
+ this.options,
405
+ "ducklake_snapshot_changes"
406
+ )
407
+ const whereSql = snapshotId === undefined ? "" : `WHERE snapshot_id = ${snapshotId}`
408
+ const orderSql = snapshotId === undefined ? "ORDER BY snapshot_id DESC" : ""
409
+ const rows = await runtime.query(`
410
+ SELECT snapshot_id, commit_extra_info
411
+ FROM ${ducklakeSnapshotChanges}
412
+ ${whereSql}
413
+ ${orderSql}
414
+ `)
415
+
416
+ for (const row of rows) {
417
+ const metadata = parseCommitMetadata(row.commit_extra_info)
418
+ if (metadata?.datasetId === datasetId && metadata.commitId === commitId) {
419
+ return String(getBigIntLike(row, "snapshot_id"))
420
+ }
421
+ }
422
+
423
+ return null
424
+ }
425
+
426
+ private async findOwnSnapshotId(
427
+ runtime: DuckDbQueryRuntime,
428
+ datasetId: string,
429
+ commitId: string,
430
+ previousWriteSnapshotId: string | null,
431
+ committedWriteSnapshotId: string | null
432
+ ): Promise<string | null> {
433
+ if (committedWriteSnapshotId === null || committedWriteSnapshotId === previousWriteSnapshotId) {
434
+ return null
435
+ }
436
+
437
+ const exactMatch = await this.findSnapshotIdByCommitId(
438
+ runtime,
439
+ datasetId,
440
+ commitId,
441
+ committedWriteSnapshotId
442
+ )
443
+ if (exactMatch !== null) {
444
+ return exactMatch
445
+ }
446
+
447
+ // Defensive fallback for driver/version edge cases. The expected hot path
448
+ // above reads a single metadata row keyed by last_committed_snapshot().
449
+ return this.findSnapshotIdByCommitId(runtime, datasetId, commitId)
450
+ }
451
+
452
+ private assertExpectedLatestVersion(input: {
453
+ readonly datasetId: string
454
+ readonly expectedLatestVersionId?: string
455
+ readonly actualLatestVersionId: string | null
456
+ }): void {
457
+ if (input.expectedLatestVersionId === undefined) {
458
+ return
459
+ }
460
+
461
+ if (input.actualLatestVersionId !== input.expectedLatestVersionId) {
462
+ throw new LakeStorageError(
463
+ `[SixbDuckLake] Optimistic commit failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId}', found '${input.actualLatestVersionId ?? "none"}'.`
464
+ )
465
+ }
466
+ }
467
+ }
468
+
469
+ function commitRowCount(
470
+ input: Pick<DuckLakeCommitDatasetVersionInput, "expectedLatestVersionId" | "mode">,
471
+ changeResult: ApplyDatasetRowsResult,
472
+ latestVersion: DuckLakeVersionSummary | null
473
+ ): CommitRowCount {
474
+ // Unguarded append commits can be retried by DuckLake after this transaction
475
+ // read the previous latest version. In that case previousRowCount +
476
+ // sourceRowCount can underreport the committed snapshot, so omit metadata
477
+ // and let hydration count the exact committed snapshot instead.
478
+ if (input.mode === "append" && input.expectedLatestVersionId === undefined) {
479
+ return { kind: "unknown" }
480
+ }
481
+
482
+ if (changeResult.resultingRowCount.kind === "exact") {
483
+ return changeResult.resultingRowCount
484
+ }
485
+
486
+ if (input.mode === "append" && latestVersion === null) {
487
+ return { kind: "exact", value: changeResult.sourceRowCount }
488
+ }
489
+
490
+ return { kind: "unknown" }
491
+ }
492
+
493
+ function assertDuckLakeSnapshotId(snapshotId: string): void {
494
+ if (!/^\d+$/.test(snapshotId)) {
495
+ throw new LakeStorageError(`[SixbDuckLake] Invalid DuckLake snapshot id '${snapshotId}'.`)
496
+ }
497
+ }
498
+
499
+ // Local metadata catalogs are useful for dev/test, but they do not have the
500
+ // same multi-connection conflict behavior as PostgreSQL. Serialize local
501
+ // commits within this process so a losing writer cannot hydrate another
502
+ // connection's snapshot during DuckLake conflict handling.
503
+ const localCatalogCommitLocks = new Map<string, Promise<void>>()
504
+
505
+ async function withCatalogCommitLock<T>(
506
+ options: DuckLakeStorageOptions,
507
+ run: () => Promise<T>
508
+ ): Promise<T> {
509
+ const key = localCatalogCoordinationKey(options)
510
+ if (key === undefined) {
511
+ return run()
512
+ }
513
+
514
+ const previous = localCatalogCommitLocks.get(key) ?? Promise.resolve()
515
+ const ready = previous.catch(() => {})
516
+ let release!: () => void
517
+ const next = new Promise<void>((resolve) => {
518
+ release = resolve
519
+ })
520
+ const current = ready.then(() => next)
521
+ localCatalogCommitLocks.set(key, current)
522
+
523
+ await ready
524
+ try {
525
+ return await run()
526
+ } finally {
527
+ release()
528
+ if (localCatalogCommitLocks.get(key) === current) {
529
+ localCatalogCommitLocks.delete(key)
530
+ }
531
+ }
532
+ }
@@ -0,0 +1,170 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import type { DatasetRow } from "@sixb/core"
3
+ import { getDatasetRowValidationError } from "@sixb/core"
4
+ import type {
5
+ BeginDatasetWriteInput,
6
+ CommitDatasetWriteInput,
7
+ DatasetWriteCommitResult,
8
+ LakeWriteSession,
9
+ } from "@sixb/core/lake-storage"
10
+ import { LakeStorageError } from "@sixb/core/lake-storage"
11
+ import type { DuckDbRuntime } from "./duckdb-runtime"
12
+ import { appendDatasetRow } from "./row-appender"
13
+ import { datasetSchemaToDuckDbColumnsSql } from "./schema"
14
+ import { quoteIdentifier } from "./sql"
15
+
16
+ export interface DuckLakeCommitWriteInput {
17
+ readonly write: BeginDatasetWriteInput
18
+ readonly commit?: CommitDatasetWriteInput
19
+ readonly stagingTableName: string
20
+ readonly rowsWritten: number
21
+ }
22
+
23
+ type DuckLakeCommitWrite = (options: DuckLakeCommitWriteInput) => Promise<DatasetWriteCommitResult>
24
+
25
+ // Rows are staged in bounded in-memory batches so a slow source iterable never
26
+ // holds the DuckDB runtime queue. Only the per-batch appender flush takes a
27
+ // queue slot. 1000 keeps memory bounded while amortizing appender open/close.
28
+ const WRITE_ROW_BATCH_SIZE = 1000
29
+
30
+ interface CreateDuckLakeWriteSessionInput {
31
+ readonly commitWrite: DuckLakeCommitWrite
32
+ readonly runtime: DuckDbRuntime
33
+ readonly write: BeginDatasetWriteInput
34
+ }
35
+
36
+ export async function createDuckLakeWriteSession(
37
+ input: CreateDuckLakeWriteSessionInput
38
+ ): Promise<LakeWriteSession> {
39
+ const stagingTableName = `sixb_write_${randomUUID().replaceAll("-", "")}`
40
+
41
+ // The temp table lives only on this runtime connection. That keeps
42
+ // partially written rows invisible until DuckLakeStorage commits them into
43
+ // the durable dataset table.
44
+ await input.runtime.run(
45
+ `CREATE TEMP TABLE ${quoteIdentifier(stagingTableName)} (${datasetSchemaToDuckDbColumnsSql(
46
+ input.write.dataset.schema
47
+ )})`
48
+ )
49
+
50
+ return new DuckLakeWriteSession(input.commitWrite, input.runtime, input.write, stagingTableName)
51
+ }
52
+
53
+ /**
54
+ * Package-private LakeWriteSession implementation for DuckLakeStorage.
55
+ *
56
+ * The session owns row validation, the connection-scoped staging table, and
57
+ * cleanup. DuckLakeStorage owns the durable transaction because it has the
58
+ * dataset/version context needed to build the returned DatasetVersion.
59
+ */
60
+ class DuckLakeWriteSession implements LakeWriteSession {
61
+ private closed = false
62
+ private cleanedUp = false
63
+ private rowsWritten = 0
64
+
65
+ constructor(
66
+ private readonly commitWrite: DuckLakeCommitWrite,
67
+ private readonly runtime: DuckDbRuntime,
68
+ private readonly input: BeginDatasetWriteInput,
69
+ private readonly stagingTableName: string
70
+ ) {}
71
+
72
+ async writeRows(rows: Iterable<DatasetRow> | AsyncIterable<DatasetRow>): Promise<void> {
73
+ this.assertOpen()
74
+
75
+ // Drain and validate the source OUTSIDE the DuckDB queue so slow external
76
+ // reads (pagination, APIs, SFTP, retries) never hold a runtime slot. Only
77
+ // appendBatchToStagingTable below briefly enters the queue.
78
+ let batch: DatasetRow[] = []
79
+ for await (const row of rows) {
80
+ const validationError = getDatasetRowValidationError(row, this.input.dataset)
81
+ if (validationError) {
82
+ throw new LakeStorageError(`[SixbDuckLake] ${validationError}`)
83
+ }
84
+
85
+ // Snapshot the validated row. Callers may reuse and mutate one row object
86
+ // between yields, so the batch must not retain a live reference.
87
+ batch.push(structuredClone(row))
88
+
89
+ if (batch.length >= WRITE_ROW_BATCH_SIZE) {
90
+ await this.appendBatchToStagingTable(batch)
91
+ batch = []
92
+ }
93
+ }
94
+
95
+ if (batch.length > 0) {
96
+ await this.appendBatchToStagingTable(batch)
97
+ }
98
+ }
99
+
100
+ private async appendBatchToStagingTable(batch: readonly DatasetRow[]): Promise<void> {
101
+ // Synchronous callback: no awaits inside the queue slot. The appender
102
+ // buffers and flushes the whole batch into the staging temp table on close.
103
+ await this.runtime.withAppender(this.stagingTableName, (appender) => {
104
+ for (const row of batch) {
105
+ appendDatasetRow(appender, this.input.dataset.schema, row)
106
+ }
107
+ })
108
+ // Count only rows actually appended -- commit asserts rowsWritten ===
109
+ // sourceRowCount in the write coordinator.
110
+ this.rowsWritten += batch.length
111
+ }
112
+
113
+ async commit(input?: CommitDatasetWriteInput): Promise<DatasetWriteCommitResult> {
114
+ this.assertOpen()
115
+ this.closed = true
116
+
117
+ let committed = false
118
+ try {
119
+ const result = await this.commitWrite({
120
+ write: this.input,
121
+ commit: input,
122
+ rowsWritten: this.rowsWritten,
123
+ stagingTableName: this.stagingTableName,
124
+ })
125
+ committed = true
126
+ // Even successful commits no longer need the staging table once rows
127
+ // have been inserted into the durable DuckLake table.
128
+ await this.cleanup()
129
+ return result
130
+ } catch (error) {
131
+ if (!committed) {
132
+ await this.cleanup()
133
+ }
134
+ throw error
135
+ }
136
+ }
137
+
138
+ async abort(): Promise<void> {
139
+ if (this.closed) {
140
+ return
141
+ }
142
+
143
+ this.closed = true
144
+ await this.cleanup()
145
+ }
146
+
147
+ private assertOpen(): void {
148
+ if (this.closed) {
149
+ throw new LakeStorageError("[SixbDuckLake] Write session is already closed.")
150
+ }
151
+ }
152
+
153
+ private async cleanup(): Promise<void> {
154
+ if (this.cleanedUp) {
155
+ return
156
+ }
157
+
158
+ this.cleanedUp = true
159
+ await this.dropStagingTable()
160
+ }
161
+
162
+ private async dropStagingTable(): Promise<void> {
163
+ try {
164
+ await this.runtime.run(`DROP TABLE IF EXISTS ${quoteIdentifier(this.stagingTableName)}`)
165
+ } catch {
166
+ // Preserve the commit/abort failure. Temp tables are connection-scoped
167
+ // and will disappear when the storage runtime closes.
168
+ }
169
+ }
170
+ }