@sixb/ducklake 0.1.2 → 0.1.4

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.
package/README.md CHANGED
@@ -180,12 +180,28 @@ await merge.writeChanges([
180
180
  const result = await merge.commit()
181
181
  ```
182
182
 
183
- Changes remain ordered across `writeChanges(...)` calls, and the final change for a repeated key
183
+ For datasets without `sequenceBy`, changes remain ordered across `writeChanges(...)` calls, and the final change for a repeated key
184
184
  wins. Upserts are complete rows rather than patches. Commit checks the dataset version captured by
185
185
  `beginMerge`, applies effective changes atomically, and creates a version only when visible rows
186
186
  change. An initial no-op returns `{ outcome: "unchanged", version: null }`; a later no-op returns the
187
187
  existing latest version.
188
188
 
189
+ Datasets declaring `sequenceBy` instead compare source revisions: older changes are ignored,
190
+ identical ties are unchanged, and conflicting ties abort the merge. Deletes require
191
+ `change.delete(key, { sequence })`. Source revisions and deletion tombstones are stored in a
192
+ companion DuckLake table in the same transaction as visible rows, surviving snapshot expiration.
193
+ New tombstone state creates a version even when the visible row count stays zero. Sequenced
194
+ provider writes use merge sessions; snapshot syncs convert rows to ordered upserts.
195
+ Snapshot syncs use `commit({ createInitialVersion: true })` to make a first empty result
196
+ addressable. Later no-op commits reuse the current version, including after a concurrent write.
197
+ Use `commit({ retryOnConflict: true })` to rebase retained changes, with at most 3 attempts.
198
+ Explicit `expectedLatestVersionId` guards are never relaxed.
199
+ See [source ordering](../../docs/data/datasets.md#source-ordering).
200
+
201
+ Local catalogs serialize commits within one process. PostgreSQL catalogs support concurrent
202
+ writer processes; known snapshot-ID conflicts are retried from the retained staging table.
203
+ Other provider/connection failures propagate without an automatic retry.
204
+
189
205
  Snapshot, append, and SQL-transform writes to keyed datasets also enforce unique keys so a later
190
206
  merge never starts from an ambiguous baseline. Application authors normally use
191
207
  `defineSync(..., { mode: "merge" })`; the sync worker stages those changes through this contract.
@@ -270,6 +286,10 @@ new rows to the existing target. Omitting a source `versionId` resolves the late
270
286
  committed version at execution time; providing a `versionId` pins that source for
271
287
  repeatable transforms.
272
288
 
289
+ DuckDB promotes `sum(BIGINT)` results to `HUGEINT`. When the matching target column is a Sixb
290
+ `int64`, the executor applies a checked conversion back to `BIGINT` before comparing or writing
291
+ rows. Values outside the signed 64-bit range fail the transform instead of being truncated.
292
+
273
293
  ## Definition Updates
274
294
 
275
295
  DuckLake supports schema evolution, and this provider exposes a small safe
package/dist/index.js CHANGED
@@ -845,7 +845,11 @@ function incrementLocalCatalogGeneration(options) {
845
845
 
846
846
  // src/internal/ducklake-dataset-catalog.ts
847
847
  import { randomUUID } from "crypto";
848
- import { LakeStorageError as LakeStorageError11, planDatasetDefinitionUpdate } from "@sixb/core/lake-storage";
848
+ import {
849
+ assertDatasetDefinition,
850
+ LakeStorageError as LakeStorageError11,
851
+ planDatasetDefinitionUpdate
852
+ } from "@sixb/core/lake-storage";
849
853
 
850
854
  // src/internal/duckdb-row.ts
851
855
  import { LakeStorageError as LakeStorageError5 } from "@sixb/core/lake-storage";
@@ -1060,6 +1064,7 @@ function getIntegerField(record, key) {
1060
1064
 
1061
1065
  // src/internal/ducklake-catalog-schema.ts
1062
1066
  var SIXB_PRIMARY_KEY_COMMENT_NAMESPACE = "sixb:primary-key:";
1067
+ var DATASET_SEQUENCE_COLUMN_COMMENT = "sixb.sequenceBy.v1";
1063
1068
  var SIXB_PRIMARY_KEY_COMMENT_PREFIX = `${SIXB_PRIMARY_KEY_COMMENT_NAMESPACE}v1:`;
1064
1069
  var FILE_REF_STRUCT_CHILDREN = [
1065
1070
  { name: "blobId", type: "string" },
@@ -1241,6 +1246,9 @@ function decodeDatasetId(encoded) {
1241
1246
  function encodeDatasetTableName(datasetId) {
1242
1247
  return `${DATASET_TABLE_PREFIX}${encodeDatasetId(datasetId)}`;
1243
1248
  }
1249
+ function encodeDatasetSequenceTableName(datasetId) {
1250
+ return `sixb__seq__${encodeDatasetTableName(datasetId)}`;
1251
+ }
1244
1252
  function decodeDatasetTableName(tableName) {
1245
1253
  if (!tableName.startsWith(DATASET_TABLE_PREFIX)) {
1246
1254
  return null;
@@ -1264,12 +1272,14 @@ async function readCurrentDatasetDefinitions(input) {
1264
1272
  for (const table of tables) {
1265
1273
  const columns = columnsByTableName.get(table.tableName) ?? [];
1266
1274
  const primaryKey = duckLakeCatalogColumnsToDatasetPrimaryKey(table.tableName, columns);
1275
+ const sequenceBy = columns.find((column) => column.comment === DATASET_SEQUENCE_COLUMN_COMMENT)?.columnName;
1267
1276
  const partitionBy = partitionByTableName.get(table.tableName) ?? [];
1268
1277
  definitions.set(table.datasetId, {
1269
1278
  kind: "dataset",
1270
1279
  id: table.datasetId,
1271
1280
  schema: duckLakeCatalogColumnsToDatasetSchema(table.tableName, columns),
1272
1281
  ...primaryKey !== undefined ? { primaryKey } : {},
1282
+ ...sequenceBy !== undefined ? { sequenceBy } : {},
1273
1283
  ...partitionBy.length > 0 ? { partitionBy } : {},
1274
1284
  ...table.comment !== undefined ? { description: table.comment } : {}
1275
1285
  });
@@ -1428,6 +1438,10 @@ class DuckLakeDatasetCatalog {
1428
1438
  await runtime.run(`COMMENT ON TABLE ${qualifiedName} IS ${quoteSqlString(definition.description)}`);
1429
1439
  }
1430
1440
  await this.applyPrimaryKey(runtime, qualifiedName, definition);
1441
+ if (definition.sequenceBy !== undefined) {
1442
+ await runtime.run(`COMMENT ON COLUMN ${qualifiedName}.${quoteIdentifier(definition.sequenceBy)} IS ${quoteSqlString(DATASET_SEQUENCE_COLUMN_COMMENT)}`);
1443
+ await runtime.run(`CREATE TABLE ${qualifiedTableName(this.options, encodeDatasetSequenceTableName(definition.id))} (source_key VARCHAR NOT NULL, sequence VARCHAR NOT NULL, content VARCHAR)`);
1444
+ }
1431
1445
  await this.applyPartitionBy(runtime, qualifiedName, definition);
1432
1446
  await runtime.run("COMMIT");
1433
1447
  committed = true;
@@ -1512,9 +1526,7 @@ ${details}`);
1512
1526
  return duckLakeCatalogColumnsToDatasetSchema(tableName, columns);
1513
1527
  }
