@sixb/ducklake 0.2.0 → 0.2.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.
package/README.md CHANGED
@@ -102,6 +102,10 @@ budget and PostgreSQL metadata pool. Metadata and writes remain available betwee
102
102
  Each read stays pinned to one immutable snapshot. Maintenance waits until no reads remain; it
103
103
  blocks new reads only while running, so continuous traffic can postpone it.
104
104
 
105
+ `readChanges` compares two versions in DuckDB. When no version between them changed rows
106
+ (`rowsChanged` in the commit metadata, read from at most 32 snapshots), it returns an empty change
107
+ set without reading the table.
108
+
105
109
  `maxStreamingReads` limits native streaming readers per provider (default: `4`); extra reads use
106
110
  bounded pages on the main connection. It does not limit pipeline inputs. Raise it when memory and
107
111
  catalog capacity allow. Finish the iterator, use `for await` with `break`, or pass `signal` to cancel
@@ -190,8 +194,10 @@ Datasets declaring `sequenceBy` instead compare source revisions: older changes
190
194
  identical ties are unchanged, and conflicting ties abort the merge. Deletes require
191
195
  `change.delete(key, { sequence })`. Source revisions and deletion tombstones are stored in a
192
196
  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.
197
+ New tombstone state creates a version even when the visible rows stay the same; such a version
198
+ records `rowsChanged: false`. So does a newer upsert that differs from the stored row only in its
199
+ `sequenceBy` column: the row is kept and only its source revision advances. Sequenced provider
200
+ writes use merge sessions; snapshot syncs convert rows to ordered upserts.
195
201
  Snapshot syncs use `commit({ createInitialVersion: true })` to make a first empty result
196
202
  addressable. Later no-op commits reuse the current version, including after a concurrent write.
197
203
  Use `commit({ retryOnConflict: true })` to rebase retained changes, with at most 3 attempts.
