@sixb/ducklake 0.1.0 → 0.1.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 (38) hide show
  1. package/README.md +37 -0
  2. package/dist/ducklake-storage.d.ts +2 -1
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +598 -43
  5. package/dist/index.js.map +15 -13
  6. package/dist/internal/dataset-row-commit.d.ts +6 -0
  7. package/dist/internal/dataset-row-commit.d.ts.map +1 -1
  8. package/dist/internal/dataset-row-merge.d.ts +16 -0
  9. package/dist/internal/dataset-row-merge.d.ts.map +1 -0
  10. package/dist/internal/ducklake-catalog-schema.d.ts +4 -1
  11. package/dist/internal/ducklake-catalog-schema.d.ts.map +1 -1
  12. package/dist/internal/ducklake-current-dataset-definitions.d.ts.map +1 -1
  13. package/dist/internal/ducklake-dataset-catalog.d.ts +1 -0
  14. package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -1
  15. package/dist/internal/ducklake-merge-session.d.ts +21 -0
  16. package/dist/internal/ducklake-merge-session.d.ts.map +1 -0
  17. package/dist/internal/ducklake-snapshot-reader.d.ts +1 -0
  18. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
  19. package/dist/internal/ducklake-write-coordinator.d.ts +5 -1
  20. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
  21. package/dist/internal/ducklake-write-session.d.ts.map +1 -1
  22. package/dist/internal/schema.d.ts +2 -0
  23. package/dist/internal/schema.d.ts.map +1 -1
  24. package/dist/internal/versions.d.ts +1 -0
  25. package/dist/internal/versions.d.ts.map +1 -1
  26. package/package.json +7 -4
  27. package/src/ducklake-storage.ts +6 -0
  28. package/src/internal/dataset-row-commit.ts +95 -2
  29. package/src/internal/dataset-row-merge.ts +268 -0
  30. package/src/internal/ducklake-catalog-schema.ts +90 -1
  31. package/src/internal/ducklake-current-dataset-definitions.ts +14 -5
  32. package/src/internal/ducklake-dataset-catalog.ts +43 -9
  33. package/src/internal/ducklake-merge-session.ts +187 -0
  34. package/src/internal/ducklake-snapshot-reader.ts +9 -2
  35. package/src/internal/ducklake-write-coordinator.ts +160 -20
  36. package/src/internal/ducklake-write-session.ts +28 -5
  37. package/src/internal/schema.ts +7 -0
  38. package/src/internal/versions.ts +17 -1
@@ -60,6 +60,7 @@ export interface DuckLakeVersionSummary {
60
60
  readonly datasetId: string
61
61
  readonly versionId: string
62
62
  readonly rowCount?: number
63
+ readonly validatedPrimaryKeyColumns?: readonly string[]
63
64
  }
64
65
 
65
66
  /** One snapshot row from the shared catalog scan, before per-dataset filtering. */
@@ -444,6 +445,9 @@ export class DuckLakeSnapshotReader {
444
445
  datasetId: tableRef.datasetId,
445
446
  versionId: toVersionId(row.snapshotId),
446
447
  ...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
448
+ ...(row.metadata?.validatedPrimaryKeyColumns !== undefined
449
+ ? { validatedPrimaryKeyColumns: row.metadata.validatedPrimaryKeyColumns }
450
+ : {}),
447
451
  }
448
452
  }
449
453
 
@@ -549,7 +553,10 @@ export class DuckLakeSnapshotReader {
549
553
  return null
550
554
  }
551
555
 