1514
1528
  assertSchema(definition) {
1515
- if (!definition.schema) {
1516
- throw new LakeStorageError11(`[SixbDuckLake] Dataset '${definition.id}' requires a schema for DuckLake storage.`);
1517
- }
1529
+ assertDatasetDefinition(definition, (message) => new LakeStorageError11(`[SixbDuckLake] ${message}`));
1518
1530
  }
1519
1531
  async applyExistingDatasetUpdate(plan) {
1520
1532
  const tableName = encodeDatasetTableName(plan.definition.id);
@@ -1765,7 +1777,7 @@ function isRecord(value) {
1765
1777
  return typeof value === "object" && value !== null && !Array.isArray(value);
1766
1778
  }
1767
1779
  function isDatasetProducer(value) {
1768
- return isRecord(value) && (value.kind === "sync" || value.kind === "pipeline") && optionalString(value.id) && optionalString(value.runId);
1780
+ return isRecord(value) && (value.kind === "sync" || value.kind === "pipeline" || value.kind === "ingest") && optionalString(value.id) && optionalString(value.runId);
1769
1781
  }
1770
1782
  function isDatasetVersionRefs(value) {
1771
1783
  return Array.isArray(value) && value.every((item) => isRecord(item) && typeof item.datasetId === "string" && typeof item.versionId === "string");
@@ -2126,6 +2138,7 @@ class DuckLakeSnapshotReader {
2126
2138
  return {
2127
2139
  datasetId: tableRef.datasetId,
2128
2140
  versionId: toVersionId(row.snapshotId),
2141
+ inputs: row.metadata?.inputs,
2129
2142
  ...row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {},
2130
2143
  ...row.metadata?.validatedPrimaryKeyColumns !== undefined ? { validatedPrimaryKeyColumns: row.metadata.validatedPrimaryKeyColumns } : {}
2131
2144
  };
@@ -2636,7 +2649,7 @@ class DuckLakeSqlExecutor {
2636
2649
  sources: sources.relations,
2637
2650
  sql: input.sql
2638
2651
  });
2639
- await this.assertResultSchemaMatchesTarget(runtime, target, sql);
2652
+ const checkedCasts = await this.assertResultSchemaMatchesTarget(runtime, target, sql);
2640
2653
  return this.writes.commitDatasetVersionOnExclusiveRuntime(runtime, {
2641
2654
  dataset: target,
2642
2655
  mode: input.mode,
@@ -2649,6 +2662,7 @@ class DuckLakeSqlExecutor {
2649
2662
  target,
2650
2663
  mode: input.mode,
2651
2664
  sql,
2665
+ checkedCasts,
2652
2666
  previousRowCount: context.previousRowCount
2653
2667
  })
2654
2668
  });
@@ -2708,6 +2722,7 @@ class DuckLakeSqlExecutor {
2708
2722
  const rows = await runtime.query(`DESCRIBE SELECT * FROM (${sql}) AS sixb_sql_transform_result_schema`);
2709
2723
  const actualColumns = rows.map((row) => resultColumnFromDescribeRow(row));
2710
2724
  const expectedColumns = target.schema.columns;
2725
+ const checkedCasts = [];
2711
2726
  if (actualColumns.length !== expectedColumns.length) {
2712
2727
  throwResultSchemaMismatch(target.id, `expected ${expectedColumns.length} columns, got ${actualColumns.length}`);
2713
2728
  }
@@ -2717,10 +2732,20 @@ class DuckLakeSqlExecutor {
2717
2732
  if (actual.name !== expected.name) {
2718
2733
  throwResultSchemaMismatch(target.id, `column ${index + 1} should be '${expected.name}', got '${actual.name}'`);
2719
2734
  }
2720
- if (actual.type !== expected.type) {
2721
- throwResultSchemaMismatch(target.id, `column '${expected.name}' should have type '${expected.type}', got '${actual.type}'`);
2735
+ const actualDatasetType = findDatasetColumnTypeForDuckDbSql(actual.typeSql);
2736
+ if (actualDatasetType === expected.type) {
2737
+ continue;
2738
+ }
2739
+ if (isCheckedInt64Cast(actual.typeSql, expected.type)) {
2740
+ checkedCasts.push({
2741
+ columnName: expected.name,
2742
+ targetTypeSql: datasetColumnTypeToDuckDbSql(expected.type)
2743
+ });
2744
+ continue;
2722
2745
  }
2746
+ throwResultSchemaMismatch(target.id, `column '${expected.name}' should have type '${expected.type}', got '${actualDatasetType ?? actual.typeSql}'`);
2723
2747
  }
2748
+ return checkedCasts;
2724
2749
  }
2725
2750
  async applySqlTransform(input) {
2726
2751
  const tempTableName = `sixb_sql_transform_${randomUUID3().replaceAll("-", "")}`;
@@ -2728,6 +2753,9 @@ class DuckLakeSqlExecutor {
2728
2753
  await input.runtime.run(`CREATE TEMP TABLE ${tempTable} AS SELECT * FROM (${input.sql}) AS sixb_sql_transform_result`);
2729
2754
  let result;
2730
2755
  try {
2756
+ for (const checkedCast of input.checkedCasts) {
2757
+ await input.runtime.run(`ALTER TABLE ${tempTable} ALTER COLUMN ${quoteIdentifier(checkedCast.columnName)} SET DATA TYPE ${checkedCast.targetTypeSql}`);
2758
+ }
2731
2759
  result = await applyDatasetRowsFromRelation({
2732
2760
  options: this.options,
2733
2761
  runtime: input.runtime,
@@ -2757,8 +2785,12 @@ function buildPreviewSql(sql, limit) {
2757
2785
  }
2758
2786
  function resultColumnFromDescribeRow(row) {
2759
2787
  const name = getString(row, "column_name");
2760
- const type = duckDbTypeToDatasetColumnType(getString(row, "column_type"));
2761
- return { name, type };
2788
+ const typeSql = getString(row, "column_type");
2789
+ return { name, typeSql };
2790
+ }
2791
+ function isCheckedInt64Cast(actualTypeSql, expectedType) {
2792
+ const normalizedType = actualTypeSql.trim().toUpperCase();
2793
+ return expectedType === "int64" && (normalizedType === "HUGEINT" || normalizedType === "INT128");
2762
2794
  }
2763
2795
  function normalizePreviewRow(row) {
2764
2796
  const normalized = {};
@@ -2795,10 +2827,21 @@ function throwResultSchemaMismatch(datasetId, detail) {
2795
2827
 
2796
2828
  // src/internal/ducklake-write-coordinator.ts
2797
2829
  import { randomUUID as randomUUID6 } from "crypto";
2798
- import { getDatasetPrimaryKeyColumns as getDatasetPrimaryKeyColumns4, LakeStorageError as LakeStorageError22 } from "@sixb/core/lake-storage";
2830
+ import {
2831
+ assertUnsequencedDatasetWrite,
2832
+ getDatasetPrimaryKeyColumns as getDatasetPrimaryKeyColumns4,
2833
+ hasDatasetInputChanges,
2834
+ LakeConcurrencyError,
2835
+ LakeStorageError as LakeStorageError22,
2836
+ retryDatasetMergeCommit
2837
+ } from "@sixb/core/lake-storage";
2799
2838
 
2800
2839
  // src/internal/dataset-row-merge.ts
2801
- import { getDatasetPrimaryKeyColumns as getDatasetPrimaryKeyColumns2, LakeStorageError as LakeStorageError19 } from "@sixb/core/lake-storage";
2840
+ import {
2841
+ getDatasetPrimaryKeyColumns as getDatasetPrimaryKeyColumns2,
2842
+ LakeStorageError as LakeStorageError19,
2843
+ reconcileDatasetSequences
2844
+ } from "@sixb/core/lake-storage";
2802
2845
  async function applyDatasetMergeFromRelation(input) {
2803
2846
  const primaryKeyColumns = getDatasetPrimaryKeyColumns2(input.dataset);
2804
2847
  if (primaryKeyColumns === null) {
@@ -2808,12 +2851,12 @@ async function applyDatasetMergeFromRelation(input) {
2808
2851
  const targetTable = qualifiedTableName(input.options, encodeDatasetTableName(input.dataset.id));
2809
2852
  const sourceRowCount = await countRows2(input.runtime, stagingTable);
2810
2853
  const previousRowCount = await currentBaselineRowCount(input, targetTable, primaryKeyColumns);
2811
- return withMaterializedFinalChanges(input, primaryKeyColumns, async (finalChanges) => {
2854
+ return withMaterializedFinalChanges(input, primaryKeyColumns, async (finalChanges, orderingChanged) => {
2812
2855
  const effects = await countMergeEffects(input, targetTable, finalChanges, primaryKeyColumns);
2813
2856
  const effectiveChangeCount = effects.inserts + effects.updates + effects.deletes;
2814
2857
  if (effectiveChangeCount === 0) {
2815
2858
  return {
2816
- dataChangeExpected: false,
2859
+ dataChangeExpected: orderingChanged,
2817
2860
  sourceRowCount,
2818
2861
  resultingRowCount: { kind: "exact", value: previousRowCount }
2819
2862
  };
@@ -2858,6 +2901,48 @@ async function applyDatasetMergeFromRelation(input) {
2858
2901
  };
2859
2902
  });
2860
2903
  }
2904
+ async function applySourceOrdering(input, acceptedTable) {
2905
+ const source = quoteIdentifier(`${input.sequenceColumnName}_source`);
2906
+ const ordinal = quoteIdentifier(input.sequenceColumnName);
2907
+ const staging = quoteIdentifier(input.stagingTableName);
2908
+ const stateTable = qualifiedTableName(input.options, encodeDatasetSequenceTableName(input.dataset.id));
2909
+ await input.runtime.run(`CREATE TEMP TABLE ${acceptedTable} (source_key VARCHAR, ordinal UBIGINT)`);
2910
+ let lastOrdinal = -1n;
2911
+ let changed = false;
2912
+ while (true) {
2913
+ const staged = await input.runtime.query(`SELECT ${ordinal} AS ordinal, ${source} AS source FROM ${staging}
2914
+ WHERE ${ordinal} > ${lastOrdinal} ORDER BY ${ordinal} LIMIT 1000`);
2915
+ if (staged.length === 0)
2916
+ return changed;
2917
+ const changes = staged.map((row) => JSON.parse(getString(row, "source")));
2918
+ lastOrdinal = getBigIntLike(staged[staged.length - 1], "ordinal");
2919
+ const batchKeys = changes.map((change) => quoteSqlString(change.key)).join(",");
2920
+ const current = await input.runtime.query(`SELECT * FROM ${stateTable} WHERE source_key IN (${batchKeys})`);
2921
+ const previous = new Map;
2922
+ for (const row of current) {
2923
+ previous.set(getString(row, "source_key"), {
2924
+ sequence: getString(row, "sequence"),
2925
+ content: getOptionalString(row, "content") ?? null
2926
+ });
2927
+ }
2928
+ const { states, accepted } = reconcileDatasetSequences(input.dataset, previous, changes);
2929
+ if (accepted.size === 0)
2930
+ continue;
2931
+ changed = true;
2932
+ const stateValues = [];
2933
+ const acceptedValues = [];
2934
+ for (const [key, index] of accepted) {
2935
+ const state = states.get(key);
2936
+ stateValues.push(`(${quoteSqlString(key)}, ${quoteSqlString(state.sequence)}, ${state.content === null ? "NULL" : quoteSqlString(state.content)})`);
2937
+ acceptedValues.push(`(${quoteSqlString(key)}, ${getBigIntLike(staged[index], "ordinal")})`);
2938
+ }
2939
+ const keys = [...accepted.keys()].map(quoteSqlString).join(",");
2940
+ await input.runtime.run(`DELETE FROM ${stateTable} WHERE source_key IN (${keys})`);
2941
+ await input.runtime.run(`INSERT INTO ${stateTable} VALUES ${stateValues.join(",")}`);
2942
+ await input.runtime.run(`DELETE FROM ${acceptedTable} WHERE source_key IN (${keys})`);
2943
+ await input.runtime.run(`INSERT INTO ${acceptedTable} VALUES ${acceptedValues.join(",")}`);
2944
+ }
2945
+ }
2861
2946
  async function currentBaselineRowCount(input, targetTable, primaryKeyColumns) {
2862
2947
  if (sameColumns(input.validatedPrimaryKeyColumns, primaryKeyColumns)) {
2863
2948
  return input.previousRowCount ?? countRows2(input.runtime, targetTable);
@@ -2871,18 +2956,23 @@ async function currentBaselineRowCount(input, targetTable, primaryKeyColumns) {
2871
2956
  }
2872
2957
  async function withMaterializedFinalChanges(input, primaryKeyColumns, run) {
2873
2958
  const table = quoteIdentifier(`${input.stagingTableName}_final`);
2874
- await input.runtime.run(`
2875
- CREATE TEMP TABLE ${table} AS
2876
- ${finalChangesSelectSql(input, primaryKeyColumns)}
2877
- `);
2959
+ const acceptedTable = quoteIdentifier(`${input.stagingTableName}_accepted`);
2878
2960
  let outcome;
2879
2961
  try {
2880
- outcome = { kind: "success", value: await run(table) };
2962
+ const sequenced = input.dataset.sequenceBy !== undefined;
2963
+ const orderingChanged = sequenced ? await applySourceOrdering(input, acceptedTable) : false;
2964
+ const ordinal = quoteIdentifier(input.sequenceColumnName);
2965
+ const selectSql = sequenced ? `SELECT source.*, source.${ordinal} AS ${quoteIdentifier(firstSequenceName(input.sequenceColumnName))}
2966
+ FROM ${quoteIdentifier(input.stagingTableName)} AS source
2967
+ JOIN ${acceptedTable} AS accepted ON source.${ordinal} = accepted.ordinal` : finalChangesSelectSql(input, primaryKeyColumns);
2968
+ await input.runtime.run(`CREATE TEMP TABLE ${table} AS ${selectSql}`);
2969
+ outcome = { kind: "success", value: await run(table, orderingChanged) };
2881
2970
  } catch (error) {
2882
2971
  outcome = { kind: "error", error };
2883
2972
  }
2884
2973
  try {
2885
2974
  await input.runtime.run(`DROP TABLE IF EXISTS ${table}`);
2975
+ await input.runtime.run(`DROP TABLE IF EXISTS ${acceptedTable}`);
2886
2976
  } catch (cleanupError) {
2887
2977
  if (outcome.kind === "success") {
2888
2978
  throw cleanupError;
@@ -2968,6 +3058,7 @@ function sameColumns(left, right) {
2968
3058
  import { randomUUID as randomUUID4 } from "crypto";
2969
3059
  import {
2970
3060
  cloneDatasetMergeChange,
3061
+ datasetSequenceChange,
2971
3062
  getDatasetMergeChangeValidationError,
2972
3063
  LakeStorageError as LakeStorageError20
2973
3064
  } from "@sixb/core/lake-storage";
@@ -3036,6 +3127,7 @@ async function createDuckLakeMergeSession(input) {
3036
3127
  CREATE TEMP TABLE ${quoteIdentifier(stagingTableName)} (
3037
3128
  ${quoteIdentifier(sequenceColumnName)} UBIGINT NOT NULL,
3038
3129
  ${quoteIdentifier(kindColumnName)} VARCHAR NOT NULL,
3130
+ ${input.merge.dataset.sequenceBy === undefined ? "" : `${quoteIdentifier(`${sequenceColumnName}_source`)} VARCHAR NOT NULL,`}
3039
3131
  ${datasetSchemaToDuckDbNullableColumnsSql(input.merge.dataset.schema)}
3040
3132
  )
3041
3133
  `);
@@ -3091,6 +3183,9 @@ class DuckLakeMergeSession {
3091
3183
  for (const staged of batch) {
3092
3184
  appender.appendBigInt(staged.sequence);
3093
3185
  appender.appendVarchar(staged.change.kind);
3186
+ if (this.input.dataset.sequenceBy !== undefined) {
3187
+ appender.appendVarchar(JSON.stringify(datasetSequenceChange(this.input.dataset, staged.change)));
3188
+ }
3094
3189
  appendDatasetRow(appender, this.input.dataset.schema, staged.change.kind === "upsert" ? staged.change.row : staged.change.key);
3095
3190
  }
3096
3191
  });
@@ -3279,6 +3374,7 @@ class DuckLakeWriteCoordinator {
3279
3374
  throw new LakeStorageError22(`[SixbDuckLake] Unknown dataset '${input.dataset.id}'.`);
3280
3375
  }
3281
3376
  this.datasets.assertSchema(definition);
3377
+ assertUnsequencedDatasetWrite(definition);
3282
3378
  const mode = input.mode ?? "snapshot";
3283
3379
  assertDatasetWriteMode(mode, "write");
3284
3380
  const runtime = await this.connections.stagingRuntime();
@@ -3304,7 +3400,7 @@ class DuckLakeWriteCoordinator {
3304
3400
  this.datasets.assertSchema(definition);
3305
3401
  const latestVersion = await this.connections.withAttachedRuntime((runtime2) => this.snapshots.getLatestVersionForDefinition(runtime2, definition));
3306
3402
  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"}'.`);
3403
+ throw new LakeConcurrencyError(`[SixbDuckLake] Optimistic merge start failed for dataset '${definition.id}': expected latest version '${input.expectedLatestVersionId}', found '${latestVersion?.versionId ?? "none"}'.`);
3308
3404
  }
3309
3405
  const runtime = await this.connections.stagingRuntime();
3310
3406
  return createDuckLakeMergeSession({
@@ -3343,32 +3439,37 @@ class DuckLakeWriteCoordinator {
3343
3439
  if (getDatasetPrimaryKeyColumns4(definition) === null) {
3344
3440
  throw new LakeStorageError22(`[SixbDuckLake] Dataset '${definition.id}' must define a primaryKey before it can be merged.`);
3345
3441
  }
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.`);
3442
+ return retryDatasetMergeCommit(input.merge, input.commit, (rebase) => this.withCommitRuntime(async (runtime) => {
3443
+ input.commit?.signal?.throwIfAborted();
3444
+ const baseVersionId = rebase ? (await this.snapshots.getLatestVersionForDefinition(runtime, definition))?.versionId ?? null : input.baseVersionId;
3445
+ return this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
3446
+ dataset: definition,
3447
+ mode: "merge",
3448
+ expectedLatestVersionId: baseVersionId,
3449
+ allowInitialNoOp: !input.commit?.createInitialVersion,
3450
+ signal: input.commit?.signal,
3451
+ disableRetries: true,
3452
+ optimisticOperation: "merge commit",
3453
+ commitMessage: input.commit?.commitMessage ?? `merge dataset ${definition.id}`,
3454
+ producer: input.merge.producer,
3455
+ inputs: input.merge.inputs,
3456
+ applyChanges: async (commitRuntime, context) => {
3457
+ const result = await applyDatasetMergeFromRelation({
3458
+ options: this.options,
3459
+ runtime: commitRuntime,
3460
+ dataset: definition,
3461
+ stagingTableName: input.stagingTableName,
3462
+ sequenceColumnName: input.sequenceColumnName,
3463
+ kindColumnName: input.kindColumnName,
3464
+ previousRowCount: context.previousRowCount,
3465
+ validatedPrimaryKeyColumns: context.validatedPrimaryKeyColumns
3466
+ });
3467
+ if (result.sourceRowCount !== input.changesWritten) {
3468
+ 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.`);
3469
+ }
3470
+ return result;
3369
3471
  }
3370
- return result;
3371
- }
3472
+ });
3372
3473
  }));
3373
3474
  }
3374
3475
  async commitDatasetVersion(input) {
@@ -3380,6 +3481,7 @@ class DuckLakeWriteCoordinator {
3380
3481
  });
3381
3482
  }
3382
3483
  async commitDatasetVersionOnExclusiveRuntime(runtime, input) {
3484
+ assertUnsequencedDatasetWrite(input.dataset);
3383
3485
  const result = await this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
3384
3486
  ...input,
3385
3487
  disableRetries: input.mode === "append" && getDatasetPrimaryKeyColumns4(input.dataset) !== null
@@ -3432,6 +3534,8 @@ class DuckLakeWriteCoordinator {
3432
3534
  previousRowCount: latestVersion?.rowCount,
3433
3535
  validatedPrimaryKeyColumns: latestVersion?.validatedPrimaryKeyColumns
3434
3536
  });
3537
+ input.signal?.throwIfAborted();
3538
+ await this.ensureNoOpHasSnapshot(input, changeResult, latestVersion);
3435
3539
  const commitId = randomUUID6();
3436
3540
  await this.setCommitMetadata(input, commitId, commitRowCount(input, changeResult, latestVersion));
3437
3541
  await input.runtime.run("COMMIT");
@@ -3451,6 +3555,9 @@ class DuckLakeWriteCoordinator {
3451
3555
  } catch (error) {
3452
3556
  if (!committed) {
3453
3557
  await this.rollbackTransaction(input.runtime);
3558
+ if (error instanceof Error && error.message.startsWith("TransactionContext Error: Failed to commit: Failed to commit DuckLake transaction.") && error.message.includes("Exceeded the maximum retry count of 0") && error.message.includes('duplicate key value violates unique constraint "ducklake_snapshot_pkey"')) {
3559
+ throw new LakeConcurrencyError(`[SixbDuckLake] Concurrent catalog commit for dataset '${input.dataset.id}'.`, { cause: error });
3560
+ }
3454
3561
  }
3455
3562
  throw error;
3456
3563
  }
@@ -3513,6 +3620,18 @@ class DuckLakeWriteCoordinator {
3513
3620
  };
3514
3621
  await input.runtime.run(`CALL ${quoteIdentifier(duckLakeAlias(this.options))}.set_commit_message(${quoteSqlString("Sixb")}, ${quoteSqlString(input.commitMessage)}, extra_info => ${quoteSqlString(JSON.stringify({ sixb: metadata }))})`);
3515
3622
  }
3623
+ async ensureNoOpHasSnapshot(input, changeResult, knownLatestVersion) {
3624
+ if (changeResult.dataChangeExpected) {
3625
+ return;
3626
+ }
3627
+ const latestVersion = knownLatestVersion ?? await this.snapshots.getLatestVersionSummaryForDefinition(input.runtime, input.dataset);
3628
+ if (latestVersion ? input.mode === "merge" || !hasDatasetInputChanges(latestVersion.inputs, input.inputs) : input.allowInitialNoOp) {
3629
+ return;
3630
+ }
3631
+ const table = qualifiedTableName(this.options, encodeDatasetTableName(input.dataset.id));
3632
+ const descriptionSql = input.dataset.description === undefined ? "NULL" : quoteSqlString(input.dataset.description);
3633
+ await input.runtime.run(`COMMENT ON TABLE ${table} IS ${descriptionSql}`);
3634
+ }
3516
3635
  async versionForNoOpCommit(runtime, definition, allowInitialNoOp) {
3517
3636
  const latestVersion = await this.snapshots.getLatestVersionForDefinition(runtime, definition);
3518
3637
  if (latestVersion) {
@@ -3577,7 +3696,7 @@ class DuckLakeWriteCoordinator {
3577
3696
  return;
3578
3697
  }
3579
3698
  if (input.actualLatestVersionId !== input.expectedLatestVersionId) {
3580
- throw new LakeStorageError22(`[SixbDuckLake] Optimistic ${input.operation} failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId ?? "none"}', found '${input.actualLatestVersionId ?? "none"}'.`);
3699
+ throw new LakeConcurrencyError(`[SixbDuckLake] Optimistic ${input.operation} failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId ?? "none"}', found '${input.actualLatestVersionId ?? "none"}'.`);
3581
3700
  }
3582
3701
  }
3583
3702
  }
@@ -3696,4 +3815,4 @@ export {
3696
3815
  DuckLakeStorage
3697
3816
  };
3698
3817
 
3699
- //# debugId=36FC46708546A1DD64756E2164756E21
3818
+ //# debugId=2A2E6355256FAE5864756E2164756E21