@sixb/ducklake 0.1.0 → 0.1.2

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 (40) hide show
  1. package/README.md +48 -4
  2. package/dist/ducklake-storage.d.ts +2 -1
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +637 -50
  5. package/dist/index.js.map +16 -14
  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-row-reader.d.ts.map +1 -1
  18. package/dist/internal/ducklake-snapshot-reader.d.ts +1 -0
  19. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
  20. package/dist/internal/ducklake-write-coordinator.d.ts +5 -1
  21. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
  22. package/dist/internal/ducklake-write-session.d.ts.map +1 -1
  23. package/dist/internal/schema.d.ts +2 -0
  24. package/dist/internal/schema.d.ts.map +1 -1
  25. package/dist/internal/versions.d.ts +1 -0
  26. package/dist/internal/versions.d.ts.map +1 -1
  27. package/package.json +7 -4
  28. package/src/ducklake-storage.ts +6 -0
  29. package/src/internal/dataset-row-commit.ts +95 -2
  30. package/src/internal/dataset-row-merge.ts +268 -0
  31. package/src/internal/ducklake-catalog-schema.ts +90 -1
  32. package/src/internal/ducklake-current-dataset-definitions.ts +14 -5
  33. package/src/internal/ducklake-dataset-catalog.ts +43 -9
  34. package/src/internal/ducklake-merge-session.ts +188 -0
  35. package/src/internal/ducklake-row-reader.ts +51 -27
  36. package/src/internal/ducklake-snapshot-reader.ts +9 -2
  37. package/src/internal/ducklake-write-coordinator.ts +160 -20
  38. package/src/internal/ducklake-write-session.ts +32 -5
  39. package/src/internal/schema.ts +7 -0
  40. 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,8 +1779,14 @@ 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
1787
+ var READ_PAGE_ROWS = 5000;
1788
+ var PHYSICAL_ROW_ID_ALIAS = "__sixb_physical_row_id";
1789
+
1693
1790
  class DuckLakeRowReader {
1694
1791
  options;
1695
1792
  connections;
@@ -1709,16 +1806,43 @@ class DuckLakeRowReader {
1709
1806
  throw new LakeStorageError14(`[SixbDuckLake] Unknown dataset '${input.datasetId}'.`);
1710
1807
  }
1711
1808
  const version = await this.resolveVersion(runtime, tableRef, input.versionId);
1712
- const snapshotId = parseVersionId(version.versionId);
1713
1809
  const selectedColumns = this.resolveReadColumns(tableRef.datasetId, version.schema, input.columns);
1810
+ const snapshotId = parseVersionId(version.versionId);
1714
1811
  const columnsSql = selectedColumns.map((column) => quoteIdentifier(column.name)).join(", ");
1715
1812
  const tableSql = qualifiedTableName(this.options, tableRef.tableName);
1716
- const orderSql = version.schema.columns.some((column) => column.name.toLowerCase() === "rowid") ? "" : " ORDER BY rowid";
1717
- const limitSql = input.limit === undefined ? "" : ` LIMIT ${Math.max(0, Math.trunc(input.limit))}`;
1718
- const offsetSql = input.offset === undefined ? "" : ` OFFSET ${Math.max(0, Math.trunc(input.offset))}`;
1719
- const sql = `SELECT ${columnsSql} FROM ${tableSql} AT (VERSION => ${snapshotId})${orderSql}${limitSql}${offsetSql}`;
1720
- const rows = input.limit === undefined ? runtime.streamRows(sql) : await runtime.query(sql);
1721
- yield* this.normalizeRows(rows, selectedColumns);
1813
+ const schemaNames = new Set(version.schema.columns.map((column) => column.name.toLowerCase()));
1814
+ const canUsePhysicalRowId = !schemaNames.has("rowid") && !schemaNames.has(PHYSICAL_ROW_ID_ALIAS.toLowerCase());
1815
+ const orderSql = canUsePhysicalRowId ? " ORDER BY rowid" : "";
1816
+ const baseSql = `SELECT ${columnsSql} FROM ${tableSql} AT (VERSION => ${snapshotId})`;
1817
+ const requestedOffset = Math.max(0, Math.trunc(input.offset ?? 0));
1818
+ let remaining = input.limit === undefined ? undefined : Math.max(0, Math.trunc(input.limit));
1819
+ if (remaining === 0)
1820
+ return;
1821
+ let offset = requestedOffset;
1822
+ let physicalCursor = null;
1823
+ while (true) {
1824
+ const pageRows = remaining === undefined ? READ_PAGE_ROWS : Math.min(READ_PAGE_ROWS, remaining);
1825
+ const pageSql = canUsePhysicalRowId ? `SELECT rowid AS ${quoteIdentifier(PHYSICAL_ROW_ID_ALIAS)}, ${columnsSql}
1826
+ FROM ${tableSql} AT (VERSION => ${snapshotId})
1827
+ ${physicalCursor === null ? "" : `WHERE rowid > ${physicalCursor}`}
1828
+ ORDER BY rowid LIMIT ${pageRows}
1829
+ ${physicalCursor === null && offset > 0 ? `OFFSET ${offset}` : ""}` : `${baseSql}${orderSql} LIMIT ${pageRows} OFFSET ${offset}`;
1830
+ const rows = await runtime.query(pageSql);
1831
+ if (rows.length === 0)
1832
+ return;
1833
+ yield* this.normalizeRows(rows, selectedColumns);
1834
+ offset += rows.length;
1835
+ if (remaining !== undefined) {
1836
+ remaining -= rows.length;
1837
+ if (remaining === 0)
1838
+ return;
1839
+ }
1840
+ if (canUsePhysicalRowId) {
1841
+ physicalCursor = getBigIntLike(rows[rows.length - 1], PHYSICAL_ROW_ID_ALIAS);
1842
+ }
1843
+ if (rows.length < pageRows)
1844
+ return;
1845
+ }
1722
1846
  } finally {
1723
1847
  await lease.release();
1724
1848
  }