package/dist/index.js CHANGED
@@ -647,6 +647,7 @@ function parseCommitMetadata(value) {
647
647
  const producer = parseDatasetProducer(parsed.sixb.producer);
648
648
  const rowCount = parsed.sixb.rowCount;
649
649
  const validatedPrimaryKeyColumns = parsed.sixb.validatedPrimaryKeyColumns;
650
+ const { rowsChanged, lineageChanged } = parsed.sixb;
650
651
  return {
651
652
  kind: "datasetVersion",
652
653
  datasetId: parsed.sixb.datasetId,
@@ -657,7 +658,9 @@ function parseCommitMetadata(value) {
657
658
  ...typeof commitId === "string" && isRowCount(rowCount) ? { rowCount } : {},
658
659
  ...typeof commitId === "string" && isValidatedPrimaryKeyColumns(validatedPrimaryKeyColumns) ? { validatedPrimaryKeyColumns } : {},
659
660
  ...isSchemaChangeMetadata(parsed.sixb.schemaChange) ? { schemaChange: parsed.sixb.schemaChange } : {},
660
- ...parsed.sixb.lineage === undefined ? {} : { lineage: parsed.sixb.lineage }
661
+ ...parsed.sixb.lineage === undefined ? {} : { lineage: parsed.sixb.lineage },
662
+ ...typeof rowsChanged === "boolean" ? { rowsChanged } : {},
663
+ ...typeof lineageChanged === "boolean" ? { lineageChanged } : {}
661
664
  };
662
665
  }
663
666
  function changesTouchTables(changesMade, tableIds) {
@@ -729,6 +732,7 @@ class DuckLakeChangeReader {
729
732
  const columns = resolveDatasetChangeColumns(input, before, after);
730
733
  if (!columns)
731
734
  return null;
735
+ const unchanged = (input.toVersionId === input.fromVersionId || !after.rowsChanged) && await this.snapshots.holdsRowsOf(input.datasetId, input.toVersionId, input.fromVersionId);
732
736
  const lease = await this.connections.acquireAttachedRuntime();
733
737
  const runtime = lease.runtime;
734
738
  const temporary = quoteIdentifier(`sixb_delta_${randomUUID().replaceAll("-", "")}`);
@@ -741,6 +745,9 @@ class DuckLakeChangeReader {
741
745
  const table = qualifiedTableName(this.options, ref.tableName);
742
746
  const oldRelation = `${table} AT (VERSION => ${parseVersionId(input.fromVersionId)})`;
743
747
  const newRelation = `${table} AT (VERSION => ${parseVersionId(input.toVersionId)})`;
748
+ if (unchanged) {
749
+ return noChanges(after.rowCount ?? before.rowCount ?? await countRows(runtime, newRelation));
750
+ }
744
751
  const previousCount = await uniqueRowCount(runtime, oldRelation, input.keyColumns);
745
752
  const nextCount = await uniqueRowCount(runtime, newRelation, input.keyColumns);
746
753
  if (previousCount === null || nextCount === null)
@@ -806,6 +813,21 @@ class DuckLakeChangeReader {
806
813
  }
807
814
  }
808
815
  }
816
+ function noChanges(rowCount) {
817
+ return {
818
+ fromRowCount: rowCount,
819
+ toRowCount: rowCount,
820
+ changeCount: 0,
821
+ changes: async function* () {
822
+ yield* [];
823
+ }(),
824
+ async close() {}
825
+ };
826
+ }
827
+ async function countRows(runtime, relation) {
828
+ const [row] = await runtime.query(`SELECT count(*) AS total FROM ${relation}`);
829
+ return safeCount(row, "total");
830
+ }
809
831
  async function uniqueRowCount(runtime, relation, keys) {
810
832
  const names = keys.map(quoteIdentifier);
811
833
  const invalid = names.map((name) => `${name} IS NULL OR trim(${name}) = ''`).join(" OR ");
@@ -2619,6 +2641,7 @@ import { LakeStorageError as LakeStorageError19 } from "@sixb/core/lake-storage"
2619
2641
  var SNAPSHOT_ROW_BATCH_SIZE = 128;
2620
2642
  var FEED_PAGE_SIZE = 512;
2621
2643
  var SHORT_READ_BATCH_SIZE = 8;
2644
+ var UNCHANGED_ROWS_WALK_LIMIT = 32;
2622
2645
  var CATALOG_SNAPSHOT_SCAN_LIMIT = 512;
2623
2646
 
2624
2647
  class DuckLakeSnapshotReader {
@@ -2661,6 +2684,33 @@ class DuckLakeSnapshotReader {
2661
2684
  return this.getVersionForTableRef(runtime, tableRef, snapshotId);
2662
2685
  });
2663
2686
  }
2687
+ async holdsRowsOf(datasetId, versionId, earlierVersionId) {
2688
+ const snapshotId = parseVersionId(versionId);
2689
+ const earlierSnapshotId = parseVersionId(earlierVersionId);
2690
+ if (snapshotId === earlierSnapshotId) {
2691
+ return true;
2692
+ }
2693
+ if (BigInt(snapshotId) < BigInt(earlierSnapshotId)) {
2694
+ return false;
2695
+ }
2696
+ this.connections.assertOpen();
2697
+ return this.connections.withAttachedRuntime(async (runtime) => {
2698
+ const tableRef = await resolveDatasetTableRef(this.options, runtime, datasetId);
2699
+ if (!tableRef) {
2700
+ return false;
2701
+ }
2702
+ const candidates = await this.querySnapshotCandidates(runtime, {
2703
+ table: tableRef,
2704
+ beforeSnapshotId: String(BigInt(snapshotId) + 1n),
2705
+ fromSnapshotId: earlierSnapshotId,
2706
+ limit: UNCHANGED_ROWS_WALK_LIMIT + 1
2707
+ });
2708
+ if (candidates[0]?.snapshotId !== snapshotId || candidates[candidates.length - 1]?.snapshotId !== earlierSnapshotId) {
2709
+ return false;
2710
+ }
2711
+ return candidates.slice(0, -1).every((candidate) => this.candidateToSnapshotRow(datasetId, tableRef.tableId, candidate)?.rowsChanged === false);
2712
+ });
2713
+ }
2664
2714
  async listDatasetCatalogState(datasetIds) {
2665
2715
  this.connections.assertOpen();
2666
2716
  if (datasetIds.length === 0) {
@@ -3101,6 +3151,9 @@ class DuckLakeSnapshotReader {
3101
3151
  if (input.beforeSnapshotId !== undefined) {
3102
3152
  assertDuckLakeSnapshotId2(input.beforeSnapshotId);
3103
3153
  }
3154
+ if (input.fromSnapshotId !== undefined) {
3155
+ assertDuckLakeSnapshotId2(input.fromSnapshotId);
3156
+ }
3104
3157
  const rows = await runtime.query(buildDuckLakeMetadataQuery(this.options, (table) => {
3105
3158
  const bounds = [
3106
3159
  `changes.snapshot_id >= (
@@ -3115,6 +3168,9 @@ class DuckLakeSnapshotReader {
3115
3168
  if (input.beforeSnapshotId !== undefined) {
3116
3169
  bounds.push(`changes.snapshot_id < ${input.beforeSnapshotId}`);
3117
3170
  }
3171
+ if (input.fromSnapshotId !== undefined) {
3172
+ bounds.push(`changes.snapshot_id >= ${input.fromSnapshotId}`);
3173
+ }
3118
3174
  const namesDatasetTable = datasetTableIds(input.table).map((tableId) => `strpos(',' || changes.changes_made || ',', ${quoteSqlString(`:${tableId},`)}) > 0`).join(" OR ");
3119
3175
  return `
3120
3176
  SELECT
@@ -3175,6 +3231,8 @@ class DuckLakeSnapshotReader {
3175
3231
  tableId,
3176
3232
  createdAt: candidate.createdAt,
3177
3233
  mode: metadata.mode ?? "schema",
3234
+ rowsChanged: metadata.rowsChanged ?? true,
3235
+ lineageChanged: metadata.lineageChanged ?? true,
3178
3236
  metadata
3179
3237
  };
3180
3238
  }
@@ -3184,6 +3242,8 @@ class DuckLakeSnapshotReader {
3184
3242
  tableId,
3185
3243
  createdAt: candidate.createdAt,
3186
3244
  mode: candidate.metadata?.mode ?? (hasDeleteChange ? "snapshot" : "append"),
3245
+ rowsChanged: true,
3246
+ lineageChanged: candidate.metadata?.lineageChanged ?? true,
3187
3247
  ...candidate.metadata !== undefined ? { metadata: candidate.metadata } : {}
3188
3248
  };
3189
3249
  }
@@ -3209,6 +3269,8 @@ function snapshotToVersion(tableRef, row, schema) {
3209
3269
  producer: row.metadata?.producer,
3210
3270
  inputs: row.metadata?.inputs,
3211
3271
  ...storedLineage(tableRef.datasetId, row),
3272
+ rowsChanged: row.rowsChanged,
3273
+ lineageChanged: row.lineageChanged,
3212
3274
  ...row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}
3213
3275
  };
3214
3276
  }
@@ -3306,7 +3368,7 @@ async function applyDatasetRowsFromRelation(input) {
3306
3368
  changedTableRows
3307
3369
  };
3308
3370
  }
3309
- const sourceRowCount = await countRows(input.runtime, input.sourceRelationSql);
3371
+ const sourceRowCount = await countRows2(input.runtime, input.sourceRelationSql);
3310
3372
  const knownRowCount = baselineRowCount ?? input.previousRowCount;
3311
3373
  const targetWasEmpty = knownRowCount === undefined ? await isEmptyRelation(input.runtime, table) : knownRowCount === 0;
3312
3374
  await input.runtime.run(`INSERT INTO ${table} (${columnsSql}) SELECT ${columnsSql} FROM ${input.sourceRelationSql}`);
@@ -3376,7 +3438,7 @@ async function fingerprintRelation(runtime, relationSql, columnsSql) {
3376
3438
  function sameFingerprint(left, right) {
3377
3439
  return left.rowCount === right.rowCount && left.hashSum === right.hashSum && left.hashXor === right.hashXor;
3378
3440
  }
3379
- async function countRows(runtime, relationSql) {
3441
+ async function countRows2(runtime, relationSql) {
3380
3442
  const [row] = await runtime.query(`SELECT count(*) AS row_count FROM ${relationSql}`);
3381
3443
  if (row === undefined) {
3382
3444
  return 0;
@@ -4707,6 +4769,7 @@ import { randomUUID as randomUUID8 } from "crypto";
4707
4769
  import {
4708
4770
  assertUnsequencedDatasetWrite,
4709
4771
  getDatasetPrimaryKeyColumns as getDatasetPrimaryKeyColumns4,
4772
+ hasDatasetLineageChanges,
4710
4773
  hasDatasetProvenanceChanges,
4711
4774
  LakeConcurrencyError as LakeConcurrencyError2,
4712
4775
  LakeStorageError as LakeStorageError26,
@@ -4727,7 +4790,7 @@ async function applyDatasetMergeFromRelation(input) {
4727
4790
  }
4728
4791
  const stagingTable = quoteIdentifier(input.stagingTableName);
4729
4792
  const targetTable = qualifiedTableName(input.options, encodeDatasetTableName(input.dataset.id));
4730
- const sourceRowCount = await countRows2(input.runtime, stagingTable);
4793
+ const sourceRowCount = await countRows3(input.runtime, stagingTable);
4731
4794
  const previousRowCount = await currentBaselineRowCount(input, targetTable, primaryKeyColumns);
4732
4795
  return withMaterializedFinalChanges(input, primaryKeyColumns, async (finalChanges, orderingChanged) => {
4733
4796
  const effects = await countMergeEffects(input, targetTable, finalChanges, primaryKeyColumns);
@@ -4827,7 +4890,7 @@ async function applySourceOrdering(input, acceptedTable) {
4827
4890
  }
4828
4891
  async function currentBaselineRowCount(input, targetTable, primaryKeyColumns) {
4829
4892
  if (sameColumns(input.validatedPrimaryKeyColumns, primaryKeyColumns)) {
4830
- return input.previousRowCount ?? countRows2(input.runtime, targetTable);
4893
+ return input.previousRowCount ?? countRows3(input.runtime, targetTable);
4831
4894
  }
4832
4895
  return inspectUniqueKeyedRelation({
4833
4896
  runtime: input.runtime,
@@ -4920,9 +4983,10 @@ function primaryKeyMatchSql(primaryKeyColumns, leftAlias, rightAlias) {
4920
4983
  return primaryKeyColumns.map((column) => `${leftAlias}.${quoteIdentifier(column)} = ${rightAlias}.${quoteIdentifier(column)}`).join(" AND ");
4921
4984
  }
4922
4985
  function datasetRowDifferenceSql(dataset, leftAlias, rightAlias) {
4923
- return dataset.schema.columns.map((column) => `${leftAlias}.${quoteIdentifier(column.name)} IS DISTINCT FROM ${rightAlias}.${quoteIdentifier(column.name)}`).join(" OR ");
4986
+ const differences = dataset.schema.columns.filter((column) => column.name !== dataset.sequenceBy).map((column) => `${leftAlias}.${quoteIdentifier(column.name)} IS DISTINCT FROM ${rightAlias}.${quoteIdentifier(column.name)}`);
4987
+ return differences.length > 0 ? differences.join(" OR ") : "FALSE";
4924
4988
  }
4925
- async function countRows2(runtime, relationSql) {
4989
+ async function countRows3(runtime, relationSql) {
4926
4990
  const [row] = await runtime.query(`SELECT count(*) AS row_count FROM ${relationSql}`);
4927
4991
  return row === undefined ? 0 : Number(getBigIntLike(row, "row_count"));
4928
4992
  }
@@ -5399,13 +5463,22 @@ class DuckLakeWriteCoordinator {
5399
5463
  validatedPrimaryKeyColumns: latestVersion?.validatedPrimaryKeyColumns
5400
5464
  });
5401
5465
  input.signal?.throwIfAborted();
5402
- const noOpBase = changeResult.dataChangeExpected ? undefined : latestVersion === undefined ? await this.latestVersionAtReadSnapshot(input) : latestVersion;
5466
+ const parent = latestVersion === undefined ? await this.latestVersionAtReadSnapshot(input) : latestVersion;
5467
+ const noOpBase = changeResult.dataChangeExpected ? undefined : parent;
5403
5468
  const createsVersion = noOpBase === undefined || this.noOpCreatesVersion(input, noOpBase, lineage);
5404
5469
  if (createsVersion && mustClaimTable(input, changeResult, lineage)) {
5405
5470
  await this.claimTable(input);
5406
5471
  }
5407
5472
  const commitId = randomUUID8();
5408
- await this.setCommitMetadata(input, commitId, commitRowCount(input, changeResult, latestVersion), lineage);
5473
+ await this.setCommitMetadata(input, commitId, commitRowCount(input, changeResult, latestVersion), lineage, {
5474
+ rowsChanged: parent === null || changeResult.changedTableRows,
5475
+ lineageChanged: hasDatasetLineageChanges(parent, {
5476
+ datasetId: input.dataset.id,
5477
+ schema: input.dataset.schema,
5478
+ producer: input.producer,
5479
+ lineage
5480
+ })
5481
+ });
5409
5482
  await commitVersionTransaction(input.runtime, input.dataset.id, createsVersion && mustCommitOnReadSnapshot(input, changeResult) ? { poisonRuntime: () => this.connections.poisonRuntime() } : undefined);
5410
5483
  committed = true;
5411
5484
  const committedWriteSnapshotId = await this.lastCommittedSnapshotId(input.runtime);
@@ -5475,7 +5548,7 @@ class DuckLakeWriteCoordinator {
5475
5548
  }
5476
5549
  return result;
5477
5550
  }
5478
- async setCommitMetadata(input, commitId, rowCount, lineage) {
5551
+ async setCommitMetadata(input, commitId, rowCount, lineage, changed) {
5479
5552
  const validatedPrimaryKeyColumns = getDatasetPrimaryKeyColumns4(input.dataset);
5480
5553
  const metadata = {
5481
5554
  kind: "datasetVersion",
@@ -5486,7 +5559,9 @@ class DuckLakeWriteCoordinator {
5486
5559
  inputs: input.inputs ? structuredClone(input.inputs) : undefined,
5487
5560
  ...rowCount.kind === "exact" ? { rowCount: rowCount.value } : {},
5488
5561
  ...validatedPrimaryKeyColumns !== null ? { validatedPrimaryKeyColumns: [...validatedPrimaryKeyColumns] } : {},
5489
- ...lineage === undefined ? {} : { lineage: encodeLineage(lineage) }
5562
+ ...lineage === undefined ? {} : { lineage: encodeLineage(lineage) },
5563
+ rowsChanged: changed.rowsChanged,
5564
+ lineageChanged: changed.lineageChanged
5490
5565
  };
5491
5566
  await input.runtime.run(`CALL ${quoteIdentifier(duckLakeAlias(this.options))}.set_commit_message(${quoteSqlString("Sixb")}, ${quoteSqlString(input.commitMessage)}, extra_info => ${quoteSqlString(JSON.stringify({ sixb: metadata }))})`);
5492
5567
  }
@@ -5737,4 +5812,4 @@ export {
5737
5812
  DuckLakeStorage
5738
5813
  };
5739
5814
 
5740
- //# debugId=9F26B0918A26BCC464756E2164756E21
5815
+ //# debugId=8FEE4C4A10B2BA3064756E2164756E21