@sixb/ducklake 0.0.1 → 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
package/dist/index.js CHANGED
@@ -989,6 +989,9 @@ function datasetColumnToDuckDbSql(column) {
989
989
  function datasetSchemaToDuckDbColumnsSql(schema) {
990
990
  return schema.columns.map((column) => datasetColumnToDuckDbSql(column)).join(", ");
991
991
  }
992
+ function datasetSchemaToDuckDbNullableColumnsSql(schema) {
993
+ return schema.columns.map((column) => `${quoteIdentifier(column.name)} ${datasetColumnTypeToDuckDbSql(column.type)}`).join(", ");
994
+ }
992
995
  function datasetSchemaColumnNamesSql(schema) {
993
996
  return schema.columns.map((column) => quoteIdentifier(column.name)).join(", ");
994
997
  }
@@ -1056,6 +1059,8 @@ function getIntegerField(record, key) {
1056
1059
  }
1057
1060
 
1058
1061
  // src/internal/ducklake-catalog-schema.ts
1062
+ var SIXB_PRIMARY_KEY_COMMENT_NAMESPACE = "sixb:primary-key:";
1063
+ var SIXB_PRIMARY_KEY_COMMENT_PREFIX = `${SIXB_PRIMARY_KEY_COMMENT_NAMESPACE}v1:`;
1059
1064
  var FILE_REF_STRUCT_CHILDREN = [
1060
1065
  { name: "blobId", type: "string" },
1061
1066
  { name: "digest", type: "string" },
@@ -1080,6 +1085,58 @@ function duckLakeCatalogColumnsToDatasetSchema(tableName, rows) {
1080
1085
  columns: topLevelColumns.sort(compareDuckLakeCatalogColumnOrder).map((column) => duckLakeCatalogColumnToDefinition(tableName, column, childrenByParentId))
1081
1086
  };
1082
1087
  }
1088
+ function duckLakePrimaryKeyColumnComment(ordinal) {
1089
+ return `${SIXB_PRIMARY_KEY_COMMENT_PREFIX}${ordinal}`;
1090
+ }
1091
+ function duckLakeCatalogColumnsToDatasetPrimaryKey(tableName, rows) {
1092
+ const keyedColumns = [];
1093
+ const seenOrdinals = new Set;
1094
+ for (const column of rows) {
1095
+ const comment = column.comment;
1096
+ if (comment === undefined || !comment.startsWith(SIXB_PRIMARY_KEY_COMMENT_NAMESPACE)) {
1097
+ continue;
1098
+ }
1099
+ if (column.parentColumnId !== undefined || !comment.startsWith(SIXB_PRIMARY_KEY_COMMENT_PREFIX)) {
1100
+ throwInvalidPrimaryKeyMetadata(tableName, column, comment);
1101
+ }
1102
+ const ordinalText = comment.slice(SIXB_PRIMARY_KEY_COMMENT_PREFIX.length);
1103
+ if (!/^(0|[1-9]\d*)$/.test(ordinalText)) {
1104
+ throwInvalidPrimaryKeyMetadata(tableName, column, comment);
1105
+ }
1106
+ const ordinal = Number(ordinalText);
1107
+ if (!Number.isSafeInteger(ordinal)) {
1108
+ throwInvalidPrimaryKeyMetadata(tableName, column, comment);
1109
+ }
1110
+ if (seenOrdinals.has(ordinal)) {
1111
+ throw new LakeStorageError8(`[SixbDuckLake] Dataset table '${tableName}' has duplicate primary-key ordinal '${ordinal}'.`);
1112
+ }
1113
+ seenOrdinals.add(ordinal);
1114
+ if (duckDbTypeToDatasetColumnType(column.columnType) !== "string") {
1115
+ throw new LakeStorageError8(`[SixbDuckLake] Dataset table '${tableName}' primary-key column '${column.columnName}' must map to type 'string'.`);
1116
+ }
1117
+ if (column.nullsAllowed) {
1118
+ throw new LakeStorageError8(`[SixbDuckLake] Dataset table '${tableName}' primary-key column '${column.columnName}' must not be nullable.`);
1119
+ }
1120
+ keyedColumns.push({ column, ordinal });
1121
+ }
1122
+ if (keyedColumns.length === 0) {
1123
+ return;
1124
+ }
1125
+ keyedColumns.sort((left, right) => left.ordinal - right.ordinal);
1126
+ for (const [expectedOrdinal, keyedColumn] of keyedColumns.entries()) {
1127
+ if (keyedColumn.ordinal !== expectedOrdinal) {
1128
+ throw new LakeStorageError8(`[SixbDuckLake] Dataset table '${tableName}' primary-key ordinals must be contiguous from 0.`);
1129
+ }
1130
+ }
1131
+ const columnNames = keyedColumns.map(({ column }) => column.columnName);
1132
+ if (columnNames.length === 1) {
1133
+ return columnNames[0];
1134
+ }
1135
+ return columnNames;
1136
+ }
1137
+ function throwInvalidPrimaryKeyMetadata(tableName, column, comment) {
1138
+ throw new LakeStorageError8(`[SixbDuckLake] Dataset table '${tableName}' has malformed primary-key metadata '${comment}' on column '${column.columnName}'.`);
1139
+ }
1083
1140
  function duckLakeCatalogColumnToDefinition(tableName, column, childrenByParentId) {
1084
1141
  const children = [...childrenByParentId.get(column.columnId) ?? []].sort(compareDuckLakeCatalogColumnOrder);
1085
1142
  return {
@@ -1205,11 +1262,14 @@ async function readCurrentDatasetDefinitions(input) {
1205
1262
  const partitionByTableName = groupPartitionsByTableName(await readCurrentDatasetPartitions(input, tableNames));
1206
1263
  const definitions = new Map;
1207
1264
  for (const table of tables) {
1265
+ const columns = columnsByTableName.get(table.tableName) ?? [];
1266
+ const primaryKey = duckLakeCatalogColumnsToDatasetPrimaryKey(table.tableName, columns);
1208
1267
  const partitionBy = partitionByTableName.get(table.tableName) ?? [];
1209
1268
  definitions.set(table.datasetId, {
1210
1269
  kind: "dataset",
1211
1270
  id: table.datasetId,
1212
- schema: duckLakeCatalogColumnsToDatasetSchema(table.tableName, columnsByTableName.get(table.tableName) ?? []),
1271
+ schema: duckLakeCatalogColumnsToDatasetSchema(table.tableName, columns),
1272
+ ...primaryKey !== undefined ? { primaryKey } : {},
1213
1273
  ...partitionBy.length > 0 ? { partitionBy } : {},
1214
1274
  ...table.comment !== undefined ? { description: table.comment } : {}
1215
1275
  });
@@ -1243,6 +1303,7 @@ async function readCurrentDatasetColumns(input, tableNames) {
1243
1303
  }
1244
1304
  const ducklakeTable = duckLakeMetadataTableName(input.options, "ducklake_table");
1245
1305
  const ducklakeColumn = duckLakeMetadataTableName(input.options, "ducklake_column");
1306
+ const ducklakeColumnTag = duckLakeMetadataTableName(input.options, "ducklake_column_tag");
1246
1307
  const rows = await input.runtime.query(`
1247
1308
  SELECT
1248
1309
  table_meta.table_name,
@@ -1251,11 +1312,17 @@ async function readCurrentDatasetColumns(input, tableNames) {
1251
1312
  column_meta.column_name,
1252
1313
  column_meta.column_type,
1253
1314
  CAST(column_meta.nulls_allowed AS BOOLEAN) AS nulls_allowed,
1254
- column_meta.parent_column
1315
+ column_meta.parent_column,
1316
+ column_tag."value" AS comment
1255
1317
  FROM ${ducklakeTable} table_meta
1256
1318
  JOIN ${ducklakeColumn} column_meta
1257
1319
  ON column_meta.table_id = table_meta.table_id
1258
1320
  AND column_meta.end_snapshot IS NULL
1321
+ LEFT JOIN ${ducklakeColumnTag} column_tag
1322
+ ON column_tag.table_id = column_meta.table_id
1323
+ AND column_tag.column_id = column_meta.column_id
1324
+ AND column_tag."key" = 'comment'
1325
+ AND column_tag.end_snapshot IS NULL
1259
1326
  WHERE table_meta.end_snapshot IS NULL
1260
1327
  AND table_meta.table_name IN (${quoteSqlStringList(tableNames)})
1261
1328
  ORDER BY table_meta.table_name, column_meta.column_order
@@ -1267,7 +1334,8 @@ async function readCurrentDatasetColumns(input, tableNames) {
1267
1334
  columnName: getString(row, "column_name"),
1268
1335
  columnType: getString(row, "column_type"),
1269
1336
  nullsAllowed: getBoolean(row, "nulls_allowed"),
1270
- parentColumnId: getOptionalBigIntLike(row, "parent_column")
1337
+ parentColumnId: getOptionalBigIntLike(row, "parent_column"),
1338
+ comment: getOptionalString(row, "comment")
1271
1339
  }));
1272
1340
  }
1273
1341
  async function readCurrentDatasetPartitions(input, tableNames) {
@@ -1352,11 +1420,23 @@ class DuckLakeDatasetCatalog {
1352
1420
  const tableName = encodeDatasetTableName(definition.id);
1353
1421
  const qualifiedName = qualifiedTableName(this.options, tableName);
1354
1422
  await this.connections.withExclusiveAttached(async (runtime) => {
1355
- await runtime.run(`CREATE TABLE ${qualifiedName} (${datasetSchemaToDuckDbColumnsSql(definition.schema)})`);
1356
- if (definition.description !== undefined) {
1357
- await runtime.run(`COMMENT ON TABLE ${qualifiedName} IS ${quoteSqlString(definition.description)}`);
1423
+ await runtime.run("BEGIN TRANSACTION");
1424
+ let committed = false;
1425
+ try {
1426
+ await runtime.run(`CREATE TABLE ${qualifiedName} (${datasetSchemaToDuckDbColumnsSql(definition.schema)})`);
1427
+ if (definition.description !== undefined) {
1428
+ await runtime.run(`COMMENT ON TABLE ${qualifiedName} IS ${quoteSqlString(definition.description)}`);
1429
+ }
1430
+ await this.applyPrimaryKey(runtime, qualifiedName, definition);
1431
+ await this.applyPartitionBy(runtime, qualifiedName, definition);
1432
+ await runtime.run("COMMIT");
1433
+ committed = true;
1434
+ } catch (error) {
1435
+ if (!committed) {
1436
+ await this.rollbackTransaction(runtime);
1437
+ }
1438
+ throw error;
1358
1439
  }
1359
- await this.applyPartitionBy(runtime, qualifiedName, definition);
1360
1440
  });
1361
1441
  this.connections.markLocalCatalogChanged();
1362
1442
  return structuredClone(definition);
@@ -1484,6 +1564,15 @@ ${details}`);
1484
1564
  throw new LakeStorageError11(`[SixbDuckLake] Dataset '${definition.id}' cannot apply partitionBy because DuckLake rejected ALTER TABLE SET PARTITIONED BY: ${message}`);
1485
1565
  }
1486
1566
  }
1567
+ async applyPrimaryKey(runtime, qualifiedName, definition) {
1568
+ if (definition.primaryKey === undefined) {
1569
+ return;
1570
+ }
1571
+ const columnNames = typeof definition.primaryKey === "string" ? [definition.primaryKey] : definition.primaryKey;
1572
+ for (const [ordinal, columnName] of columnNames.entries()) {
1573
+ await runtime.run(`COMMENT ON COLUMN ${qualifiedName}.${quoteIdentifier(columnName)} IS ${quoteSqlString(duckLakePrimaryKeyColumnComment(ordinal))}`);
1574
+ }
1575
+ }
1487
1576
  async setSchemaEvolutionCommitMetadata(runtime, datasetId, plan) {
1488
1577
  await runtime.run(`CALL ${quoteIdentifier(duckLakeAlias(this.options))}.set_commit_message(${quoteSqlString("Sixb")}, ${quoteSqlString(`evolve dataset ${datasetId} schema`)}, extra_info => ${quoteSqlString(JSON.stringify({
1489
1578
  sixb: {
@@ -1640,14 +1729,16 @@ function parseCommitMetadata(value) {
1640
1729
  const mode = parsed.sixb.mode;
1641
1730
  const commitId = parsed.sixb.commitId;
1642
1731
  const rowCount = parsed.sixb.rowCount;
1732
+ const validatedPrimaryKeyColumns = parsed.sixb.validatedPrimaryKeyColumns;
1643
1733
  return {
1644
1734
  kind: "datasetVersion",
1645
1735
  datasetId: parsed.sixb.datasetId,
1646
1736
  ...typeof commitId === "string" ? { commitId } : {},
1647
- ...mode === "snapshot" || mode === "append" || mode === "schema" ? { mode } : {},
1737
+ ...mode === "snapshot" || mode === "append" || mode === "merge" || mode === "schema" ? { mode } : {},
1648
1738
  ...isDatasetProducer(parsed.sixb.producer) ? { producer: parsed.sixb.producer } : {},
1649
1739
  ...isDatasetVersionRefs(parsed.sixb.inputs) ? { inputs: parsed.sixb.inputs } : {},
1650
1740
  ...typeof commitId === "string" && isRowCount(rowCount) ? { rowCount } : {},
1741
+ ...typeof commitId === "string" && isValidatedPrimaryKeyColumns(validatedPrimaryKeyColumns) ? { validatedPrimaryKeyColumns } : {},
1651
1742
  ...isSchemaChangeMetadata(parsed.sixb.schemaChange) ? { schemaChange: parsed.sixb.schemaChange } : {}
1652
1743
  };
1653
1744
  }
@@ -1688,6 +1779,9 @@ function optionalString(value) {
1688
1779
  function isRowCount(value) {
1689
1780
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
1690
1781
  }
1782
+ function isValidatedPrimaryKeyColumns(value) {
1783
+ return Array.isArray(value) && value.length > 0 && value.every((column) => typeof column === "string" && column.trim().length > 0) && new Set(value).size === value.length;
1784
+ }
1691
1785
 
1692
1786
  // src/internal/ducklake-row-reader.ts
1693
1787
  class DuckLakeRowReader {
@@ -2002,7 +2096,8 @@ class DuckLakeSnapshotReader {
2002
2096
  return {
2003
2097
  datasetId: tableRef.datasetId,
2004
2098
  versionId: toVersionId(row.snapshotId),
2005
- ...row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}
2099
+ ...row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {},
2100
+ ...row.metadata?.validatedPrimaryKeyColumns !== undefined ? { validatedPrimaryKeyColumns: row.metadata.validatedPrimaryKeyColumns } : {}
2006
2101
  };
2007
2102
  }
2008
2103
  async getVersionForSnapshot(runtime, dataset, snapshotId) {
@@ -2062,7 +2157,7 @@ class DuckLakeSnapshotReader {
2062
2157
  if (!row) {
2063
2158
  return null;
2064
2159
  }
2065
- if (!options.includeParent || row.mode !== "append" && row.mode !== "schema") {
2160
+ if (!options.includeParent || row.mode !== "append" && row.mode !== "merge" && row.mode !== "schema") {
2066
2161
  return row;
2067
2162
  }
2068
2163
  const [parent] = await this.collectVisibleSnapshotRows(runtime, {
@@ -2250,7 +2345,7 @@ class DuckLakeSnapshotReader {
2250
2345
  }
2251
2346
  withParentSnapshotIds(snapshots) {
2252
2347
  return snapshots.map((snapshot, index) => {
2253
- const parentSnapshotId = snapshot.mode === "append" || snapshot.mode === "schema" ? snapshots[index + 1]?.snapshotId : undefined;
2348
+ const parentSnapshotId = snapshot.mode === "append" || snapshot.mode === "merge" || snapshot.mode === "schema" ? snapshots[index + 1]?.snapshotId : undefined;
2254
2349
  return parentSnapshotId === undefined ? snapshot : { ...snapshot, parentSnapshotId };
2255
2350
  });
2256
2351
  }
@@ -2271,7 +2366,10 @@ import {
2271
2366
  } from "@sixb/core/lake-storage";
2272
2367
 
2273
2368
  // src/internal/dataset-row-commit.ts
2274
- import { LakeStorageError as LakeStorageError16 } from "@sixb/core/lake-storage";
2369
+ import {
2370
+ getDatasetPrimaryKeyColumns,
2371
+ LakeStorageError as LakeStorageError16
2372
+ } from "@sixb/core/lake-storage";
2275
2373
  function assertDatasetWriteMode(mode, operation) {
2276
2374
  if (mode !== "snapshot" && mode !== "append") {
2277
2375
  throw new LakeStorageError16(`[SixbDuckLake] Invalid ${operation} mode '${String(mode)}'. Expected 'snapshot' or 'append'.`);
@@ -2281,6 +2379,30 @@ async function applyDatasetRowsFromRelation(input) {
2281
2379
  const tableName = encodeDatasetTableName(input.dataset.id);
2282
2380
  const table = qualifiedTableName(input.options, tableName);
2283
2381
  const columnsSql = datasetSchemaColumnNamesSql(input.dataset.schema);
2382
+ const primaryKeyColumns = getDatasetPrimaryKeyColumns(input.dataset);
2383
+ if (primaryKeyColumns !== null) {
2384
+ await inspectUniqueKeyedRelation({
2385
+ runtime: input.runtime,
2386
+ dataset: input.dataset,
2387
+ relationSql: input.sourceRelationSql,
2388
+ context: `${input.mode} source`
2389
+ });
2390
+ if (input.mode === "append") {
2391
+ await inspectUniqueKeyedRelation({
2392
+ runtime: input.runtime,
2393
+ dataset: input.dataset,
2394
+ relationSql: table,
2395
+ context: "current baseline"
2396
+ });
2397
+ await assertNoPrimaryKeyOverlap({
2398
+ runtime: input.runtime,
2399
+ dataset: input.dataset,
2400
+ leftRelationSql: input.sourceRelationSql,
2401
+ rightRelationSql: table,
2402
+ context: "append"
2403
+ });
2404
+ }
2405
+ }
2284
2406
  if (input.mode === "snapshot") {
2285
2407
  const source = await fingerprintRelation(input.runtime, input.sourceRelationSql, columnsSql);
2286
2408
  const target = await fingerprintRelation(input.runtime, table, columnsSql);
@@ -2307,6 +2429,44 @@ async function applyDatasetRowsFromRelation(input) {
2307
2429
  resultingRowCount: input.previousRowCount === undefined ? { kind: "unknown" } : { kind: "exact", value: input.previousRowCount + sourceRowCount }
2308
2430
  };
2309
2431
  }
2432
+ async function inspectUniqueKeyedRelation(input) {
2433
+ const primaryKeyColumns = getDatasetPrimaryKeyColumns(input.dataset);
2434
+ if (primaryKeyColumns === null) {
2435
+ throw new LakeStorageError16(`[SixbDuckLake] Dataset '${input.dataset.id}' must define a primaryKey before rows can be keyed.`);
2436
+ }
2437
+ const keysSql = primaryKeyColumns.map((column) => quoteIdentifier(column)).join(", ");
2438
+ const [row] = await input.runtime.query(`
2439
+ SELECT
2440
+ count(*) AS row_count,
2441
+ coalesce(max(key_count), 0) AS max_key_count
2442
+ FROM (
2443
+ SELECT count(*) OVER (PARTITION BY ${keysSql}) AS key_count
2444
+ FROM ${input.relationSql}
2445
+ ) keyed_rows
2446
+ `);
2447
+ const rowCount = row === undefined ? 0 : Number(getBigIntLike(row, "row_count"));
2448
+ const maxKeyCount = row === undefined ? 0 : Number(getBigIntLike(row, "max_key_count"));
2449
+ if (maxKeyCount > 1) {
2450
+ throw new LakeStorageError16(`[SixbDuckLake] Dataset '${input.dataset.id}' ${input.context} contains duplicate primary key.`);
2451
+ }
2452
+ return rowCount;
2453
+ }
2454
+ async function assertNoPrimaryKeyOverlap(input) {
2455
+ const primaryKeyColumns = getDatasetPrimaryKeyColumns(input.dataset);
2456
+ if (primaryKeyColumns === null) {
2457
+ return;
2458
+ }
2459
+ const matchSql = primaryKeyColumns.map((column) => `left_rows.${quoteIdentifier(column)} = right_rows.${quoteIdentifier(column)}`).join(" AND ");
2460
+ const [collision] = await input.runtime.query(`
2461
+ SELECT 1
2462
+ FROM ${input.leftRelationSql} left_rows
2463
+ JOIN ${input.rightRelationSql} right_rows ON ${matchSql}
2464
+ LIMIT 1
2465
+ `);
2466
+ if (collision !== undefined) {
2467
+ throw new LakeStorageError16(`[SixbDuckLake] Dataset '${input.dataset.id}' ${input.context} contains duplicate primary key.`);
2468
+ }
2469
+ }
2310
2470
  async function fingerprintRelation(runtime, relationSql, columnsSql) {
2311
2471
  const [row] = await runtime.query(`
2312
2472
  SELECT
@@ -2604,13 +2764,183 @@ function throwResultSchemaMismatch(datasetId, detail) {
2604
2764
  }
2605
2765
 
2606
2766
  // src/internal/ducklake-write-coordinator.ts
2607
- import { randomUUID as randomUUID5 } from "crypto";
2608
- import { LakeStorageError as LakeStorageError20 } from "@sixb/core/lake-storage";
2767
+ import { randomUUID as randomUUID6 } from "crypto";
2768
+ import { getDatasetPrimaryKeyColumns as getDatasetPrimaryKeyColumns4, LakeStorageError as LakeStorageError22 } from "@sixb/core/lake-storage";
2609
2769
 
2610
- // src/internal/ducklake-write-session.ts
2770
+ // src/internal/dataset-row-merge.ts
2771
+ import { getDatasetPrimaryKeyColumns as getDatasetPrimaryKeyColumns2, LakeStorageError as LakeStorageError19 } from "@sixb/core/lake-storage";
2772
+ async function applyDatasetMergeFromRelation(input) {
2773
+ const primaryKeyColumns = getDatasetPrimaryKeyColumns2(input.dataset);
2774
+ if (primaryKeyColumns === null) {
2775
+ throw new LakeStorageError19(`[SixbDuckLake] Dataset '${input.dataset.id}' must define a primaryKey before it can be merged.`);
2776
+ }
2777
+ const stagingTable = quoteIdentifier(input.stagingTableName);
2778
+ const targetTable = qualifiedTableName(input.options, encodeDatasetTableName(input.dataset.id));
2779
+ const sourceRowCount = await countRows2(input.runtime, stagingTable);
2780
+ const previousRowCount = await currentBaselineRowCount(input, targetTable, primaryKeyColumns);
2781
+ return withMaterializedFinalChanges(input, primaryKeyColumns, async (finalChanges) => {
2782
+ const effects = await countMergeEffects(input, targetTable, finalChanges, primaryKeyColumns);
2783
+ const effectiveChangeCount = effects.inserts + effects.updates + effects.deletes;
2784
+ if (effectiveChangeCount === 0) {
2785
+ return {
2786
+ dataChangeExpected: false,
2787
+ sourceRowCount,
2788
+ resultingRowCount: { kind: "exact", value: previousRowCount }
2789
+ };
2790
+ }
2791
+ const keyMatchSql = primaryKeyMatchSql(primaryKeyColumns, "target", "source");
2792
+ const rowDifferenceSql = datasetRowDifferenceSql(input.dataset, "target", "source");
2793
+ const kindColumn = `source.${quoteIdentifier(input.kindColumnName)}`;
2794
+ await input.runtime.run(`
2795
+ DELETE FROM ${targetTable} AS target
2796
+ USING ${finalChanges} AS source
2797
+ WHERE ${keyMatchSql}
2798
+ AND (
2799
+ ${kindColumn} = ${quoteSqlString("delete")}
2800
+ OR (
2801
+ ${kindColumn} = ${quoteSqlString("upsert")}
2802
+ AND (${rowDifferenceSql})
2803
+ )
2804
+ )
2805
+ `);
2806
+ const columnsSql = datasetSchemaColumnNamesSql(input.dataset.schema);
2807
+ const selectedColumnsSql = input.dataset.schema.columns.map((column) => `source.${quoteIdentifier(column.name)}`).join(", ");
2808
+ const firstSequenceColumnName = firstSequenceName(input.sequenceColumnName);
2809
+ await input.runtime.run(`
2810
+ INSERT INTO ${targetTable} (${columnsSql})
2811
+ SELECT ${selectedColumnsSql}
2812
+ FROM ${finalChanges} AS source
2813
+ WHERE ${kindColumn} = ${quoteSqlString("upsert")}
2814
+ AND NOT EXISTS (
2815
+ SELECT 1
2816
+ FROM ${targetTable} AS target
2817
+ WHERE ${keyMatchSql}
2818
+ )
2819
+ ORDER BY source.${quoteIdentifier(firstSequenceColumnName)}
2820
+ `);
2821
+ return {
2822
+ dataChangeExpected: true,
2823
+ sourceRowCount,
2824
+ resultingRowCount: {
2825
+ kind: "exact",
2826
+ value: previousRowCount + effects.inserts - effects.deletes
2827
+ }
2828
+ };
2829
+ });
2830
+ }
2831
+ async function currentBaselineRowCount(input, targetTable, primaryKeyColumns) {
2832
+ if (sameColumns(input.validatedPrimaryKeyColumns, primaryKeyColumns)) {
2833
+ return input.previousRowCount ?? countRows2(input.runtime, targetTable);
2834
+ }
2835
+ return inspectUniqueKeyedRelation({
2836
+ runtime: input.runtime,
2837
+ dataset: input.dataset,
2838
+ relationSql: targetTable,
2839
+ context: "current baseline"
2840
+ });
2841
+ }
2842
+ async function withMaterializedFinalChanges(input, primaryKeyColumns, run) {
2843
+ const table = quoteIdentifier(`${input.stagingTableName}_final`);
2844
+ await input.runtime.run(`
2845
+ CREATE TEMP TABLE ${table} AS
2846
+ ${finalChangesSelectSql(input, primaryKeyColumns)}
2847
+ `);
2848
+ let outcome;
2849
+ try {
2850
+ outcome = { kind: "success", value: await run(table) };
2851
+ } catch (error) {
2852
+ outcome = { kind: "error", error };
2853
+ }
2854
+ try {
2855
+ await input.runtime.run(`DROP TABLE IF EXISTS ${table}`);
2856
+ } catch (cleanupError) {
2857
+ if (outcome.kind === "success") {
2858
+ throw cleanupError;
2859
+ }
2860
+ }
2861
+ if (outcome.kind === "error") {
2862
+ throw outcome.error;
2863
+ }
2864
+ return outcome.value;
2865
+ }
2866
+ function finalChangesSelectSql(input, primaryKeyColumns) {
2867
+ const sequenceColumn = quoteIdentifier(input.sequenceColumnName);
2868
+ const keyColumnsSql = primaryKeyColumns.map((column) => quoteIdentifier(column)).join(", ");
2869
+ const rankColumn = quoteIdentifier(rankName(input.sequenceColumnName));
2870
+ const firstSequenceColumn = quoteIdentifier(firstSequenceName(input.sequenceColumnName));
2871
+ return `
2872
+ SELECT * EXCLUDE (${rankColumn})
2873
+ FROM (
2874
+ SELECT
2875
+ *,
2876
+ min(${sequenceColumn}) OVER (PARTITION BY ${keyColumnsSql}) AS ${firstSequenceColumn},
2877
+ row_number() OVER (
2878
+ PARTITION BY ${keyColumnsSql}
2879
+ ORDER BY ${sequenceColumn} DESC
2880
+ ) AS ${rankColumn}
2881
+ FROM ${quoteIdentifier(input.stagingTableName)}
2882
+ ) ranked_changes
2883
+ WHERE ${rankColumn} = 1
2884
+ `;
2885
+ }
2886
+ async function countMergeEffects(input, targetTable, finalChanges, primaryKeyColumns) {
2887
+ const matchColumn = primaryKeyColumns[0];
2888
+ if (matchColumn === undefined) {
2889
+ throw new LakeStorageError19(`[SixbDuckLake] Dataset '${input.dataset.id}' primaryKey must contain at least one column.`);
2890
+ }
2891
+ const keyMatchSql = primaryKeyMatchSql(primaryKeyColumns, "target", "source");
2892
+ const targetMatch = `target.${quoteIdentifier(matchColumn)} IS NOT NULL`;
2893
+ const kindColumn = `source.${quoteIdentifier(input.kindColumnName)}`;
2894
+ const rowDifferenceSql = datasetRowDifferenceSql(input.dataset, "target", "source");
2895
+ const [row] = await input.runtime.query(`
2896
+ SELECT
2897
+ count(*) FILTER (
2898
+ WHERE ${kindColumn} = ${quoteSqlString("upsert")} AND NOT (${targetMatch})
2899
+ ) AS insert_count,
2900
+ count(*) FILTER (
2901
+ WHERE ${kindColumn} = ${quoteSqlString("upsert")}
2902
+ AND ${targetMatch}
2903
+ AND (${rowDifferenceSql})
2904
+ ) AS update_count,
2905
+ count(*) FILTER (
2906
+ WHERE ${kindColumn} = ${quoteSqlString("delete")} AND ${targetMatch}
2907
+ ) AS delete_count
2908
+ FROM ${finalChanges} AS source
2909
+ LEFT JOIN ${targetTable} AS target ON ${keyMatchSql}
2910
+ `);
2911
+ return {
2912
+ inserts: row === undefined ? 0 : Number(getBigIntLike(row, "insert_count")),
2913
+ updates: row === undefined ? 0 : Number(getBigIntLike(row, "update_count")),
2914
+ deletes: row === undefined ? 0 : Number(getBigIntLike(row, "delete_count"))
2915
+ };
2916
+ }
2917
+ function primaryKeyMatchSql(primaryKeyColumns, leftAlias, rightAlias) {
2918
+ return primaryKeyColumns.map((column) => `${leftAlias}.${quoteIdentifier(column)} = ${rightAlias}.${quoteIdentifier(column)}`).join(" AND ");
2919
+ }
2920
+ function datasetRowDifferenceSql(dataset, leftAlias, rightAlias) {
2921
+ return dataset.schema.columns.map((column) => `${leftAlias}.${quoteIdentifier(column.name)} IS DISTINCT FROM ${rightAlias}.${quoteIdentifier(column.name)}`).join(" OR ");
2922
+ }
2923
+ async function countRows2(runtime, relationSql) {
2924
+ const [row] = await runtime.query(`SELECT count(*) AS row_count FROM ${relationSql}`);
2925
+ return row === undefined ? 0 : Number(getBigIntLike(row, "row_count"));
2926
+ }
2927
+ function rankName(sequenceColumnName) {
2928
+ return `${sequenceColumnName}_rank`;
2929
+ }
2930
+ function firstSequenceName(sequenceColumnName) {
2931
+ return `${sequenceColumnName}_first`;
2932
+ }
2933
+ function sameColumns(left, right) {
2934
+ return left !== undefined && left.length === right.length && left.every((value, index) => value === right[index]);
2935
+ }
2936
+
2937
+ // src/internal/ducklake-merge-session.ts
2611
2938
  import { randomUUID as randomUUID4 } from "crypto";
2612
- import { getDatasetRowValidationError } from "@sixb/core";
2613
- import { LakeStorageError as LakeStorageError19 } from "@sixb/core/lake-storage";
2939
+ import {
2940
+ cloneDatasetMergeChange,
2941
+ getDatasetMergeChangeValidationError,
2942
+ LakeStorageError as LakeStorageError20
2943
+ } from "@sixb/core/lake-storage";
2614
2944
 
2615
2945
  // src/internal/row-appender.ts
2616
2946
  function appendDatasetRow(appender, schema, row) {
@@ -2665,10 +2995,134 @@ function fileRefToDuckDbStruct(fileRef) {
2665
2995
  };
2666
2996
  }
2667
2997
 
2998
+ // src/internal/ducklake-merge-session.ts
2999
+ var MERGE_CHANGE_BATCH_SIZE = 1000;
3000
+ async function createDuckLakeMergeSession(input) {
3001
+ const suffix = randomUUID4().replaceAll("-", "");
3002
+ const stagingTableName = `sixb_merge_${suffix}`;
3003
+ const sequenceColumnName = `sixb_merge_sequence_${suffix}`;
3004
+ const kindColumnName = `sixb_merge_kind_${suffix}`;
3005
+ await input.runtime.run(`
3006
+ CREATE TEMP TABLE ${quoteIdentifier(stagingTableName)} (
3007
+ ${quoteIdentifier(sequenceColumnName)} UBIGINT NOT NULL,
3008
+ ${quoteIdentifier(kindColumnName)} VARCHAR NOT NULL,
3009
+ ${datasetSchemaToDuckDbNullableColumnsSql(input.merge.dataset.schema)}
3010
+ )
3011
+ `);
3012
+ return new DuckLakeMergeSession(input.commitMerge, input.runtime, input.merge, input.baseVersionId, stagingTableName, sequenceColumnName, kindColumnName);
3013
+ }
3014
+
3015
+ class DuckLakeMergeSession {
3016
+ commitMerge;
3017
+ runtime;
3018
+ input;
3019
+ baseVersionId;
3020
+ stagingTableName;
3021
+ sequenceColumnName;
3022
+ kindColumnName;
3023
+ closed = false;
3024
+ cleanedUp = false;
3025
+ nextSequence = 0n;
3026
+ changesWritten = 0;
3027
+ constructor(commitMerge, runtime, input, baseVersionId, stagingTableName, sequenceColumnName, kindColumnName) {
3028
+ this.commitMerge = commitMerge;
3029
+ this.runtime = runtime;
3030
+ this.input = input;
3031
+ this.baseVersionId = baseVersionId;
3032
+ this.stagingTableName = stagingTableName;
3033
+ this.sequenceColumnName = sequenceColumnName;
3034
+ this.kindColumnName = kindColumnName;
3035
+ }
3036
+ async writeChanges(changes) {
3037
+ this.assertOpen();
3038
+ let batch = [];
3039
+ for await (const change of changes) {
3040
+ const validationError = getDatasetMergeChangeValidationError(change, this.input.dataset);
3041
+ if (validationError) {
3042
+ throw new LakeStorageError20(`[SixbDuckLake] ${validationError}`);
3043
+ }
3044
+ batch.push({
3045
+ sequence: this.nextSequence,
3046
+ change: cloneDatasetMergeChange(change)
3047
+ });
3048
+ this.nextSequence += 1n;
3049
+ if (batch.length >= MERGE_CHANGE_BATCH_SIZE) {
3050
+ await this.appendBatchToStagingTable(batch);
3051
+ batch = [];
3052
+ }
3053
+ }
3054
+ if (batch.length > 0) {
3055
+ await this.appendBatchToStagingTable(batch);
3056
+ }
3057
+ }
3058
+ async appendBatchToStagingTable(batch) {
3059
+ await this.runtime.withAppender(this.stagingTableName, (appender) => {
3060
+ for (const staged of batch) {
3061
+ appender.appendBigInt(staged.sequence);
3062
+ appender.appendVarchar(staged.change.kind);
3063
+ appendDatasetRow(appender, this.input.dataset.schema, staged.change.kind === "upsert" ? staged.change.row : staged.change.key);
3064
+ }
3065
+ });
3066
+ this.changesWritten += batch.length;
3067
+ }
3068
+ async commit(input) {
3069
+ this.assertOpen();
3070
+ this.closed = true;
3071
+ let committed = false;
3072
+ try {
3073
+ const result = await this.commitMerge({
3074
+ merge: this.input,
3075
+ baseVersionId: this.baseVersionId,
3076
+ commit: input,
3077
+ stagingTableName: this.stagingTableName,
3078
+ sequenceColumnName: this.sequenceColumnName,
3079
+ kindColumnName: this.kindColumnName,
3080
+ changesWritten: this.changesWritten
3081
+ });
3082
+ committed = true;
3083
+ await this.cleanup();
3084
+ return result;
3085
+ } catch (error) {
3086
+ if (!committed) {
3087
+ await this.cleanup();
3088
+ }
3089
+ throw error;
3090
+ }
3091
+ }
3092
+ async abort() {
3093
+ if (this.closed) {
3094
+ return;
3095
+ }
3096
+ this.closed = true;
3097
+ await this.cleanup();
3098
+ }
3099
+ assertOpen() {
3100
+ if (this.closed) {
3101
+ throw new LakeStorageError20("[SixbDuckLake] Merge session is already closed.");
3102
+ }
3103
+ }
3104
+ async cleanup() {
3105
+ if (this.cleanedUp) {
3106
+ return;
3107
+ }
3108
+ this.cleanedUp = true;
3109
+ try {
3110
+ await this.runtime.run(`DROP TABLE IF EXISTS ${quoteIdentifier(this.stagingTableName)}`);
3111
+ } catch {}
3112
+ }
3113
+ }
3114
+
2668
3115
  // src/internal/ducklake-write-session.ts
3116
+ import { randomUUID as randomUUID5 } from "crypto";
3117
+ import { getDatasetRowValidationError } from "@sixb/core";
3118
+ import {
3119
+ encodeDatasetPrimaryKey,
3120
+ getDatasetPrimaryKeyColumns as getDatasetPrimaryKeyColumns3,
3121
+ LakeStorageError as LakeStorageError21
3122
+ } from "@sixb/core/lake-storage";
2669
3123
  var WRITE_ROW_BATCH_SIZE = 1000;
2670
3124
  async function createDuckLakeWriteSession(input) {
2671
- const stagingTableName = `sixb_write_${randomUUID4().replaceAll("-", "")}`;
3125
+ const stagingTableName = `sixb_write_${randomUUID5().replaceAll("-", "")}`;
2672
3126
  await input.runtime.run(`CREATE TEMP TABLE ${quoteIdentifier(stagingTableName)} (${datasetSchemaToDuckDbColumnsSql(input.write.dataset.schema)})`);
2673
3127
  return new DuckLakeWriteSession(input.commitWrite, input.runtime, input.write, stagingTableName);
2674
3128
  }
@@ -2681,6 +3135,7 @@ class DuckLakeWriteSession {
2681
3135
  closed = false;
2682
3136
  cleanedUp = false;
2683
3137
  rowsWritten = 0;
3138
+ stagedPrimaryKeys = new Set;
2684
3139
  constructor(commitWrite, runtime, input, stagingTableName) {
2685
3140
  this.commitWrite = commitWrite;
2686
3141
  this.runtime = runtime;
@@ -2690,28 +3145,41 @@ class DuckLakeWriteSession {
2690
3145
  async writeRows(rows) {
2691
3146
  this.assertOpen();
2692
3147
  let batch = [];
3148
+ let batchPrimaryKeys = new Set;
2693
3149
  for await (const row of rows) {
2694
3150
  const validationError = getDatasetRowValidationError(row, this.input.dataset);
2695
3151
  if (validationError) {
2696
- throw new LakeStorageError19(`[SixbDuckLake] ${validationError}`);
3152
+ throw new LakeStorageError21(`[SixbDuckLake] ${validationError}`);
2697
3153
  }
2698
- batch.push(structuredClone(row));
3154
+ const clonedRow = structuredClone(row);
3155
+ if (getDatasetPrimaryKeyColumns3(this.input.dataset) !== null) {
3156
+ const primaryKey = encodeDatasetPrimaryKey(this.input.dataset, clonedRow);
3157
+ if (this.stagedPrimaryKeys.has(primaryKey) || batchPrimaryKeys.has(primaryKey)) {
3158
+ throw new LakeStorageError21(`[SixbDuckLake] Dataset '${this.input.dataset.id}' ${this.input.mode ?? "snapshot"} source contains duplicate primary key.`);
3159
+ }
3160
+ batchPrimaryKeys.add(primaryKey);
3161
+ }
3162
+ batch.push(clonedRow);
2699
3163
  if (batch.length >= WRITE_ROW_BATCH_SIZE) {
2700
- await this.appendBatchToStagingTable(batch);
3164
+ await this.appendBatchToStagingTable(batch, batchPrimaryKeys);
2701
3165
  batch = [];
3166
+ batchPrimaryKeys = new Set;
2702
3167
  }
2703
3168
  }
2704
3169
  if (batch.length > 0) {
2705
- await this.appendBatchToStagingTable(batch);
3170
+ await this.appendBatchToStagingTable(batch, batchPrimaryKeys);
2706
3171
  }
2707
3172
  }
2708
- async appendBatchToStagingTable(batch) {
3173
+ async appendBatchToStagingTable(batch, primaryKeys) {
2709
3174
  await this.runtime.withAppender(this.stagingTableName, (appender) => {
2710
3175
  for (const row of batch) {
2711
3176
  appendDatasetRow(appender, this.input.dataset.schema, row);
2712
3177
  }
2713
3178
  });
2714
3179
  this.rowsWritten += batch.length;
3180
+ for (const primaryKey of primaryKeys) {
3181
+ this.stagedPrimaryKeys.add(primaryKey);
3182
+ }
2715
3183
  }
2716
3184
  async commit(input) {
2717
3185
  this.assertOpen();
@@ -2743,7 +3211,7 @@ class DuckLakeWriteSession {
2743
3211
  }
2744
3212
  assertOpen() {
2745
3213
  if (this.closed) {
2746
- throw new LakeStorageError19("[SixbDuckLake] Write session is already closed.");
3214
+ throw new LakeStorageError21("[SixbDuckLake] Write session is already closed.");
2747
3215
  }
2748
3216
  }
2749
3217
  async cleanup() {
@@ -2776,7 +3244,7 @@ class DuckLakeWriteCoordinator {
2776
3244
  this.connections.assertOpen();
2777
3245
  const definition = await this.datasets.getDataset(input.dataset.id);
2778
3246
  if (!definition) {
2779
- throw new LakeStorageError20(`[SixbDuckLake] Unknown dataset '${input.dataset.id}'.`);
3247
+ throw new LakeStorageError22(`[SixbDuckLake] Unknown dataset '${input.dataset.id}'.`);
2780
3248
  }
2781
3249
  this.datasets.assertSchema(definition);
2782
3250
  const mode = input.mode ?? "snapshot";
@@ -2792,10 +3260,32 @@ class DuckLakeWriteCoordinator {
2792
3260
  }
2793
3261
  });
2794
3262
  }
3263
+ async beginMerge(input) {
3264
+ this.connections.assertOpen();
3265
+ const definition = await this.datasets.getDataset(input.dataset.id);
3266
+ if (!definition) {
3267
+ throw new LakeStorageError22(`[SixbDuckLake] Unknown dataset '${input.dataset.id}'.`);
3268
+ }
3269
+ if (getDatasetPrimaryKeyColumns4(definition) === null) {
3270
+ throw new LakeStorageError22(`[SixbDuckLake] Dataset '${definition.id}' must define a primaryKey before it can be merged.`);
3271
+ }
3272
+ this.datasets.assertSchema(definition);
3273
+ const latestVersion = await this.connections.withAttachedRuntime((runtime2) => this.snapshots.getLatestVersionForDefinition(runtime2, definition));
3274
+ if (input.expectedLatestVersionId !== undefined && latestVersion?.versionId !== input.expectedLatestVersionId) {
3275
+ throw new LakeStorageError22(`[SixbDuckLake] Optimistic merge start failed for dataset '${definition.id}': expected latest version '${input.expectedLatestVersionId}', found '${latestVersion?.versionId ?? "none"}'.`);
3276
+ }
3277
+ const runtime = await this.connections.stagingRuntime();
3278
+ return createDuckLakeMergeSession({
3279
+ commitMerge: (options) => this.commitMerge(options),
3280
+ runtime,
3281
+ merge: { ...input, dataset: definition },
3282
+ baseVersionId: latestVersion?.versionId ?? null
3283
+ });
3284
+ }
2795
3285
  async commitWrite(input) {
2796
3286
  const definition = await this.datasets.getDataset(input.write.dataset.id);
2797
3287
  if (!definition) {
2798
- throw new LakeStorageError20(`[SixbDuckLake] Unknown dataset '${input.write.dataset.id}'.`);
3288
+ throw new LakeStorageError22(`[SixbDuckLake] Unknown dataset '${input.write.dataset.id}'.`);
2799
3289
  }
2800
3290
  const mode = input.write.mode ?? "snapshot";
2801
3291
  assertDatasetWriteMode(mode, "write");
@@ -2813,6 +3303,42 @@ class DuckLakeWriteCoordinator {
2813
3303
  })
2814
3304
  });
2815
3305
  }
3306
+ async commitMerge(input) {
3307
+ const definition = await this.datasets.getDataset(input.merge.dataset.id);
3308
+ if (!definition) {
3309
+ throw new LakeStorageError22(`[SixbDuckLake] Unknown dataset '${input.merge.dataset.id}'.`);
3310
+ }
3311
+ if (getDatasetPrimaryKeyColumns4(definition) === null) {
3312
+ throw new LakeStorageError22(`[SixbDuckLake] Dataset '${definition.id}' must define a primaryKey before it can be merged.`);
3313
+ }
3314
+ return this.withCommitRuntime((runtime) => this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
3315
+ dataset: definition,
3316
+ mode: "merge",
3317
+ expectedLatestVersionId: input.baseVersionId,
3318
+ allowInitialNoOp: true,
3319
+ disableRetries: true,
3320
+ optimisticOperation: "merge commit",
3321
+ commitMessage: input.commit?.commitMessage ?? `merge dataset ${definition.id}`,
3322
+ producer: input.merge.producer,
3323
+ inputs: input.merge.inputs,
3324
+ applyChanges: async (commitRuntime, context) => {
3325
+ const result = await applyDatasetMergeFromRelation({
3326
+ options: this.options,
3327
+ runtime: commitRuntime,
3328
+ dataset: definition,
3329
+ stagingTableName: input.stagingTableName,
3330
+ sequenceColumnName: input.sequenceColumnName,
3331
+ kindColumnName: input.kindColumnName,
3332
+ previousRowCount: context.previousRowCount,
3333
+ validatedPrimaryKeyColumns: context.validatedPrimaryKeyColumns
3334
+ });
3335
+ if (result.sourceRowCount !== input.changesWritten) {
3336
+ throw new LakeStorageError22(`[SixbDuckLake] Staged merge for dataset '${definition.id}' accepted ${input.changesWritten} change(s), but DuckLake saw ${result.sourceRowCount} source change(s) at commit time.`);
3337
+ }
3338
+ return result;
3339
+ }
3340
+ }));
3341
+ }
2816
3342
  async commitDatasetVersion(input) {
2817
3343
  return this.withCommitRuntime((runtime) => this.commitDatasetVersionOnExclusiveRuntime(runtime, input));
2818
3344
  }
@@ -2822,11 +3348,21 @@ class DuckLakeWriteCoordinator {
2822
3348
  });
2823
3349
  }
2824
3350
  async commitDatasetVersionOnExclusiveRuntime(runtime, input) {
3351
+ const result = await this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
3352
+ ...input,
3353
+ disableRetries: input.mode === "append" && getDatasetPrimaryKeyColumns4(input.dataset) !== null
3354
+ });
3355
+ if (result.version === null) {
3356
+ throw new LakeStorageError22(`[SixbDuckLake] No DuckLake changes were committed for dataset '${input.dataset.id}', and no previous version exists.`);
3357
+ }
3358
+ return { ...result.version, outcome: result.outcome };
3359
+ }
3360
+ async commitVersionOutcomeOnExclusiveRuntime(runtime, input) {
2825
3361
  const runtimeInput = { ...input, runtime };
2826
3362
  return this.withGuardedRetrySetting(runtimeInput, () => this.commitDatasetVersionUnlocked(runtimeInput));
2827
3363
  }