@@ -2002,7 +2126,8 @@ class DuckLakeSnapshotReader {
2002
2126
  return {
2003
2127
  datasetId: tableRef.datasetId,
2004
2128
  versionId: toVersionId(row.snapshotId),
2005
- ...row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}
2129
+ ...row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {},
2130
+ ...row.metadata?.validatedPrimaryKeyColumns !== undefined ? { validatedPrimaryKeyColumns: row.metadata.validatedPrimaryKeyColumns } : {}
2006
2131
  };
2007
2132
  }
2008
2133
  async getVersionForSnapshot(runtime, dataset, snapshotId) {
@@ -2062,7 +2187,7 @@ class DuckLakeSnapshotReader {
2062
2187
  if (!row) {
2063
2188
  return null;
2064
2189
  }
2065
- if (!options.includeParent || row.mode !== "append" && row.mode !== "schema") {
2190
+ if (!options.includeParent || row.mode !== "append" && row.mode !== "merge" && row.mode !== "schema") {
2066
2191
  return row;
2067
2192
  }
2068
2193
  const [parent] = await this.collectVisibleSnapshotRows(runtime, {
@@ -2250,7 +2375,7 @@ class DuckLakeSnapshotReader {
2250
2375
  }
2251
2376
  withParentSnapshotIds(snapshots) {
2252
2377
  return snapshots.map((snapshot, index) => {
2253
- const parentSnapshotId = snapshot.mode === "append" || snapshot.mode === "schema" ? snapshots[index + 1]?.snapshotId : undefined;
2378
+ const parentSnapshotId = snapshot.mode === "append" || snapshot.mode === "merge" || snapshot.mode === "schema" ? snapshots[index + 1]?.snapshotId : undefined;
2254
2379
  return parentSnapshotId === undefined ? snapshot : { ...snapshot, parentSnapshotId };
2255
2380
  });
2256
2381
  }
@@ -2271,7 +2396,10 @@ import {
2271
2396
  } from "@sixb/core/lake-storage";
2272
2397
 
2273
2398
  // src/internal/dataset-row-commit.ts
2274
- import { LakeStorageError as LakeStorageError16 } from "@sixb/core/lake-storage";
2399
+ import {
2400
+ getDatasetPrimaryKeyColumns,
2401
+ LakeStorageError as LakeStorageError16
2402
+ } from "@sixb/core/lake-storage";
2275
2403
  function assertDatasetWriteMode(mode, operation) {
2276
2404
  if (mode !== "snapshot" && mode !== "append") {
2277
2405
  throw new LakeStorageError16(`[SixbDuckLake] Invalid ${operation} mode '${String(mode)}'. Expected 'snapshot' or 'append'.`);
@@ -2281,6 +2409,30 @@ async function applyDatasetRowsFromRelation(input) {
2281
2409
  const tableName = encodeDatasetTableName(input.dataset.id);
2282
2410
  const table = qualifiedTableName(input.options, tableName);
2283
2411
  const columnsSql = datasetSchemaColumnNamesSql(input.dataset.schema);
2412
+ const primaryKeyColumns = getDatasetPrimaryKeyColumns(input.dataset);
2413
+ if (primaryKeyColumns !== null) {
2414
+ await inspectUniqueKeyedRelation({
2415
+ runtime: input.runtime,
2416
+ dataset: input.dataset,
2417
+ relationSql: input.sourceRelationSql,
2418
+ context: `${input.mode} source`
2419
+ });
2420
+ if (input.mode === "append") {
2421
+ await inspectUniqueKeyedRelation({
2422
+ runtime: input.runtime,
2423
+ dataset: input.dataset,
2424
+ relationSql: table,
2425
+ context: "current baseline"
2426
+ });
2427
+ await assertNoPrimaryKeyOverlap({
2428
+ runtime: input.runtime,
2429
+ dataset: input.dataset,
2430
+ leftRelationSql: input.sourceRelationSql,
2431
+ rightRelationSql: table,
2432
+ context: "append"
2433
+ });
2434
+ }
2435
+ }
2284
2436
  if (input.mode === "snapshot") {
2285
2437
  const source = await fingerprintRelation(input.runtime, input.sourceRelationSql, columnsSql);
2286
2438
  const target = await fingerprintRelation(input.runtime, table, columnsSql);
@@ -2307,6 +2459,44 @@ async function applyDatasetRowsFromRelation(input) {
2307
2459
  resultingRowCount: input.previousRowCount === undefined ? { kind: "unknown" } : { kind: "exact", value: input.previousRowCount + sourceRowCount }
2308
2460
  };
2309
2461
  }
2462
+ async function inspectUniqueKeyedRelation(input) {
2463
+ const primaryKeyColumns = getDatasetPrimaryKeyColumns(input.dataset);
2464
+ if (primaryKeyColumns === null) {
2465
+ throw new LakeStorageError16(`[SixbDuckLake] Dataset '${input.dataset.id}' must define a primaryKey before rows can be keyed.`);
2466
+ }
2467
+ const keysSql = primaryKeyColumns.map((column) => quoteIdentifier(column)).join(", ");
2468
+ const [row] = await input.runtime.query(`
2469
+ SELECT
2470
+ count(*) AS row_count,
2471
+ coalesce(max(key_count), 0) AS max_key_count
2472
+ FROM (
2473
+ SELECT count(*) OVER (PARTITION BY ${keysSql}) AS key_count
2474
+ FROM ${input.relationSql}
2475
+ ) keyed_rows
2476
+ `);
2477
+ const rowCount = row === undefined ? 0 : Number(getBigIntLike(row, "row_count"));
2478
+ const maxKeyCount = row === undefined ? 0 : Number(getBigIntLike(row, "max_key_count"));
2479
+ if (maxKeyCount > 1) {
2480
+ throw new LakeStorageError16(`[SixbDuckLake] Dataset '${input.dataset.id}' ${input.context} contains duplicate primary key.`);
2481
+ }
2482
+ return rowCount;
2483
+ }
2484
+ async function assertNoPrimaryKeyOverlap(input) {
2485
+ const primaryKeyColumns = getDatasetPrimaryKeyColumns(input.dataset);
2486
+ if (primaryKeyColumns === null) {
2487
+ return;
2488
+ }
2489
+ const matchSql = primaryKeyColumns.map((column) => `left_rows.${quoteIdentifier(column)} = right_rows.${quoteIdentifier(column)}`).join(" AND ");
2490
+ const [collision] = await input.runtime.query(`
2491
+ SELECT 1
2492
+ FROM ${input.leftRelationSql} left_rows
2493
+ JOIN ${input.rightRelationSql} right_rows ON ${matchSql}
2494
+ LIMIT 1
2495
+ `);
2496
+ if (collision !== undefined) {
2497
+ throw new LakeStorageError16(`[SixbDuckLake] Dataset '${input.dataset.id}' ${input.context} contains duplicate primary key.`);
2498
+ }
2499
+ }
2310
2500
  async function fingerprintRelation(runtime, relationSql, columnsSql) {
2311
2501
  const [row] = await runtime.query(`
2312
2502
  SELECT
@@ -2604,13 +2794,183 @@ function throwResultSchemaMismatch(datasetId, detail) {
2604
2794
  }
2605
2795
 
2606
2796
  // src/internal/ducklake-write-coordinator.ts
2607
- import { randomUUID as randomUUID5 } from "crypto";
2608
- import { LakeStorageError as LakeStorageError20 } from "@sixb/core/lake-storage";
2797
+ import { randomUUID as randomUUID6 } from "crypto";
2798
+ import { getDatasetPrimaryKeyColumns as getDatasetPrimaryKeyColumns4, LakeStorageError as LakeStorageError22 } from "@sixb/core/lake-storage";
2609
2799
 
2610
- // src/internal/ducklake-write-session.ts
2800
+ // src/internal/dataset-row-merge.ts
2801
+ import { getDatasetPrimaryKeyColumns as getDatasetPrimaryKeyColumns2, LakeStorageError as LakeStorageError19 } from "@sixb/core/lake-storage";
2802
+ async function applyDatasetMergeFromRelation(input) {
2803
+ const primaryKeyColumns = getDatasetPrimaryKeyColumns2(input.dataset);
2804
+ if (primaryKeyColumns === null) {
2805
+ throw new LakeStorageError19(`[SixbDuckLake] Dataset '${input.dataset.id}' must define a primaryKey before it can be merged.`);
2806
+ }
2807
+ const stagingTable = quoteIdentifier(input.stagingTableName);
2808
+ const targetTable = qualifiedTableName(input.options, encodeDatasetTableName(input.dataset.id));
2809
+ const sourceRowCount = await countRows2(input.runtime, stagingTable);
2810
+ const previousRowCount = await currentBaselineRowCount(input, targetTable, primaryKeyColumns);
2811
+ return withMaterializedFinalChanges(input, primaryKeyColumns, async (finalChanges) => {
2812
+ const effects = await countMergeEffects(input, targetTable, finalChanges, primaryKeyColumns);
2813
+ const effectiveChangeCount = effects.inserts + effects.updates + effects.deletes;
2814
+ if (effectiveChangeCount === 0) {
2815
+ return {
2816
+ dataChangeExpected: false,
2817
+ sourceRowCount,
2818
+ resultingRowCount: { kind: "exact", value: previousRowCount }
2819
+ };
2820
+ }
2821
+ const keyMatchSql = primaryKeyMatchSql(primaryKeyColumns, "target", "source");
2822
+ const rowDifferenceSql = datasetRowDifferenceSql(input.dataset, "target", "source");
2823
+ const kindColumn = `source.${quoteIdentifier(input.kindColumnName)}`;
2824
+ await input.runtime.run(`
2825
+ DELETE FROM ${targetTable} AS target
2826
+ USING ${finalChanges} AS source
2827
+ WHERE ${keyMatchSql}
2828
+ AND (
2829
+ ${kindColumn} = ${quoteSqlString("delete")}
2830
+ OR (
2831
+ ${kindColumn} = ${quoteSqlString("upsert")}
2832
+ AND (${rowDifferenceSql})
2833
+ )
2834
+ )
2835
+ `);
2836
+ const columnsSql = datasetSchemaColumnNamesSql(input.dataset.schema);
2837
+ const selectedColumnsSql = input.dataset.schema.columns.map((column) => `source.${quoteIdentifier(column.name)}`).join(", ");
2838
+ const firstSequenceColumnName = firstSequenceName(input.sequenceColumnName);
2839
+ await input.runtime.run(`
2840
+ INSERT INTO ${targetTable} (${columnsSql})
2841
+ SELECT ${selectedColumnsSql}
2842
+ FROM ${finalChanges} AS source
2843
+ WHERE ${kindColumn} = ${quoteSqlString("upsert")}
2844
+ AND NOT EXISTS (
2845
+ SELECT 1
2846
+ FROM ${targetTable} AS target
2847
+ WHERE ${keyMatchSql}
2848
+ )
2849
+ ORDER BY source.${quoteIdentifier(firstSequenceColumnName)}
2850
+ `);
2851
+ return {
2852
+ dataChangeExpected: true,
2853
+ sourceRowCount,
2854
+ resultingRowCount: {
2855
+ kind: "exact",
2856
+ value: previousRowCount + effects.inserts - effects.deletes
2857
+ }
2858
+ };
2859
+ });
2860
+ }
2861
+ async function currentBaselineRowCount(input, targetTable, primaryKeyColumns) {
2862
+ if (sameColumns(input.validatedPrimaryKeyColumns, primaryKeyColumns)) {
2863
+ return input.previousRowCount ?? countRows2(input.runtime, targetTable);
2864
+ }
2865
+ return inspectUniqueKeyedRelation({
2866
+ runtime: input.runtime,
2867
+ dataset: input.dataset,
2868
+ relationSql: targetTable,
2869
+ context: "current baseline"
2870
+ });
2871
+ }
2872
+ async function withMaterializedFinalChanges(input, primaryKeyColumns, run) {
2873
+ const table = quoteIdentifier(`${input.stagingTableName}_final`);
2874
+ await input.runtime.run(`
2875
+ CREATE TEMP TABLE ${table} AS
2876
+ ${finalChangesSelectSql(input, primaryKeyColumns)}
2877
+ `);
2878
+ let outcome;
2879
+ try {
2880
+ outcome = { kind: "success", value: await run(table) };
2881
+ } catch (error) {
2882
+ outcome = { kind: "error", error };
2883
+ }
2884
+ try {
2885
+ await input.runtime.run(`DROP TABLE IF EXISTS ${table}`);
2886
+ } catch (cleanupError) {
2887
+ if (outcome.kind === "success") {
2888
+ throw cleanupError;
2889
+ }
2890
+ }
2891
+ if (outcome.kind === "error") {
2892
+ throw outcome.error;
2893
+ }
2894
+ return outcome.value;
2895
+ }
2896
+ function finalChangesSelectSql(input, primaryKeyColumns) {
2897
+ const sequenceColumn = quoteIdentifier(input.sequenceColumnName);
2898
+ const keyColumnsSql = primaryKeyColumns.map((column) => quoteIdentifier(column)).join(", ");
2899
+ const rankColumn = quoteIdentifier(rankName(input.sequenceColumnName));
2900
+ const firstSequenceColumn = quoteIdentifier(firstSequenceName(input.sequenceColumnName));
2901
+ return `
2902
+ SELECT * EXCLUDE (${rankColumn})
2903
+ FROM (
2904
+ SELECT
2905
+ *,
2906
+ min(${sequenceColumn}) OVER (PARTITION BY ${keyColumnsSql}) AS ${firstSequenceColumn},
2907
+ row_number() OVER (
2908
+ PARTITION BY ${keyColumnsSql}
2909
+ ORDER BY ${sequenceColumn} DESC
2910
+ ) AS ${rankColumn}
2911
+ FROM ${quoteIdentifier(input.stagingTableName)}
2912
+ ) ranked_changes
2913
+ WHERE ${rankColumn} = 1
2914
+ `;
2915
+ }
2916
+ async function countMergeEffects(input, targetTable, finalChanges, primaryKeyColumns) {
2917
+ const matchColumn = primaryKeyColumns[0];
2918
+ if (matchColumn === undefined) {
2919
+ throw new LakeStorageError19(`[SixbDuckLake] Dataset '${input.dataset.id}' primaryKey must contain at least one column.`);
2920
+ }
2921
+ const keyMatchSql = primaryKeyMatchSql(primaryKeyColumns, "target", "source");
2922
+ const targetMatch = `target.${quoteIdentifier(matchColumn)} IS NOT NULL`;
2923
+ const kindColumn = `source.${quoteIdentifier(input.kindColumnName)}`;
2924
+ const rowDifferenceSql = datasetRowDifferenceSql(input.dataset, "target", "source");
2925
+ const [row] = await input.runtime.query(`
2926
+ SELECT
2927
+ count(*) FILTER (
2928
+ WHERE ${kindColumn} = ${quoteSqlString("upsert")} AND NOT (${targetMatch})
2929
+ ) AS insert_count,
2930
+ count(*) FILTER (
2931
+ WHERE ${kindColumn} = ${quoteSqlString("upsert")}
2932
+ AND ${targetMatch}
2933
+ AND (${rowDifferenceSql})
2934
+ ) AS update_count,
2935
+ count(*) FILTER (
2936
+ WHERE ${kindColumn} = ${quoteSqlString("delete")} AND ${targetMatch}
2937
+ ) AS delete_count
2938
+ FROM ${finalChanges} AS source
2939
+ LEFT JOIN ${targetTable} AS target ON ${keyMatchSql}
2940
+ `);
2941
+ return {
2942
+ inserts: row === undefined ? 0 : Number(getBigIntLike(row, "insert_count")),
2943
+ updates: row === undefined ? 0 : Number(getBigIntLike(row, "update_count")),
2944
+ deletes: row === undefined ? 0 : Number(getBigIntLike(row, "delete_count"))
2945
+ };
2946
+ }
2947
+ function primaryKeyMatchSql(primaryKeyColumns, leftAlias, rightAlias) {
2948
+ return primaryKeyColumns.map((column) => `${leftAlias}.${quoteIdentifier(column)} = ${rightAlias}.${quoteIdentifier(column)}`).join(" AND ");
2949
+ }
2950
+ function datasetRowDifferenceSql(dataset, leftAlias, rightAlias) {
2951
+ return dataset.schema.columns.map((column) => `${leftAlias}.${quoteIdentifier(column.name)} IS DISTINCT FROM ${rightAlias}.${quoteIdentifier(column.name)}`).join(" OR ");
2952
+ }
2953
+ async function countRows2(runtime, relationSql) {
2954
+ const [row] = await runtime.query(`SELECT count(*) AS row_count FROM ${relationSql}`);
2955
+ return row === undefined ? 0 : Number(getBigIntLike(row, "row_count"));
2956
+ }
2957
+ function rankName(sequenceColumnName) {
2958
+ return `${sequenceColumnName}_rank`;
2959
+ }
2960
+ function firstSequenceName(sequenceColumnName) {
2961
+ return `${sequenceColumnName}_first`;
2962
+ }
2963
+ function sameColumns(left, right) {
2964
+ return left !== undefined && left.length === right.length && left.every((value, index) => value === right[index]);
2965
+ }
2966
+
2967
+ // src/internal/ducklake-merge-session.ts
2611
2968
  import { randomUUID as randomUUID4 } from "crypto";
2612
- import { getDatasetRowValidationError } from "@sixb/core";
2613
- import { LakeStorageError as LakeStorageError19 } from "@sixb/core/lake-storage";
2969
+ import {
2970
+ cloneDatasetMergeChange,
2971
+ getDatasetMergeChangeValidationError,
2972
+ LakeStorageError as LakeStorageError20
2973
+ } from "@sixb/core/lake-storage";
2614
2974
 
2615
2975
  // src/internal/row-appender.ts
2616
2976
  function appendDatasetRow(appender, schema, row) {
@@ -2665,10 +3025,135 @@ function fileRefToDuckDbStruct(fileRef) {
2665
3025
  };
2666
3026
  }
2667
3027
 
3028
+ // src/internal/ducklake-merge-session.ts
3029
+ var MERGE_CHANGE_BATCH_SIZE = 1000;
3030
+ async function createDuckLakeMergeSession(input) {
3031
+ const suffix = randomUUID4().replaceAll("-", "");
3032
+ const stagingTableName = `sixb_merge_${suffix}`;
3033
+ const sequenceColumnName = `sixb_merge_sequence_${suffix}`;
3034
+ const kindColumnName = `sixb_merge_kind_${suffix}`;
3035
+ await input.runtime.run(`
3036
+ CREATE TEMP TABLE ${quoteIdentifier(stagingTableName)} (
3037
+ ${quoteIdentifier(sequenceColumnName)} UBIGINT NOT NULL,
3038
+ ${quoteIdentifier(kindColumnName)} VARCHAR NOT NULL,
3039
+ ${datasetSchemaToDuckDbNullableColumnsSql(input.merge.dataset.schema)}
3040
+ )
3041
+ `);
3042
+ return new DuckLakeMergeSession(input.commitMerge, input.runtime, input.merge, input.baseVersionId, stagingTableName, sequenceColumnName, kindColumnName);
3043
+ }
3044
+
3045
+ class DuckLakeMergeSession {
3046
+ commitMerge;
3047
+ runtime;
3048
+ input;
3049
+ baseVersionId;
3050
+ stagingTableName;
3051
+ sequenceColumnName;
3052
+ kindColumnName;
3053
+ closed = false;
3054
+ cleanedUp = false;
3055
+ nextSequence = 0n;
3056
+ changesWritten = 0;
3057
+ constructor(commitMerge, runtime, input, baseVersionId, stagingTableName, sequenceColumnName, kindColumnName) {
3058
+ this.commitMerge = commitMerge;
3059
+ this.runtime = runtime;
3060
+ this.input = input;
3061
+ this.baseVersionId = baseVersionId;
3062
+ this.stagingTableName = stagingTableName;
3063
+ this.sequenceColumnName = sequenceColumnName;
3064
+ this.kindColumnName = kindColumnName;
3065
+ }
3066
+ async writeChanges(changes) {
3067
+ this.assertOpen();
3068
+ let batch = [];
3069
+ for await (const change of changes) {
3070
+ const validationError = getDatasetMergeChangeValidationError(change, this.input.dataset);
3071
+ if (validationError) {
3072
+ throw new LakeStorageError20(`[SixbDuckLake] ${validationError}`);
3073
+ }
3074
+ batch.push({
3075
+ sequence: this.nextSequence,
3076
+ change: cloneDatasetMergeChange(change)
3077
+ });
3078
+ this.nextSequence += 1n;
3079
+ if (batch.length >= MERGE_CHANGE_BATCH_SIZE) {
3080
+ await this.appendBatchToStagingTable(batch);
3081
+ batch = [];
3082
+ await Bun.sleep(0);
3083
+ }
3084
+ }
3085
+ if (batch.length > 0) {
3086
+ await this.appendBatchToStagingTable(batch);
3087
+ }
3088
+ }
3089
+ async appendBatchToStagingTable(batch) {
3090
+ await this.runtime.withAppender(this.stagingTableName, (appender) => {
3091
+ for (const staged of batch) {
3092
+ appender.appendBigInt(staged.sequence);
3093
+ appender.appendVarchar(staged.change.kind);
3094
+ appendDatasetRow(appender, this.input.dataset.schema, staged.change.kind === "upsert" ? staged.change.row : staged.change.key);
3095
+ }
3096
+ });
3097
+ this.changesWritten += batch.length;
3098
+ }
3099
+ async commit(input) {
3100
+ this.assertOpen();
3101
+ this.closed = true;
3102
+ let committed = false;
3103
+ try {
3104
+ const result = await this.commitMerge({
3105
+ merge: this.input,
3106
+ baseVersionId: this.baseVersionId,
3107
+ commit: input,
3108
+ stagingTableName: this.stagingTableName,
3109
+ sequenceColumnName: this.sequenceColumnName,
3110
+ kindColumnName: this.kindColumnName,
3111
+ changesWritten: this.changesWritten
3112
+ });
3113
+ committed = true;
3114
+ await this.cleanup();
3115
+ return result;
3116
+ } catch (error) {
3117
+ if (!committed) {
3118
+ await this.cleanup();
3119
+ }
3120
+ throw error;
3121
+ }
3122
+ }
3123
+ async abort() {
3124
+ if (this.closed) {
3125
+ return;
3126
+ }
3127
+ this.closed = true;
3128
+ await this.cleanup();
3129
+ }
3130
+ assertOpen() {
3131
+ if (this.closed) {
3132
+ throw new LakeStorageError20("[SixbDuckLake] Merge session is already closed.");
3133
+ }
3134
+ }
3135
+ async cleanup() {
3136
+ if (this.cleanedUp) {
3137
+ return;
3138
+ }
3139
+ this.cleanedUp = true;
3140
+ try {
3141
+ await this.runtime.run(`DROP TABLE IF EXISTS ${quoteIdentifier(this.stagingTableName)}`);
3142
+ } catch {}
3143
+ }
3144
+ }
3145
+
2668
3146
  // src/internal/ducklake-write-session.ts
3147
+ import { randomUUID as randomUUID5 } from "crypto";
3148
+ import { getDatasetRowValidationError } from "@sixb/core";
3149
+ import {
3150
+ encodeDatasetPrimaryKey,
3151
+ getDatasetPrimaryKeyColumns as getDatasetPrimaryKeyColumns3,
3152
+ LakeStorageError as LakeStorageError21
3153
+ } from "@sixb/core/lake-storage";
2669
3154
  var WRITE_ROW_BATCH_SIZE = 1000;
2670
3155
  async function createDuckLakeWriteSession(input) {
2671
- const stagingTableName = `sixb_write_${randomUUID4().replaceAll("-", "")}`;
3156
+ const stagingTableName = `sixb_write_${randomUUID5().replaceAll("-", "")}`;
2672
3157
  await input.runtime.run(`CREATE TEMP TABLE ${quoteIdentifier(stagingTableName)} (${datasetSchemaToDuckDbColumnsSql(input.write.dataset.schema)})`);
2673
3158
  return new DuckLakeWriteSession(input.commitWrite, input.runtime, input.write, stagingTableName);
2674
3159
  }
@@ -2681,6 +3166,7 @@ class DuckLakeWriteSession {
2681
3166
  closed = false;
2682
3167
  cleanedUp = false;
2683
3168
  rowsWritten = 0;
3169
+ stagedPrimaryKeys = new Set;
2684
3170
  constructor(commitWrite, runtime, input, stagingTableName) {
2685
3171
  this.commitWrite = commitWrite;
2686
3172
  this.runtime = runtime;
@@ -2690,28 +3176,42 @@ class DuckLakeWriteSession {
2690
3176
  async writeRows(rows) {
2691
3177
  this.assertOpen();
2692
3178
  let batch = [];
3179
+ let batchPrimaryKeys = new Set;
2693
3180
  for await (const row of rows) {
2694
3181
  const validationError = getDatasetRowValidationError(row, this.input.dataset);
2695
3182
  if (validationError) {
2696
- throw new LakeStorageError19(`[SixbDuckLake] ${validationError}`);
3183
+ throw new LakeStorageError21(`[SixbDuckLake] ${validationError}`);
3184
+ }
3185
+ const clonedRow = structuredClone(row);
3186
+ if (getDatasetPrimaryKeyColumns3(this.input.dataset) !== null) {
3187
+ const primaryKey = encodeDatasetPrimaryKey(this.input.dataset, clonedRow);
3188
+ if (this.stagedPrimaryKeys.has(primaryKey) || batchPrimaryKeys.has(primaryKey)) {
3189
+ throw new LakeStorageError21(`[SixbDuckLake] Dataset '${this.input.dataset.id}' ${this.input.mode ?? "snapshot"} source contains duplicate primary key.`);
3190
+ }
3191
+ batchPrimaryKeys.add(primaryKey);
2697
3192
  }
2698
- batch.push(structuredClone(row));
3193
+ batch.push(clonedRow);
2699
3194
  if (batch.length >= WRITE_ROW_BATCH_SIZE) {
2700
- await this.appendBatchToStagingTable(batch);
3195
+ await this.appendBatchToStagingTable(batch, batchPrimaryKeys);
2701
3196
  batch = [];
3197
+ batchPrimaryKeys = new Set;
3198
+ await Bun.sleep(0);
2702
3199
  }
2703
3200
  }
2704
3201
  if (batch.length > 0) {
2705
- await this.appendBatchToStagingTable(batch);
3202
+ await this.appendBatchToStagingTable(batch, batchPrimaryKeys);
2706
3203
  }
2707
3204
  }
2708
- async appendBatchToStagingTable(batch) {
3205
+ async appendBatchToStagingTable(batch, primaryKeys) {
2709
3206
  await this.runtime.withAppender(this.stagingTableName, (appender) => {
2710
3207
  for (const row of batch) {
2711
3208
  appendDatasetRow(appender, this.input.dataset.schema, row);
2712
3209
  }
2713
3210
  });
2714
3211
  this.rowsWritten += batch.length;
3212
+ for (const primaryKey of primaryKeys) {
3213
+ this.stagedPrimaryKeys.add(primaryKey);
3214
+ }
2715
3215
  }
2716
3216
  async commit(input) {
2717
3217
  this.assertOpen();
@@ -2743,7 +3243,7 @@ class DuckLakeWriteSession {
2743
3243
  }
2744
3244
  assertOpen() {
2745
3245
  if (this.closed) {
2746
- throw new LakeStorageError19("[SixbDuckLake] Write session is already closed.");
3246
+ throw new LakeStorageError21("[SixbDuckLake] Write session is already closed.");
2747
3247
  }
2748
3248
  }
2749
3249
  async cleanup() {
@@ -2776,7 +3276,7 @@ class DuckLakeWriteCoordinator {
2776
3276
  this.connections.assertOpen();
2777
3277
  const definition = await this.datasets.getDataset(input.dataset.id);
2778
3278
  if (!definition) {
2779
- throw new LakeStorageError20(`[SixbDuckLake] Unknown dataset '${input.dataset.id}'.`);
3279
+ throw new LakeStorageError22(`[SixbDuckLake] Unknown dataset '${input.dataset.id}'.`);
2780
3280
  }
2781
3281
  this.datasets.assertSchema(definition);
2782
3282
  const mode = input.mode ?? "snapshot";
@@ -2792,10 +3292,32 @@ class DuckLakeWriteCoordinator {
2792
3292
  }
2793
3293
  });
2794
3294
  }
3295
+ async beginMerge(input) {
3296
+ this.connections.assertOpen();
3297
+ const definition = await this.datasets.getDataset(input.dataset.id);
3298
+ if (!definition) {
3299
+ throw new LakeStorageError22(`[SixbDuckLake] Unknown dataset '${input.dataset.id}'.`);
3300
+ }
3301
+ if (getDatasetPrimaryKeyColumns4(definition) === null) {
3302
+ throw new LakeStorageError22(`[SixbDuckLake] Dataset '${definition.id}' must define a primaryKey before it can be merged.`);
3303
+ }
3304
+ this.datasets.assertSchema(definition);
3305
+ const latestVersion = await this.connections.withAttachedRuntime((runtime2) => this.snapshots.getLatestVersionForDefinition(runtime2, definition));
3306
+ if (input.expectedLatestVersionId !== undefined && latestVersion?.versionId !== input.expectedLatestVersionId) {
3307
+ throw new LakeStorageError22(`[SixbDuckLake] Optimistic merge start failed for dataset '${definition.id}': expected latest version '${input.expectedLatestVersionId}', found '${latestVersion?.versionId ?? "none"}'.`);
3308
+ }
3309
+ const runtime = await this.connections.stagingRuntime();
3310
+ return createDuckLakeMergeSession({
3311
+ commitMerge: (options) => this.commitMerge(options),
3312
+ runtime,
3313
+ merge: { ...input, dataset: definition },
3314
+ baseVersionId: latestVersion?.versionId ?? null
3315
+ });
3316
+ }
2795
3317
  async commitWrite(input) {
2796
3318
  const definition = await this.datasets.getDataset(input.write.dataset.id);
2797
3319
  if (!definition) {
2798
- throw new LakeStorageError20(`[SixbDuckLake] Unknown dataset '${input.write.dataset.id}'.`);
3320
+ throw new LakeStorageError22(`[SixbDuckLake] Unknown dataset '${input.write.dataset.id}'.`);
2799
3321
  }
2800
3322
  const mode = input.write.mode ?? "snapshot";
2801
3323
  assertDatasetWriteMode(mode, "write");
@@ -2813,6 +3335,42 @@ class DuckLakeWriteCoordinator {
2813
3335
  })
2814
3336
  });
2815
3337
  }
3338
+ async commitMerge(input) {
3339
+ const definition = await this.datasets.getDataset(input.merge.dataset.id);
3340
+ if (!definition) {
3341
+ throw new LakeStorageError22(`[SixbDuckLake] Unknown dataset '${input.merge.dataset.id}'.`);
3342
+ }
3343
+ if (getDatasetPrimaryKeyColumns4(definition) === null) {
3344
+ throw new LakeStorageError22(`[SixbDuckLake] Dataset '${definition.id}' must define a primaryKey before it can be merged.`);
3345
+ }
3346
+ return this.withCommitRuntime((runtime) => this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
3347
+ dataset: definition,
3348
+ mode: "merge",
3349
+ expectedLatestVersionId: input.baseVersionId,
3350
+ allowInitialNoOp: true,
3351
+ disableRetries: true,
3352
+ optimisticOperation: "merge commit",
3353
+ commitMessage: input.commit?.commitMessage ?? `merge dataset ${definition.id}`,
3354
+ producer: input.merge.producer,
3355
+ inputs: input.merge.inputs,
3356
+ applyChanges: async (commitRuntime, context) => {
3357
+ const result = await applyDatasetMergeFromRelation({
3358
+ options: this.options,
3359
+ runtime: commitRuntime,
3360
+ dataset: definition,
3361
+ stagingTableName: input.stagingTableName,
3362
+ sequenceColumnName: input.sequenceColumnName,
3363
+ kindColumnName: input.kindColumnName,
3364
+ previousRowCount: context.previousRowCount,
3365
+ validatedPrimaryKeyColumns: context.validatedPrimaryKeyColumns
3366
+ });
3367
+ if (result.sourceRowCount !== input.changesWritten) {
3368
+ 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.`);
3369
+ }
3370
+ return result;
3371
+ }
3372
+ }));
3373
+ }
2816
3374
  async commitDatasetVersion(input) {
2817
3375
  return this.withCommitRuntime((runtime) => this.commitDatasetVersionOnExclusiveRuntime(runtime, input));
2818
3376
  }
@@ -2822,11 +3380,21 @@ class DuckLakeWriteCoordinator {
2822
3380
  });
2823
3381
  }
2824
3382
  async commitDatasetVersionOnExclusiveRuntime(runtime, input) {
3383
+ const result = await this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
3384
+ ...input,
3385
+ disableRetries: input.mode === "append" && getDatasetPrimaryKeyColumns4(input.dataset) !== null
3386
+ });
3387
+ if (result.version === null) {
3388
+ throw new LakeStorageError22(`[SixbDuckLake] No DuckLake changes were committed for dataset '${input.dataset.id}', and no previous version exists.`);
3389
+ }
3390
+ return { ...result.version, outcome: result.outcome };
3391
+ }
3392
+ async commitVersionOutcomeOnExclusiveRuntime(runtime, input) {
2825
3393
  const runtimeInput = { ...input, runtime };
2826
3394
  return this.withGuardedRetrySetting(runtimeInput, () => this.commitDatasetVersionUnlocked(runtimeInput));
2827
3395
  }