552
- if (!options.includeParent || (row.mode !== "append" && row.mode !== "schema")) {
556
+ if (
557
+ !options.includeParent ||
558
+ (row.mode !== "append" && row.mode !== "merge" && row.mode !== "schema")
559
+ ) {
553
560
  return row
554
561
  }
555
562
 
@@ -807,7 +814,7 @@ export class DuckLakeSnapshotReader {
807
814
  // Parent ids track versions that reuse the previous dataset state.
808
815
  // Snapshot versions replace the rows, so time travel stands on the snapshot id.
809
816
  const parentSnapshotId =
810
- snapshot.mode === "append" || snapshot.mode === "schema"
817
+ snapshot.mode === "append" || snapshot.mode === "merge" || snapshot.mode === "schema"
811
818
  ? snapshots[index + 1]?.snapshotId
812
819
  : undefined
813
820
  return parentSnapshotId === undefined ? snapshot : { ...snapshot, parentSnapshotId }
@@ -1,13 +1,16 @@
1
1
  import { randomUUID } from "node:crypto"
2
2
  import type { DatasetDefinition } from "@sixb/core"
3
3
  import type {
4
+ BeginDatasetMergeInput,
4
5
  BeginDatasetWriteInput,
6
+ DatasetMergeCommitResult,
5
7
  DatasetVersion,
6
8
  DatasetWriteCommitResult,
7
9
  DatasetWriteMode,
10
+ LakeMergeSession,
8
11
  LakeWriteSession,
9
12
  } from "@sixb/core/lake-storage"
10
- import { LakeStorageError } from "@sixb/core/lake-storage"
13
+ import { getDatasetPrimaryKeyColumns, LakeStorageError } from "@sixb/core/lake-storage"
11
14
  import type { DuckLakeStorageOptions } from "../types"
12
15
  import { localCatalogCoordinationKey } from "./catalog-key"
13
16
  import {
@@ -16,10 +19,12 @@ import {
16
19
  assertDatasetWriteMode,
17
20
  type CommitRowCount,
18
21
  } from "./dataset-row-commit"
22
+ import { applyDatasetMergeFromRelation } from "./dataset-row-merge"
19
23
  import { getBigIntLike } from "./duckdb-row"
20
24
  import type { DuckDbQueryRuntime } from "./duckdb-runtime"
21
25
  import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
22
26
  import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
27
+ import { createDuckLakeMergeSession, type DuckLakeCommitMergeInput } from "./ducklake-merge-session"
23
28
  import type { DuckLakeSnapshotReader, DuckLakeVersionSummary } from "./ducklake-snapshot-reader"
24
29
  import { createDuckLakeWriteSession, type DuckLakeCommitWriteInput } from "./ducklake-write-session"
25
30
  import { duckLakeAlias, duckLakeMetadataTableName, quoteIdentifier, quoteSqlString } from "./sql"
@@ -38,12 +43,22 @@ export interface DuckLakeCommitDatasetVersionInput {
38
43
  ): Promise<ApplyDatasetRowsResult>
39
44
  }
40
45
 
41
- interface DuckLakeCommitDatasetVersionRuntimeInput extends DuckLakeCommitDatasetVersionInput {
46
+ interface DuckLakeCommitVersionOutcomeInput
47
+ extends Omit<DuckLakeCommitDatasetVersionInput, "expectedLatestVersionId" | "mode"> {
48
+ readonly mode: DatasetWriteMode | "merge"
49
+ readonly expectedLatestVersionId?: string | null
50
+ readonly allowInitialNoOp?: boolean
51
+ readonly disableRetries?: boolean
52
+ readonly optimisticOperation?: "commit" | "merge commit"
53
+ }
54
+
55
+ interface DuckLakeCommitVersionOutcomeRuntimeInput extends DuckLakeCommitVersionOutcomeInput {
42
56
  readonly runtime: DuckDbQueryRuntime
43
57
  }
44
58
 
45
59
  export interface DuckLakeApplyChangesContext {
46
60
  readonly previousRowCount?: number
61
+ readonly validatedPrimaryKeyColumns?: readonly string[]
47
62
  }
48
63
 
49
64
  /**
@@ -90,6 +105,40 @@ export class DuckLakeWriteCoordinator {
90
105
  })
91
106
  }
92
107
 
108
+ async beginMerge(input: BeginDatasetMergeInput): Promise<LakeMergeSession> {
109
+ this.connections.assertOpen()
110
+
111
+ const definition = await this.datasets.getDataset(input.dataset.id)
112
+ if (!definition) {
113
+ throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.dataset.id}'.`)
114
+ }
115
+ if (getDatasetPrimaryKeyColumns(definition) === null) {
116
+ throw new LakeStorageError(
117
+ `[SixbDuckLake] Dataset '${definition.id}' must define a primaryKey before it can be merged.`
118
+ )
119
+ }
120
+
121
+ this.datasets.assertSchema(definition)
122
+ const latestVersion = await this.connections.withAttachedRuntime((runtime) =>
123
+ this.snapshots.getLatestVersionForDefinition(runtime, definition)
124
+ )
125
+ if (
126
+ input.expectedLatestVersionId !== undefined &&
127
+ latestVersion?.versionId !== input.expectedLatestVersionId
128
+ ) {
129
+ throw new LakeStorageError(
130
+ `[SixbDuckLake] Optimistic merge start failed for dataset '${definition.id}': expected latest version '${input.expectedLatestVersionId}', found '${latestVersion?.versionId ?? "none"}'.`
131
+ )
132
+ }
133
+ const runtime = await this.connections.stagingRuntime()
134
+ return createDuckLakeMergeSession({
135
+ commitMerge: (options) => this.commitMerge(options),
136
+ runtime,
137
+ merge: { ...input, dataset: definition },
138
+ baseVersionId: latestVersion?.versionId ?? null,
139
+ })
140
+ }
141
+
93
142
  private async commitWrite(input: DuckLakeCommitWriteInput): Promise<DatasetWriteCommitResult> {
94
143
  const definition = await this.datasets.getDataset(input.write.dataset.id)
95
144
  if (!definition) {
@@ -115,6 +164,50 @@ export class DuckLakeWriteCoordinator {
115
164
  })
116
165
  }
117
166
 
167
+ private async commitMerge(input: DuckLakeCommitMergeInput): Promise<DatasetMergeCommitResult> {
168
+ const definition = await this.datasets.getDataset(input.merge.dataset.id)
169
+ if (!definition) {
170
+ throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.merge.dataset.id}'.`)
171
+ }
172
+ if (getDatasetPrimaryKeyColumns(definition) === null) {
173
+ throw new LakeStorageError(
174
+ `[SixbDuckLake] Dataset '${definition.id}' must define a primaryKey before it can be merged.`
175
+ )
176
+ }
177
+
178
+ return this.withCommitRuntime((runtime) =>
179
+ this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
180
+ dataset: definition,
181
+ mode: "merge",
182
+ expectedLatestVersionId: input.baseVersionId,
183
+ allowInitialNoOp: true,
184
+ disableRetries: true,
185
+ optimisticOperation: "merge commit",
186
+ commitMessage: input.commit?.commitMessage ?? `merge dataset ${definition.id}`,
187
+ producer: input.merge.producer,
188
+ inputs: input.merge.inputs,
189
+ applyChanges: async (commitRuntime, context) => {
190
+ const result = await applyDatasetMergeFromRelation({
191
+ options: this.options,
192
+ runtime: commitRuntime,
193
+ dataset: definition,
194
+ stagingTableName: input.stagingTableName,
195
+ sequenceColumnName: input.sequenceColumnName,
196
+ kindColumnName: input.kindColumnName,
197
+ previousRowCount: context.previousRowCount,
198
+ validatedPrimaryKeyColumns: context.validatedPrimaryKeyColumns,
199
+ })
200
+ if (result.sourceRowCount !== input.changesWritten) {
201
+ throw new LakeStorageError(
202
+ `[SixbDuckLake] Staged merge for dataset '${definition.id}' accepted ${input.changesWritten} change(s), but DuckLake saw ${result.sourceRowCount} source change(s) at commit time.`
203
+ )
204
+ }
205
+ return result
206
+ },
207
+ })
208
+ )
209
+ }
210
+
118
211
  async commitDatasetVersion(
119
212
  input: DuckLakeCommitDatasetVersionInput
120
213
  ): Promise<DatasetWriteCommitResult> {
@@ -135,6 +228,26 @@ export class DuckLakeWriteCoordinator {
135
228
  runtime: DuckDbQueryRuntime,
136
229
  input: DuckLakeCommitDatasetVersionInput
137
230
  ): Promise<DatasetWriteCommitResult> {
231
+ const result = await this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
232
+ ...input,
233
+ // DuckLake has no primary-key constraint. Do not let its automatic retry replay a keyed
234
+ // append after the collision checks ran against an older transaction snapshot.
235
+ disableRetries:
236
+ input.mode === "append" && getDatasetPrimaryKeyColumns(input.dataset) !== null,
237
+ })
238
+ if (result.version === null) {
239
+ throw new LakeStorageError(
240
+ `[SixbDuckLake] No DuckLake changes were committed for dataset '${input.dataset.id}', and no previous version exists.`
241
+ )
242
+ }
243
+
244
+ return { ...result.version, outcome: result.outcome }
245
+ }
246
+
247
+ private async commitVersionOutcomeOnExclusiveRuntime(
248
+ runtime: DuckDbQueryRuntime,
249
+ input: DuckLakeCommitVersionOutcomeInput
250
+ ): Promise<DatasetMergeCommitResult> {
138
251
  const runtimeInput = { ...input, runtime }
139
252
  return this.withGuardedRetrySetting(runtimeInput, () =>
140
253
  this.commitDatasetVersionUnlocked(runtimeInput)
@@ -142,16 +255,16 @@ export class DuckLakeWriteCoordinator {
142
255
  }
143
256
 
144
257
  private async withGuardedRetrySetting<T>(
145
- input: DuckLakeCommitDatasetVersionRuntimeInput,
258
+ input: DuckLakeCommitVersionOutcomeRuntimeInput,
146
259
  run: () => Promise<T>
147
260
  ): Promise<T> {
148
- if (input.expectedLatestVersionId === undefined) {
261
+ if (input.expectedLatestVersionId === undefined && !input.disableRetries) {
149
262
  return run()
150
263
  }
151
264
 
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.
265
+ // DuckLake can retry transactions internally. Optimistic guards and keyed append constraint
266
+ // checks must not be replayed against a different snapshot, so disable retries for this
267
+ // exclusive commit and restore the runtime setting before releasing the queue.
155
268
  await input.runtime.run("SET ducklake_max_retry_count = 0")
156
269
  let outcome:
157
270
  | { readonly kind: "success"; readonly value: T }
@@ -185,8 +298,8 @@ export class DuckLakeWriteCoordinator {
185
298
  }
186
299
 
187
300
  private async commitDatasetVersionUnlocked(
188
- input: DuckLakeCommitDatasetVersionRuntimeInput
189
- ): Promise<DatasetWriteCommitResult> {
301
+ input: DuckLakeCommitVersionOutcomeRuntimeInput
302
+ ): Promise<DatasetMergeCommitResult> {
190
303
  // Capture the write connection's last committed DuckLake snapshot before
191
304
  // and after COMMIT. The commitId in DuckLake commit_extra_info proves
192
305
  // which snapshot belongs to this transaction.
@@ -204,11 +317,13 @@ export class DuckLakeWriteCoordinator {
204
317
  datasetId: input.dataset.id,
205
318
  expectedLatestVersionId: input.expectedLatestVersionId,
206
319
  actualLatestVersionId: latestVersion?.versionId ?? null,
320
+ operation: input.optimisticOperation ?? "commit",
207
321
  })
208
322
  }
209
323
 
210
324
  const changeResult = await input.applyChanges(input.runtime, {
211
325
  previousRowCount: latestVersion?.rowCount,
326
+ validatedPrimaryKeyColumns: latestVersion?.validatedPrimaryKeyColumns,
212
327
  })
213
328
  const commitId = randomUUID()
214
329
  await this.setCommitMetadata(
@@ -241,13 +356,13 @@ export class DuckLakeWriteCoordinator {
241
356
  }
242
357
 
243
358
  private async versionForCommittedWrite(
244
- input: DuckLakeCommitDatasetVersionRuntimeInput & {
359
+ input: DuckLakeCommitVersionOutcomeRuntimeInput & {
245
360
  readonly commitId: string
246
361
  readonly previousWriteSnapshotId: string | null
247
362
  readonly committedWriteSnapshotId: string | null
248
363
  readonly changeResult: ApplyDatasetRowsResult
249
364
  }
250
- ): Promise<DatasetWriteCommitResult> {
365
+ ): Promise<DatasetMergeCommitResult> {
251
366
  // Hydrate the committed version on the same exclusive runtime. That keeps
252
367
  // snapshot matching and row-count fallback inside the same serialized
253
368
  // connection state that just committed.
@@ -266,7 +381,7 @@ export class DuckLakeWriteCoordinator {
266
381
  ownSnapshotId
267
382
  )
268
383
  if (version) {
269
- return { ...version, outcome: "created" }
384
+ return { outcome: "created", version }
270
385
  }
271
386
 
272
387
  throw new LakeStorageError(
@@ -275,9 +390,22 @@ export class DuckLakeWriteCoordinator {
275
390
  }
276
391
 
277
392
  if (!input.changeResult.dataChangeExpected) {
393
+ const version = await this.versionForNoOpCommit(
394
+ input.runtime,
395
+ input.dataset,
396
+ input.allowInitialNoOp ?? false
397
+ )
398
+ if (input.expectedLatestVersionId !== undefined) {
399
+ this.assertExpectedLatestVersion({
400
+ datasetId: input.dataset.id,
401
+ expectedLatestVersionId: input.expectedLatestVersionId,
402
+ actualLatestVersionId: version?.versionId ?? null,
403
+ operation: input.optimisticOperation ?? "commit",
404
+ })
405
+ }
278
406
  return {
279
- ...(await this.versionForNoOpCommit(input.runtime, input.dataset)),
280
407
  outcome: "unchanged",
408
+ version,
281
409
  }
282
410
  }
283
411
 
@@ -318,7 +446,7 @@ export class DuckLakeWriteCoordinator {
318
446
  }
319
447
 
320
448
  private async setCommitMetadata(
321
- input: DuckLakeCommitDatasetVersionRuntimeInput,
449
+ input: DuckLakeCommitVersionOutcomeRuntimeInput,
322
450
  commitId: string,
323
451
  rowCount: CommitRowCount
324
452
  ): Promise<void> {
@@ -326,6 +454,9 @@ export class DuckLakeWriteCoordinator {
326
454
  // transaction. Version id, timestamp, and data-change detection still come
327
455
  // from DuckLake itself. commitId is only a transaction correlation token so
328
456
  // concurrent writers do not accidentally hydrate each other's snapshots.
457
+ // Every keyed data path either replaces the baseline or validates its uniqueness before this
458
+ // marker is persisted. Merge commits can therefore avoid repeating that full-table audit.
459
+ const validatedPrimaryKeyColumns = getDatasetPrimaryKeyColumns(input.dataset)
329
460
  const metadata: SixbCommitMetadata = {
330
461
  kind: "datasetVersion",
331
462
  datasetId: input.dataset.id,
@@ -334,6 +465,9 @@ export class DuckLakeWriteCoordinator {
334
465
  producer: input.producer ? structuredClone(input.producer) : undefined,
335
466
  inputs: input.inputs ? structuredClone(input.inputs) : undefined,
336
467
  ...(rowCount.kind === "exact" ? { rowCount: rowCount.value } : {}),
468
+ ...(validatedPrimaryKeyColumns !== null
469
+ ? { validatedPrimaryKeyColumns: [...validatedPrimaryKeyColumns] }
470
+ : {}),
337
471
  }
338
472
 
339
473
  await input.runtime.run(
@@ -347,13 +481,18 @@ export class DuckLakeWriteCoordinator {
347
481
 
348
482
  private async versionForNoOpCommit(
349
483
  runtime: DuckDbQueryRuntime,
350
- definition: DatasetDefinition
351
- ): Promise<DatasetVersion> {
484
+ definition: DatasetDefinition,
485
+ allowInitialNoOp: boolean
486
+ ): Promise<DatasetVersion | null> {
352
487
  const latestVersion = await this.snapshots.getLatestVersionForDefinition(runtime, definition)
353
488
  if (latestVersion) {
354
489
  return latestVersion
355
490
  }
356
491
 
492
+ if (allowInitialNoOp) {
493
+ return null
494
+ }
495
+
357
496
  throw new LakeStorageError(
358
497
  `[SixbDuckLake] No DuckLake changes were committed for dataset '${definition.id}', and no previous version exists.`
359
498
  )
@@ -369,7 +508,7 @@ export class DuckLakeWriteCoordinator {
369
508
  }
370
509
 
371
510
  private async latestVersionForCommit(
372
- input: DuckLakeCommitDatasetVersionRuntimeInput
511
+ input: DuckLakeCommitVersionOutcomeRuntimeInput
373
512
  ): Promise<DuckLakeVersionSummary | null> {
374
513
  if (input.expectedLatestVersionId === undefined && input.mode !== "append") {
375
514
  return null
@@ -451,8 +590,9 @@ export class DuckLakeWriteCoordinator {
451
590
 
452
591
  private assertExpectedLatestVersion(input: {
453
592
  readonly datasetId: string
454
- readonly expectedLatestVersionId?: string
593
+ readonly expectedLatestVersionId?: string | null
455
594
  readonly actualLatestVersionId: string | null
595
+ readonly operation: "commit" | "merge commit"
456
596
  }): void {
457
597
  if (input.expectedLatestVersionId === undefined) {
458
598
  return
@@ -460,14 +600,14 @@ export class DuckLakeWriteCoordinator {
460
600
 
461
601
  if (input.actualLatestVersionId !== input.expectedLatestVersionId) {
462
602
  throw new LakeStorageError(
463
- `[SixbDuckLake] Optimistic commit failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId}', found '${input.actualLatestVersionId ?? "none"}'.`
603
+ `[SixbDuckLake] Optimistic ${input.operation} failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId ?? "none"}', found '${input.actualLatestVersionId ?? "none"}'.`
464
604
  )
465
605
  }
466
606
  }
467
607
  }
468
608
 
469
609
  function commitRowCount(
470
- input: Pick<DuckLakeCommitDatasetVersionInput, "expectedLatestVersionId" | "mode">,
610
+ input: Pick<DuckLakeCommitVersionOutcomeInput, "expectedLatestVersionId" | "mode">,
471
611
  changeResult: ApplyDatasetRowsResult,
472
612
  latestVersion: DuckLakeVersionSummary | null
473
613
  ): CommitRowCount {
@@ -7,7 +7,11 @@ import type {
7
7
  DatasetWriteCommitResult,
8
8
  LakeWriteSession,
9
9
  } from "@sixb/core/lake-storage"
10
- import { LakeStorageError } from "@sixb/core/lake-storage"
10
+ import {
11
+ encodeDatasetPrimaryKey,
12
+ getDatasetPrimaryKeyColumns,
13
+ LakeStorageError,
14
+ } from "@sixb/core/lake-storage"
11
15
  import type { DuckDbRuntime } from "./duckdb-runtime"
12
16
  import { appendDatasetRow } from "./row-appender"
13
17
  import { datasetSchemaToDuckDbColumnsSql } from "./schema"
@@ -61,6 +65,7 @@ class DuckLakeWriteSession implements LakeWriteSession {
61
65
  private closed = false
62
66
  private cleanedUp = false
63
67
  private rowsWritten = 0
68
+ private readonly stagedPrimaryKeys = new Set<string>()
64
69
 
65
70
  constructor(
66
71
  private readonly commitWrite: DuckLakeCommitWrite,
@@ -76,6 +81,7 @@ class DuckLakeWriteSession implements LakeWriteSession {
76
81
  // reads (pagination, APIs, SFTP, retries) never hold a runtime slot. Only
77
82
  // appendBatchToStagingTable below briefly enters the queue.
78
83
  let batch: DatasetRow[] = []
84
+ let batchPrimaryKeys = new Set<string>()
79
85
  for await (const row of rows) {
80
86
  const validationError = getDatasetRowValidationError(row, this.input.dataset)
81
87
  if (validationError) {
@@ -84,20 +90,34 @@ class DuckLakeWriteSession implements LakeWriteSession {
84
90
 
85
91
  // Snapshot the validated row. Callers may reuse and mutate one row object
86
92
  // between yields, so the batch must not retain a live reference.
87
- batch.push(structuredClone(row))
93
+ const clonedRow = structuredClone(row)
94
+ if (getDatasetPrimaryKeyColumns(this.input.dataset) !== null) {
95
+ const primaryKey = encodeDatasetPrimaryKey(this.input.dataset, clonedRow)
96
+ if (this.stagedPrimaryKeys.has(primaryKey) || batchPrimaryKeys.has(primaryKey)) {
97
+ throw new LakeStorageError(
98
+ `[SixbDuckLake] Dataset '${this.input.dataset.id}' ${this.input.mode ?? "snapshot"} source contains duplicate primary key.`
99
+ )
100
+ }
101
+ batchPrimaryKeys.add(primaryKey)
102
+ }
103
+ batch.push(clonedRow)
88
104
 
89
105
  if (batch.length >= WRITE_ROW_BATCH_SIZE) {
90
- await this.appendBatchToStagingTable(batch)
106
+ await this.appendBatchToStagingTable(batch, batchPrimaryKeys)
91
107
  batch = []
108
+ batchPrimaryKeys = new Set()
92
109
  }
93
110
  }
94
111
 
95
112
  if (batch.length > 0) {
96
- await this.appendBatchToStagingTable(batch)
113
+ await this.appendBatchToStagingTable(batch, batchPrimaryKeys)
97
114
  }
98
115
  }
99
116
 
100
- private async appendBatchToStagingTable(batch: readonly DatasetRow[]): Promise<void> {
117
+ private async appendBatchToStagingTable(
118
+ batch: readonly DatasetRow[],
119
+ primaryKeys: ReadonlySet<string>
120
+ ): Promise<void> {
101
121
  // Synchronous callback: no awaits inside the queue slot. The appender
102
122
  // buffers and flushes the whole batch into the staging temp table on close.
103
123
  await this.runtime.withAppender(this.stagingTableName, (appender) => {
@@ -108,6 +128,9 @@ class DuckLakeWriteSession implements LakeWriteSession {
108
128
  // Count only rows actually appended -- commit asserts rowsWritten ===
109
129
  // sourceRowCount in the write coordinator.
110
130
  this.rowsWritten += batch.length
131
+ for (const primaryKey of primaryKeys) {
132
+ this.stagedPrimaryKeys.add(primaryKey)
133
+ }
111
134
  }
112
135
 
113
136
  async commit(input?: CommitDatasetWriteInput): Promise<DatasetWriteCommitResult> {
@@ -49,6 +49,13 @@ export function datasetSchemaToDuckDbColumnsSql(schema: DatasetSchema): string {
49
49
  return schema.columns.map((column) => datasetColumnToDuckDbSql(column)).join(", ")
50
50
  }
51
51
 
52
+ /** Render a dataset schema for merge staging, where delete rows leave non-key values null. */
53
+ export function datasetSchemaToDuckDbNullableColumnsSql(schema: DatasetSchema): string {
54
+ return schema.columns
55
+ .map((column) => `${quoteIdentifier(column.name)} ${datasetColumnTypeToDuckDbSql(column.type)}`)
56
+ .join(", ")
57
+ }
58
+
52
59
  /**
53
60
  * Render the column list used by INSERT ... SELECT operations.
54
61
  */
@@ -13,6 +13,7 @@ export interface SixbCommitMetadata {
13
13
  readonly producer?: DatasetVersion["producer"]
14
14
  readonly inputs?: DatasetVersion["inputs"]
15
15
  readonly rowCount?: number
16
+ readonly validatedPrimaryKeyColumns?: readonly string[]
16
17
  readonly schemaChange?: SixbSchemaChangeMetadata
17
18
  }
18
19
 
@@ -57,15 +58,21 @@ export function parseCommitMetadata(value: unknown): SixbCommitMetadata | undefi
57
58
  const mode = parsed.sixb.mode
58
59
  const commitId = parsed.sixb.commitId
59
60
  const rowCount = parsed.sixb.rowCount
61
+ const validatedPrimaryKeyColumns = parsed.sixb.validatedPrimaryKeyColumns
60
62
 
61
63
  return {
62
64
  kind: "datasetVersion",
63
65
  datasetId: parsed.sixb.datasetId,
64
66
  ...(typeof commitId === "string" ? { commitId } : {}),
65
- ...(mode === "snapshot" || mode === "append" || mode === "schema" ? { mode } : {}),
67
+ ...(mode === "snapshot" || mode === "append" || mode === "merge" || mode === "schema"
68
+ ? { mode }
69
+ : {}),
66
70
  ...(isDatasetProducer(parsed.sixb.producer) ? { producer: parsed.sixb.producer } : {}),
67
71
  ...(isDatasetVersionRefs(parsed.sixb.inputs) ? { inputs: parsed.sixb.inputs } : {}),
68
72
  ...(typeof commitId === "string" && isRowCount(rowCount) ? { rowCount } : {}),
73
+ ...(typeof commitId === "string" && isValidatedPrimaryKeyColumns(validatedPrimaryKeyColumns)
74
+ ? { validatedPrimaryKeyColumns }
75
+ : {}),
69
76
  ...(isSchemaChangeMetadata(parsed.sixb.schemaChange)
70
77
  ? { schemaChange: parsed.sixb.schemaChange }
71
78
  : {}),
@@ -138,3 +145,12 @@ function optionalString(value: unknown): boolean {
138
145
  function isRowCount(value: unknown): value is number {
139
146
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
140
147
  }
148
+
149
+ function isValidatedPrimaryKeyColumns(value: unknown): value is readonly string[] {
150
+ return (
151
+ Array.isArray(value) &&
152
+ value.length > 0 &&
153
+ value.every((column) => typeof column === "string" && column.trim().length > 0) &&
154
+ new Set(value).size === value.length
155
+ )
156
+ }