2828
3364
  async withGuardedRetrySetting(input, run) {
2829
- if (input.expectedLatestVersionId === undefined) {
3365
+ if (input.expectedLatestVersionId === undefined && !input.disableRetries) {
2830
3366
  return run();
2831
3367
  }
2832
3368
  await input.runtime.run("SET ducklake_max_retry_count = 0");
@@ -2856,13 +3392,15 @@ class DuckLakeWriteCoordinator {
2856
3392
  this.assertExpectedLatestVersion({
2857
3393
  datasetId: input.dataset.id,
2858
3394
  expectedLatestVersionId: input.expectedLatestVersionId,
2859
- actualLatestVersionId: latestVersion?.versionId ?? null
3395
+ actualLatestVersionId: latestVersion?.versionId ?? null,
3396
+ operation: input.optimisticOperation ?? "commit"
2860
3397
  });
2861
3398
  }
2862
3399
  const changeResult = await input.applyChanges(input.runtime, {
2863
- previousRowCount: latestVersion?.rowCount
3400
+ previousRowCount: latestVersion?.rowCount,
3401
+ validatedPrimaryKeyColumns: latestVersion?.validatedPrimaryKeyColumns
2864
3402
  });
2865
- const commitId = randomUUID5();
3403
+ const commitId = randomUUID6();
2866
3404
  await this.setCommitMetadata(input, commitId, commitRowCount(input, changeResult, latestVersion));
2867
3405
  await input.runtime.run("COMMIT");
2868
3406
  committed = true;
@@ -2890,20 +3428,29 @@ class DuckLakeWriteCoordinator {
2890
3428
  if (ownSnapshotId !== null) {
2891
3429
  const version = await this.snapshots.getVersionForSnapshot(input.runtime, input.dataset, ownSnapshotId);
2892
3430
  if (version) {
2893
- return { ...version, outcome: "created" };
3431
+ return { outcome: "created", version };
2894
3432
  }
2895
- throw new LakeStorageError20(`[SixbDuckLake] DuckLake committed snapshot '${ownSnapshotId}' for dataset '${input.dataset.id}', but Sixb could not hydrate it as a dataset version.`);
3433
+ throw new LakeStorageError22(`[SixbDuckLake] DuckLake committed snapshot '${ownSnapshotId}' for dataset '${input.dataset.id}', but Sixb could not hydrate it as a dataset version.`);
2896
3434
  }
2897
3435
  if (!input.changeResult.dataChangeExpected) {
3436
+ const version = await this.versionForNoOpCommit(input.runtime, input.dataset, input.allowInitialNoOp ?? false);
3437
+ if (input.expectedLatestVersionId !== undefined) {
3438
+ this.assertExpectedLatestVersion({
3439
+ datasetId: input.dataset.id,
3440
+ expectedLatestVersionId: input.expectedLatestVersionId,
3441
+ actualLatestVersionId: version?.versionId ?? null,
3442
+ operation: input.optimisticOperation ?? "commit"
3443
+ });
3444
+ }
2898
3445
  return {
2899
- ...await this.versionForNoOpCommit(input.runtime, input.dataset),
2900
- outcome: "unchanged"
3446
+ outcome: "unchanged",
3447
+ version
2901
3448
  };
2902
3449
  }
2903
3450
  if (input.committedWriteSnapshotId === input.previousWriteSnapshotId) {
2904
- throw new LakeStorageError20(`[SixbDuckLake] DuckLake commit for dataset '${input.dataset.id}' completed without producing a snapshot for this non-empty write.`);
3451
+ throw new LakeStorageError22(`[SixbDuckLake] DuckLake commit for dataset '${input.dataset.id}' completed without producing a snapshot for this non-empty write.`);
2905
3452
  }
2906
- throw new LakeStorageError20(`[SixbDuckLake] DuckLake commit for dataset '${input.dataset.id}' completed, but Sixb could not find a matching snapshot for this write.`);
3453
+ throw new LakeStorageError22(`[SixbDuckLake] DuckLake commit for dataset '${input.dataset.id}' completed, but Sixb could not find a matching snapshot for this write.`);
2907
3454
  }
2908
3455
  async applyStagedRows(input) {
2909
3456
  const mode = input.write.mode ?? "snapshot";
@@ -2916,11 +3463,12 @@ class DuckLakeWriteCoordinator {
2916
3463
  previousRowCount: input.previousRowCount
2917
3464
  });
2918
3465
  if (result.sourceRowCount !== input.rowsWritten) {
2919
- throw new LakeStorageError20(`[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.`);
3466
+ throw new LakeStorageError22(`[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.`);
2920
3467
  }
2921
3468
  return result;
2922
3469
  }
2923
3470
  async setCommitMetadata(input, commitId, rowCount) {
3471
+ const validatedPrimaryKeyColumns = getDatasetPrimaryKeyColumns4(input.dataset);
2924
3472
  const metadata = {
2925
3473
  kind: "datasetVersion",
2926
3474
  datasetId: input.dataset.id,
@@ -2928,16 +3476,20 @@ class DuckLakeWriteCoordinator {
2928
3476
  mode: input.mode,
2929
3477
  producer: input.producer ? structuredClone(input.producer) : undefined,
2930
3478
  inputs: input.inputs ? structuredClone(input.inputs) : undefined,
2931
- ...rowCount.kind === "exact" ? { rowCount: rowCount.value } : {}
3479
+ ...rowCount.kind === "exact" ? { rowCount: rowCount.value } : {},
3480
+ ...validatedPrimaryKeyColumns !== null ? { validatedPrimaryKeyColumns: [...validatedPrimaryKeyColumns] } : {}
2932
3481
  };
2933
3482
  await input.runtime.run(`CALL ${quoteIdentifier(duckLakeAlias(this.options))}.set_commit_message(${quoteSqlString("Sixb")}, ${quoteSqlString(input.commitMessage)}, extra_info => ${quoteSqlString(JSON.stringify({ sixb: metadata }))})`);
2934
3483
  }
2935
- async versionForNoOpCommit(runtime, definition) {
3484
+ async versionForNoOpCommit(runtime, definition, allowInitialNoOp) {
2936
3485
  const latestVersion = await this.snapshots.getLatestVersionForDefinition(runtime, definition);
2937
3486
  if (latestVersion) {
2938
3487
  return latestVersion;
2939
3488
  }
2940
- throw new LakeStorageError20(`[SixbDuckLake] No DuckLake changes were committed for dataset '${definition.id}', and no previous version exists.`);
3489
+ if (allowInitialNoOp) {
3490
+ return null;
3491
+ }
3492
+ throw new LakeStorageError22(`[SixbDuckLake] No DuckLake changes were committed for dataset '${definition.id}', and no previous version exists.`);
2941
3493
  }
2942
3494
  async rollbackTransaction(runtime) {
2943
3495
  try {
@@ -2993,7 +3545,7 @@ class DuckLakeWriteCoordinator {
2993
3545
  return;
2994
3546
  }
2995
3547
  if (input.actualLatestVersionId !== input.expectedLatestVersionId) {
2996
- throw new LakeStorageError20(`[SixbDuckLake] Optimistic commit failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId}', found '${input.actualLatestVersionId ?? "none"}'.`);
3548
+ throw new LakeStorageError22(`[SixbDuckLake] Optimistic ${input.operation} failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId ?? "none"}', found '${input.actualLatestVersionId ?? "none"}'.`);
2997
3549
  }
2998
3550
  }
2999
3551
  }
@@ -3011,7 +3563,7 @@ function commitRowCount(input, changeResult, latestVersion) {
3011
3563
  }
3012
3564
  function assertDuckLakeSnapshotId3(snapshotId) {
3013
3565
  if (!/^\d+$/.test(snapshotId)) {
3014
- throw new LakeStorageError20(`[SixbDuckLake] Invalid DuckLake snapshot id '${snapshotId}'.`);
3566
+ throw new LakeStorageError22(`[SixbDuckLake] Invalid DuckLake snapshot id '${snapshotId}'.`);
3015
3567
  }
3016
3568
  }
3017
3569
  var localCatalogCommitLocks = new Map;
@@ -3089,6 +3641,9 @@ class DuckLakeStorage {
3089
3641
  async beginWrite(input) {
3090
3642
  return this.writes.beginWrite(input);
3091
3643
  }
3644
+ async beginMerge(input) {
3645
+ return this.writes.beginMerge(input);
3646
+ }
3092
3647
  async getLatestVersion(datasetId) {
3093
3648
  return this.snapshotReader.getLatestVersion(datasetId);
3094
3649
  }
@@ -3109,4 +3664,4 @@ export {
3109
3664
  DuckLakeStorage
3110
3665
  };
3111
3666
 
3112
- //# debugId=0B56C838FAF193D864756E2164756E21
3667
+ //# debugId=8AEF2C0D635BEB8A64756E2164756E21