2828
3396
  async withGuardedRetrySetting(input, run) {
2829
- if (input.expectedLatestVersionId === undefined) {
3397
+ if (input.expectedLatestVersionId === undefined && !input.disableRetries) {
2830
3398
  return run();
2831
3399
  }
2832
3400
  await input.runtime.run("SET ducklake_max_retry_count = 0");
@@ -2856,13 +3424,15 @@ class DuckLakeWriteCoordinator {
2856
3424
  this.assertExpectedLatestVersion({
2857
3425
  datasetId: input.dataset.id,
2858
3426
  expectedLatestVersionId: input.expectedLatestVersionId,
2859
- actualLatestVersionId: latestVersion?.versionId ?? null
3427
+ actualLatestVersionId: latestVersion?.versionId ?? null,
3428
+ operation: input.optimisticOperation ?? "commit"
2860
3429
  });
2861
3430
  }
2862
3431
  const changeResult = await input.applyChanges(input.runtime, {
2863
- previousRowCount: latestVersion?.rowCount
3432
+ previousRowCount: latestVersion?.rowCount,
3433
+ validatedPrimaryKeyColumns: latestVersion?.validatedPrimaryKeyColumns
2864
3434
  });
2865
- const commitId = randomUUID5();
3435
+ const commitId = randomUUID6();
2866
3436
  await this.setCommitMetadata(input, commitId, commitRowCount(input, changeResult, latestVersion));
2867
3437
  await input.runtime.run("COMMIT");
2868
3438
  committed = true;
@@ -2890,20 +3460,29 @@ class DuckLakeWriteCoordinator {
2890
3460
  if (ownSnapshotId !== null) {
2891
3461
  const version = await this.snapshots.getVersionForSnapshot(input.runtime, input.dataset, ownSnapshotId);
2892
3462
  if (version) {
2893
- return { ...version, outcome: "created" };
3463
+ return { outcome: "created", version };
2894
3464
  }
2895
- throw new LakeStorageError20(`[SixbDuckLake] DuckLake committed snapshot '${ownSnapshotId}' for dataset '${input.dataset.id}', but Sixb could not hydrate it as a dataset version.`);
3465
+ throw new LakeStorageError22(`[SixbDuckLake] DuckLake committed snapshot '${ownSnapshotId}' for dataset '${input.dataset.id}', but Sixb could not hydrate it as a dataset version.`);
2896
3466
  }
2897
3467
  if (!input.changeResult.dataChangeExpected) {
3468
+ const version = await this.versionForNoOpCommit(input.runtime, input.dataset, input.allowInitialNoOp ?? false);
3469
+ if (input.expectedLatestVersionId !== undefined) {
3470
+ this.assertExpectedLatestVersion({
3471
+ datasetId: input.dataset.id,
3472
+ expectedLatestVersionId: input.expectedLatestVersionId,
3473
+ actualLatestVersionId: version?.versionId ?? null,
3474
+ operation: input.optimisticOperation ?? "commit"
3475
+ });
3476
+ }
2898
3477
  return {
2899
- ...await this.versionForNoOpCommit(input.runtime, input.dataset),
2900
- outcome: "unchanged"
3478
+ outcome: "unchanged",
3479
+ version
2901
3480
  };
2902
3481
  }
2903
3482
  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.`);
3483
+ throw new LakeStorageError22(`[SixbDuckLake] DuckLake commit for dataset '${input.dataset.id}' completed without producing a snapshot for this non-empty write.`);
2905
3484
  }
2906
- throw new LakeStorageError20(`[SixbDuckLake] DuckLake commit for dataset '${input.dataset.id}' completed, but Sixb could not find a matching snapshot for this write.`);
3485
+ throw new LakeStorageError22(`[SixbDuckLake] DuckLake commit for dataset '${input.dataset.id}' completed, but Sixb could not find a matching snapshot for this write.`);
2907
3486
  }
2908
3487
  async applyStagedRows(input) {
2909
3488
  const mode = input.write.mode ?? "snapshot";
@@ -2916,11 +3495,12 @@ class DuckLakeWriteCoordinator {
2916
3495
  previousRowCount: input.previousRowCount
2917
3496
  });
2918
3497
  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.`);
3498
+ 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
3499
  }
2921
3500
  return result;
2922
3501
  }
2923
3502
  async setCommitMetadata(input, commitId, rowCount) {
3503
+ const validatedPrimaryKeyColumns = getDatasetPrimaryKeyColumns4(input.dataset);
2924
3504
  const metadata = {
2925
3505
  kind: "datasetVersion",
2926
3506
  datasetId: input.dataset.id,
@@ -2928,16 +3508,20 @@ class DuckLakeWriteCoordinator {
2928
3508
  mode: input.mode,
2929
3509
  producer: input.producer ? structuredClone(input.producer) : undefined,
2930
3510
  inputs: input.inputs ? structuredClone(input.inputs) : undefined,
2931
- ...rowCount.kind === "exact" ? { rowCount: rowCount.value } : {}
3511
+ ...rowCount.kind === "exact" ? { rowCount: rowCount.value } : {},
3512
+ ...validatedPrimaryKeyColumns !== null ? { validatedPrimaryKeyColumns: [...validatedPrimaryKeyColumns] } : {}
2932
3513
  };
2933
3514
  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
3515
  }
2935
- async versionForNoOpCommit(runtime, definition) {
3516
+ async versionForNoOpCommit(runtime, definition, allowInitialNoOp) {
2936
3517
  const latestVersion = await this.snapshots.getLatestVersionForDefinition(runtime, definition);
2937
3518
  if (latestVersion) {
2938
3519
  return latestVersion;
2939
3520
  }
2940
- throw new LakeStorageError20(`[SixbDuckLake] No DuckLake changes were committed for dataset '${definition.id}', and no previous version exists.`);
3521
+ if (allowInitialNoOp) {
3522
+ return null;
3523
+ }
3524
+ throw new LakeStorageError22(`[SixbDuckLake] No DuckLake changes were committed for dataset '${definition.id}', and no previous version exists.`);
2941
3525
  }
2942
3526
  async rollbackTransaction(runtime) {
2943
3527
  try {
@@ -2993,7 +3577,7 @@ class DuckLakeWriteCoordinator {
2993
3577
  return;
2994
3578
  }
2995
3579
  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"}'.`);
3580
+ throw new LakeStorageError22(`[SixbDuckLake] Optimistic ${input.operation} failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId ?? "none"}', found '${input.actualLatestVersionId ?? "none"}'.`);
2997
3581
  }
2998
3582
  }
2999
3583
  }
@@ -3011,7 +3595,7 @@ function commitRowCount(input, changeResult, latestVersion) {
3011
3595
  }
3012
3596
  function assertDuckLakeSnapshotId3(snapshotId) {
3013
3597
  if (!/^\d+$/.test(snapshotId)) {
3014
- throw new LakeStorageError20(`[SixbDuckLake] Invalid DuckLake snapshot id '${snapshotId}'.`);
3598
+ throw new LakeStorageError22(`[SixbDuckLake] Invalid DuckLake snapshot id '${snapshotId}'.`);
3015
3599
  }
3016
3600
  }
3017
3601
  var localCatalogCommitLocks = new Map;
@@ -3089,6 +3673,9 @@ class DuckLakeStorage {
3089
3673
  async beginWrite(input) {
3090
3674
  return this.writes.beginWrite(input);
3091
3675
  }
3676
+ async beginMerge(input) {
3677
+ return this.writes.beginMerge(input);
3678
+ }
3092
3679
  async getLatestVersion(datasetId) {
3093
3680
  return this.snapshotReader.getLatestVersion(datasetId);
3094
3681
  }
@@ -3109,4 +3696,4 @@ export {
3109
3696
  DuckLakeStorage
3110
3697
  };
3111
3698
 
3112
- //# debugId=0B56C838FAF193D864756E2164756E21
3699
+ //# debugId=36FC46708546A1DD64756E2164756E21