diff --git a/Cargo.lock b/Cargo.lock index 2e2aafc6521..8c5bbc5296c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "dbsp" -version = "0.284.0" +version = "0.285.0" dependencies = [ "anyhow", "arc-swap", @@ -3849,7 +3849,7 @@ dependencies = [ [[package]] name = "dbsp_adapters" -version = "0.284.0" +version = "0.285.0" dependencies = [ "actix", "actix-codec", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "dbsp_nexmark" -version = "0.284.0" +version = "0.285.0" dependencies = [ "anyhow", "ascii_table", @@ -4861,7 +4861,7 @@ dependencies = [ [[package]] name = "fda" -version = "0.284.0" +version = "0.285.0" dependencies = [ "anyhow", "arrow", @@ -4913,7 +4913,7 @@ dependencies = [ [[package]] name = "feldera-adapterlib" -version = "0.284.0" +version = "0.285.0" dependencies = [ "actix-web", "anyhow", @@ -4944,7 +4944,7 @@ dependencies = [ [[package]] name = "feldera-buffer-cache" -version = "0.284.0" +version = "0.285.0" dependencies = [ "crossbeam-utils", "enum-map", @@ -4972,7 +4972,7 @@ dependencies = [ [[package]] name = "feldera-datagen" -version = "0.284.0" +version = "0.285.0" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -4998,7 +4998,7 @@ dependencies = [ [[package]] name = "feldera-fxp" -version = "0.284.0" +version = "0.285.0" dependencies = [ "bytecheck", "dbsp", @@ -5018,7 +5018,7 @@ dependencies = [ [[package]] name = "feldera-iceberg" -version = "0.284.0" +version = "0.285.0" dependencies = [ "anyhow", "chrono", @@ -5038,7 +5038,7 @@ dependencies = [ [[package]] name = "feldera-ir" -version = "0.284.0" +version = "0.285.0" dependencies = [ "proptest", "proptest-derive", @@ -5050,7 +5050,7 @@ dependencies = [ [[package]] name = "feldera-macros" -version = "0.284.0" +version = "0.285.0" dependencies = [ "prettyplease", "proc-macro2", @@ -5060,7 +5060,7 @@ dependencies = [ [[package]] name = "feldera-observability" -version = "0.284.0" +version = "0.285.0" dependencies = [ "actix-http", "awc", @@ -5075,7 +5075,7 @@ dependencies = [ [[package]] name = "feldera-rest-api" -version = "0.284.0" +version = "0.285.0" dependencies = [ "chrono", "feldera-observability", @@ -5109,7 +5109,7 @@ dependencies = [ [[package]] name = "feldera-sqllib" -version = "0.284.0" +version = "0.285.0" dependencies = [ "arcstr", "base58", @@ -5150,7 +5150,7 @@ dependencies = [ [[package]] name = "feldera-storage" -version = "0.284.0" +version = "0.285.0" dependencies = [ "anyhow", "crossbeam", @@ -5173,7 +5173,7 @@ dependencies = [ [[package]] name = "feldera-types" -version = "0.284.0" +version = "0.285.0" dependencies = [ "actix-web", "anyhow", @@ -8094,7 +8094,7 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pipeline-manager" -version = "0.284.0" +version = "0.285.0" dependencies = [ "actix-cors", "actix-files", @@ -9188,7 +9188,7 @@ dependencies = [ [[package]] name = "readers" -version = "0.284.0" +version = "0.285.0" dependencies = [ "async-std", "csv", @@ -10764,7 +10764,7 @@ dependencies = [ [[package]] name = "sltsqlvalue" -version = "0.284.0" +version = "0.285.0" dependencies = [ "dbsp", "feldera-sqllib", @@ -11067,7 +11067,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storage-test-compat" -version = "0.284.0" +version = "0.285.0" dependencies = [ "dbsp", "derive_more 1.0.0", diff --git a/Cargo.toml b/Cargo.toml index d1290585a9c..123c01c0980 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace.package] authors = ["Feldera Team "] -version = "0.284.0" +version = "0.285.0" license = "MIT OR Apache-2.0" homepage = "https://github.com/feldera/feldera" repository = "https://github.com/feldera/feldera" @@ -101,7 +101,7 @@ csv = "1.2.2" csv-core = "0.1.10" dashmap = "6.1.0" datafusion = "51.0" -dbsp = { path = "crates/dbsp", version = "0.284.0" } +dbsp = { path = "crates/dbsp", version = "0.285.0" } dbsp_nexmark = { path = "crates/nexmark" } deadpool-postgres = "0.14.1" #deltalake = "0.30.2" @@ -121,19 +121,19 @@ erased-serde = "0.3.31" fake = "2.10" fastbloom = "0.14.0" fdlimit = "0.3.0" -feldera-buffer-cache = { version = "0.284.0", path = "crates/buffer-cache" } +feldera-buffer-cache = { version = "0.285.0", path = "crates/buffer-cache" } feldera-cloud1-client = "0.1.2" feldera-datagen = { path = "crates/datagen" } -feldera-fxp = { version = "0.284.0", path = "crates/fxp", features = ["dbsp"] } +feldera-fxp = { version = "0.285.0", path = "crates/fxp", features = ["dbsp"] } feldera-iceberg = { path = "crates/iceberg" } -feldera-observability = { version = "0.284.0", path = "crates/feldera-observability" } -feldera-macros = { version = "0.284.0", path = "crates/feldera-macros" } -feldera-sqllib = { version = "0.284.0", path = "crates/sqllib" } -feldera-storage = { version = "0.284.0", path = "crates/storage" } -feldera-types = { version = "0.284.0", path = "crates/feldera-types" } -feldera-rest-api = { version = "0.284.0", path = "crates/rest-api" } -feldera-ir = { version = "0.284.0", path = "crates/ir" } -feldera-adapterlib = { version = "0.284.0", path = "crates/adapterlib" } +feldera-observability = { version = "0.285.0", path = "crates/feldera-observability" } +feldera-macros = { version = "0.285.0", path = "crates/feldera-macros" } +feldera-sqllib = { version = "0.285.0", path = "crates/sqllib" } +feldera-storage = { version = "0.285.0", path = "crates/storage" } +feldera-types = { version = "0.285.0", path = "crates/feldera-types" } +feldera-rest-api = { version = "0.285.0", path = "crates/rest-api" } +feldera-ir = { version = "0.285.0", path = "crates/ir" } +feldera-adapterlib = { version = "0.285.0", path = "crates/adapterlib" } flate2 = "1.1.0" form_urlencoded = "1.2.0" futures = "0.3.30" diff --git a/crates/adapters/benches/delta_encoder.rs b/crates/adapters/benches/delta_encoder.rs index 23f97bbda35..82337187256 100644 --- a/crates/adapters/benches/delta_encoder.rs +++ b/crates/adapters/benches/delta_encoder.rs @@ -19,6 +19,7 @@ fn create_indexed_writer(threads: usize, table_uri: &str) -> DeltaTableWriter { max_retries: Some(0), threads: Some(threads), object_store_config: Default::default(), + checkpoint_interval: None, }; let key_schema = Some(BenchKeyStruct::relation_schema()); let mut value_schema = BenchTestStruct::relation_schema(); diff --git a/crates/adapters/src/integrated/delta_table/output.rs b/crates/adapters/src/integrated/delta_table/output.rs index fc364063c7d..26dbcc4cdb5 100644 --- a/crates/adapters/src/integrated/delta_table/output.rs +++ b/crates/adapters/src/integrated/delta_table/output.rs @@ -16,7 +16,7 @@ use dbsp::circuit::tokio::TOKIO; use delta_kernel::engine::arrow_conversion::TryFromArrow; use delta_kernel::table_properties::DataSkippingNumIndexedCols; use deltalake::DeltaTable; -use deltalake::kernel::transaction::{CommitBuilder, TableReference}; +use deltalake::kernel::transaction::{CommitBuilder, CommitProperties, TableReference}; use deltalake::kernel::{Action, Add, DataType, StructField}; use deltalake::logstore::ObjectStoreRef; use deltalake::operations::create::CreateBuilder; @@ -281,11 +281,20 @@ impl WriterTask { let mut operation_timeout: Duration = Duration::from_secs(60); loop { + let checkpoint_interval = match inner.config.checkpoint_interval { + Some(0) => None, + Some(interval) => Some(interval.to_string()), + None => Some("10".to_string()), + }; let create_future = CreateBuilder::new() .with_location(inner.config.uri.clone()) .with_save_mode(save_mode) .with_storage_options(storage_options.clone()) - .with_columns(inner.struct_fields.clone()); + .with_columns(inner.struct_fields.clone()) + .with_configuration_property( + deltalake::TableProperty::CheckpointInterval, + checkpoint_interval, + ); match tokio::time::timeout(operation_timeout, create_future).await { Ok(Ok(table)) => break table, @@ -357,7 +366,11 @@ impl WriterTask { )) })?; - CommitBuilder::default() + // `CommitBuilder::default()` leaves `post_commit_hook` unset, so delta-rs skips the + // post-commit hook entirely and never writes `_last_checkpoint` / `*.checkpoint.parquet`, + // regardless of `delta.checkpointInterval`. Use default commit properties so checkpoint + // creation runs when `(version + 1) % checkpoint_interval == 0`. + CommitBuilder::from(CommitProperties::default()) .with_actions( actions .iter() @@ -988,6 +1001,7 @@ mod parallel { max_retries: Some(0), threads: Some(threads), object_store_config: Default::default(), + checkpoint_interval: None, }, &key_schema, &value_relation(), @@ -1268,6 +1282,7 @@ mod parallel { max_retries: Some(0), threads: Some(4), object_store_config: Default::default(), + checkpoint_interval: None, }, &key_schema, &value_relation(), @@ -1405,6 +1420,7 @@ mod parallel { max_retries: Some(1), threads: Some(1), object_store_config: Default::default(), + checkpoint_interval: None, }, &key_schema, &value_relation(), @@ -1433,6 +1449,7 @@ mod parallel { max_retries: Some(0), threads: Some(0), object_store_config: Default::default(), + checkpoint_interval: None, }; assert!(config.validate().is_err()); } diff --git a/crates/adapters/src/integrated/delta_table/test.rs b/crates/adapters/src/integrated/delta_table/test.rs index ef9d5742740..b221b7def1a 100644 --- a/crates/adapters/src/integrated/delta_table/test.rs +++ b/crates/adapters/src/integrated/delta_table/test.rs @@ -646,8 +646,14 @@ fn delta_table_output_test( let mut output_records = Vec::with_capacity(data.len()); for parquet_file in parquet_files { - let mut records: Vec = load_parquet_file(&parquet_file); - output_records.append(&mut records); + if !parquet_file + .display() + .to_string() + .ends_with(".checkpoint.parquet") + { + let mut records: Vec = load_parquet_file(&parquet_file); + output_records.append(&mut records); + } } output_records.sort(); diff --git a/crates/feldera-types/src/transport/delta_table.rs b/crates/feldera-types/src/transport/delta_table.rs index c75cf58be35..51abdc7aee7 100644 --- a/crates/feldera-types/src/transport/delta_table.rs +++ b/crates/feldera-types/src/transport/delta_table.rs @@ -9,6 +9,8 @@ use utoipa::ToSchema; #[derive(Default, Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)] pub enum DeltaTableWriteMode { /// New updates will be appended to the existing table at the target location. + /// + /// If the table doesn't exist, it is created. #[default] #[serde(rename = "append")] Append, @@ -17,6 +19,8 @@ pub enum DeltaTableWriteMode { /// /// The connector truncates the table by outputting delete actions for all /// files in the latest snapshot of the table. + /// + /// If the table doesn't exist, it is created. #[serde(rename = "truncate")] Truncate, @@ -35,6 +39,18 @@ pub struct DeltaTableWriterConfig { #[serde(default)] pub mode: DeltaTableWriteMode, + /// Checkpoint interval (i.e., the number of commits after which a new checkpoint should be created) for newly created Delta tables. + /// + /// The option is only available when creating the Delta table (`mode = append` and there + /// is no existing table at the target location or `mode = truncate`). It configures the `checkpointInterval` + /// table property, which determines the number of commits after which a new checkpoint should be created. + /// + /// 0 means no checkpoints are created. + /// + /// Default: 10. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkpoint_interval: Option, + /// Maximum number of retries for failed operations. /// /// The connector performs retries on several levels: individual S3 operations, Delta Lake transaction commits, diff --git a/docs.feldera.com/docs/connectors/sinks/delta.md b/docs.feldera.com/docs/connectors/sinks/delta.md index d5c19bc6438..14b4de23cb1 100644 --- a/docs.feldera.com/docs/connectors/sinks/delta.md +++ b/docs.feldera.com/docs/connectors/sinks/delta.md @@ -73,9 +73,10 @@ MERGE INTO {target_table} AS target |------------|------------| | `uri`* | Table URI, e.g., `"s3://feldera-fraud-detection-data/feature_train"`. | | `mode`* | Determines how the Delta table connector handles an existing table at the target location. Options: | -| | - `append`: New updates will be appended to the existing table at the target location. | +| | - `append`: New updates will be appended to the existing table at the target location. If the table doesn't exist, it will be created. | | | - `truncate`: Existing table at the specified location will be truncated. The connector achieves this by outputting delete actions for all files in the latest snapshot of the table. | | | - `error_if_exists`: If a table exists at the specified location, the operation will fail. | +| `checkpoint_interval` |

Checkpoint interval (i.e., the number of commits after which a new checkpoint should be created) for newly created Delta tables.

The option is only available when creating the Delta table (`mode = append` and there is no existing table at the target location or `mode = truncate`). It configures the `checkpointInterval` table property, which determines the number of commits after which a new checkpoint should be created.

0 means no checkpoints are created.

Default: 10.

| | `max_retries`|

Maximum number of retries for failed Delta Lake operations like writing Parquet files and committing transactions.

The connector performs retries on several levels: individual S3 operations, Delta Lake transaction commits, and overall operation retries. This setting controls the overall operation retries. When a write to the table fails, because of an S3 timeout or any other reason that was not resolved by lower-level retries, the connector will retry the entire operation.

When not specified, the connector performs infinite retries. When set to 0, the connector doesn't retry failed operations.

| | `threads` | Number of parallel threads used by the connector. Increasing this value can improve Delta Lake write throughput by enabling concurrent writes. Default: `1`. | diff --git a/js-packages/web-console/src/lib/components/pipelines/editor/LogsStreamList.svelte b/js-packages/web-console/src/lib/components/pipelines/editor/LogsStreamList.svelte index a8bd1b593b0..bb07ef75e78 100644 --- a/js-packages/web-console/src/lib/components/pipelines/editor/LogsStreamList.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/editor/LogsStreamList.svelte @@ -5,7 +5,7 @@ import ScrollDownFab from '$lib/components/other/ScrollDownFab.svelte' import WarningBanner from '$lib/components/pipelines/editor/WarningBanner.svelte' import { useReverseScrollContainer } from '$lib/compositions/common/useReverseScrollContainer.svelte' - import { selectScope, virtualSelect } from '$lib/compositions/common/userSelect' + import { virtualSelect } from '$lib/compositions/common/userSelect' import { useSkeletonTheme } from '$lib/compositions/useSkeletonTheme.svelte' import { humanSize } from '$lib/functions/common/string' @@ -27,32 +27,14 @@ )} in total. {/if} - - - -
node.firstElementChild!, - getRootChildrenOffset: (root) => { - const firstChild = root?.children.item(0)?.children.item(0) - if (!firstChild) { - return 0 - } - const num = parseInt(firstChild.getAttribute('data-rowindex')!) - return num - }, getCopyContent(slice) { if (slice === 'all') { return logs.rows.map(stripANSI).join('') @@ -70,42 +52,9 @@ i + logs.firstRowIndex} bind:this={virtualizer}> {#snippet children(value, index)}
-
{/snippet}
- - - - diff --git a/js-packages/web-console/src/lib/components/pipelines/editor/MonitoringPanel.svelte b/js-packages/web-console/src/lib/components/pipelines/editor/MonitoringPanel.svelte index a5e44f5499a..3110f65fe2e 100755 --- a/js-packages/web-console/src/lib/components/pipelines/editor/MonitoringPanel.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/editor/MonitoringPanel.svelte @@ -60,7 +60,7 @@ true ), tuple('Samply' as const, TabSamplyProfile.Label, TabSamplyProfile.default, false), - tuple('Logs' as const, TabLogs, PanelLogs, false) + tuple('Logs' as const, TabLogs, PanelLogs, true) ].filter((tab) => !hiddenTabs.includes(tab[0])) ) const currentTabStorage = $derived( diff --git a/js-packages/web-console/src/lib/components/pipelines/editor/TabLogs.svelte b/js-packages/web-console/src/lib/components/pipelines/editor/TabLogs.svelte index 8a36dabcc7b..6cfff7a00cc 100644 --- a/js-packages/web-console/src/lib/components/pipelines/editor/TabLogs.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/editor/TabLogs.svelte @@ -51,7 +51,7 @@ let pipelineStatusName = $derived(unionName(pipeline.current.status)) - $effect.pre(() => { + let pipelineLogs = $derived.by(() => { if (!streams[pipelineName]) { streams[pipelineName] = { firstRowIndex: 0, @@ -61,6 +61,7 @@ totalSkippedBytes: 0 } } + return getStreams.current[pipelineName] }) $effect(() => { @@ -204,11 +205,6 @@ } } - // Trigger update to display the latest rows when switching to another pipeline - $effect(() => { - pipelineName - getStreams.current = streams - }) $effect(() => { const interval = setInterval(() => { getStreams.current = streams @@ -221,7 +217,7 @@ pipelineActionCallbacks.remove('', 'delete', dropLogHistory) } }) - let stream = $derived(getStreams.current[pipelineName].stream) + let stream = $derived(pipelineLogs.stream) const now = useInterval(() => new Date(), 1000, 1000 - (Date.now() % 1000)) @@ -240,7 +236,7 @@ {/if} {:else if !areLogsExpected(pipelineStatusName)} - {#if getStreams.current[pipelineName].rows.length} + {#if pipelineLogs.rows.length} Displaying log history from the last pipeline run. When the pipeline is started again this history will be cleared. @@ -255,6 +251,6 @@ Connecting to logs stream... {/if} {#key pipelineName} - + {/key} diff --git a/js-packages/web-console/src/lib/components/pipelines/editor/TabsPanel.svelte b/js-packages/web-console/src/lib/components/pipelines/editor/TabsPanel.svelte index 69cc9bc8ea9..8160c9d9c1e 100644 --- a/js-packages/web-console/src/lib/components/pipelines/editor/TabsPanel.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/editor/TabsPanel.svelte @@ -1,6 +1,7 @@ {#snippet defaultTabContainer(tab: Snippet, hidden: boolean)} @@ -49,7 +55,7 @@ {#snippet tab()} {/snippet} - {#if keepAlive} + {#if keepAlive && visited.has(tabName)} {@render tabContainer(tab, currentTab !== tabName)} {:else if currentTab === tabName} {@render tabContainer(tab, false)} diff --git a/js-packages/web-console/src/lib/compositions/common/useReverseScrollContainer.svelte.ts b/js-packages/web-console/src/lib/compositions/common/useReverseScrollContainer.svelte.ts index 48e8b4cf9ff..9e6c6f78847 100644 --- a/js-packages/web-console/src/lib/compositions/common/useReverseScrollContainer.svelte.ts +++ b/js-packages/web-console/src/lib/compositions/common/useReverseScrollContainer.svelte.ts @@ -91,11 +91,25 @@ export const useReverseScrollContainer = ( }) } + // When the container becomes visible after being hidden (e.g. tab switch with keepAlive), + // the ResizeObserver fires but the virtualizer may need extra time to lay out content. + // Schedule scrollToBottom attempt to catch up. + let wasHidden = false + const visibilityObserver = new IntersectionObserver((entries) => { + const isVisible = entries[0]?.isIntersecting ?? false + if (isVisible && wasHidden && stickToBottom) { + scrollToBottom() + } + wasHidden = !isVisible + }) + visibilityObserver.observe(ref) + ref.addEventListener('scroll', onscroll) scrollToBottom() return { destroy: () => { resizeObserver?.disconnect() + visibilityObserver.disconnect() ref.removeEventListener('scroll', onscroll) } } diff --git a/js-packages/web-console/src/lib/compositions/common/userSelect.ts b/js-packages/web-console/src/lib/compositions/common/userSelect.ts index 2cf6ad22fc9..6a213096547 100644 --- a/js-packages/web-console/src/lib/compositions/common/userSelect.ts +++ b/js-packages/web-console/src/lib/compositions/common/userSelect.ts @@ -4,7 +4,7 @@ export const selectScope = ( _node: HTMLElement, props?: { getNode?: (node: HTMLElement) => HTMLElement } ) => { - function handleUserSelectContain(event: Event) { + function handleUserSelectContain() { const node = props?.getNode?.(_node) ?? _node if (document.activeElement !== node) { return @@ -30,230 +30,497 @@ export const selectScope = ( } } -export const injectOnCopyAll = (node: HTMLElement, props: { value: string }) => {} +type RowLocation = { + row: number + col: number +} -const previousSiblingsTextLength = (node: Node | null) => { - let textLen = 0 - while ((node = node?.previousSibling ?? null)) { - if (node.nodeType === Node.TEXT_NODE) { - textLen += node.textContent?.length ?? 0 - } else if (node.nodeType === Node.ELEMENT_NODE && node instanceof HTMLElement) { - textLen += node.innerText.length +/** Walk up the DOM to find the nearest ancestor with a `data-rowindex` attribute */ +const findRowAncestor = (node: Node): HTMLElement | null => { + let current: Node | null = node + while (current) { + if (current instanceof HTMLElement && current.hasAttribute('data-rowindex')) { + return current } + current = current.parentNode } - return textLen + return null } -type RowLocation = { - row: number - col: number +/** Get the character offset from the start of a row element to a specific DOM position */ +const getCharOffset = (rowElement: HTMLElement, node: Node, offset: number): number => { + const range = document.createRange() + range.setStart(rowElement, 0) + range.setEnd(node, offset) + return range.toString().length } -type Location = { - index: number[] // [firstChild, ...restChildren] firstChild - index in the list of .children, restChildren - indices in the list of .childNodes - offsetLocal: number // Location in the leaf Node.TEXT_NODE - offsetAbsolute: number // Location in the root.item(i).innerText +/** Convert a DOM selection point to a logical {row, col} position */ +const domPointToLogical = (node: Node, offset: number): RowLocation | null => { + const rowElement = findRowAncestor(node) + if (!rowElement) { + return null + } + const row = parseInt(rowElement.getAttribute('data-rowindex')!) + if (isNaN(row)) { + return null + } + return { row, col: getCharOffset(rowElement, node, offset) } } -/** - * @param parent A valid parent for the passed node - */ -const getElementLocation = ( - parent: Element, - node: Node, - suffix: Location = { index: [], offsetLocal: 0, offsetAbsolute: 0 } -): Location | null => { - if (!node.parentNode) { - return null +/** Iterate over row elements in the virtualizer root, calling fn for each row index and its wrapper */ +const forEachRow = (root: Element, fn: (rowIndex: number, wrapper: Element) => void) => { + for (let i = 0; i < root.children.length; i++) { + const child = root.children[i] + // The virtualizer root's direct children are wrappers; the data-rowindex is on their first child + const rowEl = child.querySelector('[data-rowindex]') + if (!rowEl) { + continue + } + const idx = parseInt(rowEl.getAttribute('data-rowindex')!) + if (!isNaN(idx)) { + fn(idx, child) + } } - const index = Array.from( - parent === node.parentNode ? node.parentNode.children : node.parentNode.childNodes - ).indexOf(node) - if (index === -1) { - return getElementLocation(parent, node.parentNode, { - index: [0, ...suffix.index], - offsetLocal: 0 + suffix.offsetLocal, - offsetAbsolute: 0 + suffix.offsetAbsolute - }) +} + +/** Compute min/max row indices from a set of row elements, optionally filtering by a predicate */ +const rowRange = ( + root: Element, + filter?: (wrapper: Element) => boolean +): { min: number; max: number } | null => { + let min = Infinity + let max = -Infinity + forEachRow(root, (idx, wrapper) => { + if (filter && !filter(wrapper)) { + return + } + if (idx < min) { + min = idx + } + if (idx > max) { + max = idx + } + }) + if (min === Infinity) { + return null } - const location = { - index: [index, ...suffix.index], - offsetLocal: suffix.offsetLocal, - offsetAbsolute: suffix.offsetAbsolute + return { min, max } +} + +/** Get the range of row indices present in the virtualizer root's DOM (including overscan) */ +const getVisibleRange = (root: Element) => rowRange(root) + +/** Find the DOM element for a given row index within the root */ +const findRowElement = (root: Element, rowIndex: number): HTMLElement | null => { + return root.querySelector(`[data-rowindex="${rowIndex}"]`) +} + +/** Convert a character offset within a row element to a DOM {node, offset} position */ +const charOffsetToDOM = ( + rowElement: HTMLElement, + charOffset: number +): { node: Node; offset: number } | null => { + const walker = document.createTreeWalker(rowElement, NodeFilter.SHOW_TEXT) + let remaining = charOffset + let lastTextNode: Text | null = null + let textNode: Text | null + while ((textNode = walker.nextNode() as Text | null)) { + const len = textNode.textContent?.length ?? 0 + if (remaining <= len) { + return { node: textNode, offset: remaining } + } + remaining -= len + lastTextNode = textNode } - if (parent === node.parentNode) { - return location + // Offset beyond text content — clamp to end of last text node + if (lastTextNode) { + return { node: lastTextNode, offset: lastTextNode.textContent?.length ?? 0 } } - // We do not account for previous siblings text length in the - location.offsetAbsolute += previousSiblingsTextLength(node) - return getElementLocation(parent, node.parentNode, location) + // No text nodes at all — return the element itself + return { node: rowElement, offset: 0 } } -const getChildElementAtPath = (parent: Element, path: number[], parentOffset = 0) => { - let element: Element | null = parent - for (const i of path) { - element = - (element === parent - ? element.children.item(i + parentOffset) - : (element?.childNodes.item(i) as Element)) ?? null +/** Clamp a RowLocation to a visible range: col=0 for above, col=Infinity for below */ +const clampToVisible = (loc: RowLocation, visible: { min: number; max: number }): RowLocation => { + if (loc.row < visible.min) { + return { row: visible.min, col: 0 } + } + if (loc.row > visible.max) { + return { row: visible.max, col: Infinity } } - return element + return loc } +/** Resolve a clamped col=Infinity to a DOM position at the end of the row element */ +const resolveToDOM = (loc: RowLocation, rowEl: HTMLElement) => + loc.col === Infinity + ? { node: rowEl as Node, offset: rowEl.childNodes.length } + : charOffsetToDOM(rowEl, loc.col) + export const virtualSelect = ( node: HTMLElement, { - getRootChildrenOffset = () => 0, getRoot = () => node, getCopyContent }: { getRoot?: (node: Element) => Element - getRootChildrenOffset?: (root: Element) => number getCopyContent: (slice: { start: RowLocation; end: RowLocation } | 'all') => string } ) => { - let currentSelection: - | { - anchor: Location - focus: Location - } - | null - | 'all' = null const root = getRoot(node) - function handleUserSelectContain(event: Event) { + + let currentSelection: { anchor: RowLocation; focus: RowLocation } | 'all' | null = null + let isReapplying = false + // MutationObserver (microtask) sets this before selectionchange (task) fires, + // so handleSelectionChange can distinguish DOM recycling from user action. + let hadMutation = false + // Caret position from the user's last click. Survives DOM recycling so + // Shift+click can extend to it even after the original DOM node was recycled. + let caretPosition: RowLocation | null = null + // Set by pointerdown, consumed by handleSelectionChange to know when to update caretPosition. + let expectCaretFromClick = false + // Set when Shift+click is detected with an off-screen caretPosition. Handled in + // handleSelectionChange to construct the selection ourselves (the browser can't + // create the right Range because the original caret's DOM node was recycled). + let shiftClickPending = false + // True while the left mouse button is held on content (drag-selecting). + // Used to suppress reapplySelection during drag — calling setBaseAndExtent while + // the browser is tracking a drag breaks its native selection direction. + let isDragging = false + // Track when the mouse leaves the container during a drag-selection, so the MO + // callback knows to extend the focus toward newly visible rows. + let dragOutside: 'above' | 'below' | null = null + // Set when handleSelectionChange detects that Firefox placed the anchor on the + // container/root node (its fallback when the anchor's DOM node is recycled). + // This signals the MO callback that the browser can't maintain the selection + // natively, so we must reapply and extend focus ourselves even during a drag + // inside the container. Cleared on pointerup when the drag ends. + let browserSelectionBroken = false + + /** Like getVisibleRange but only includes rows actually within the scroll container's viewport, + * excluding virtua's overscan/buffer rows that are in the DOM but scrolled off-screen. */ + function getViewportVisibleRange() { + const containerRect = node.getBoundingClientRect() + return rowRange(root, (wrapper) => { + const rect = wrapper.getBoundingClientRect() + return rect.bottom > containerRect.top && rect.top < containerRect.bottom + }) + } + + /** + * Merge browser-reported anchor/focus with tracked state to handle off-screen endpoints. + * When virtua recycles DOM nodes, the browser creates fallback endpoints at visible boundaries. + * This detects those fallbacks and substitutes the tracked logical positions. + */ + function mergeWithTracked( + anchor: RowLocation, + focus: RowLocation, + visible: { min: number; max: number } + ): { anchor: RowLocation; focus: RowLocation } { + if (currentSelection && typeof currentSelection === 'object') { + const tracked = currentSelection + const anchorOffscreen = tracked.anchor.row < visible.min || tracked.anchor.row > visible.max + + if (anchorOffscreen) { + const focusOffscreen = + tracked.focus.row < visible.min || tracked.focus.row > visible.max + if (focusOffscreen) { + // Both tracked endpoints are off-screen — the browser's anchor and focus + // are both clamped values from reapplySelection. Preserve entire tracked + // selection; reapplySelection will clamp for display. + return { anchor: tracked.anchor, focus: tracked.focus } + } + // Only the anchor is off-screen. The browser may have flipped anchor/focus + // due to absolute positioning (DOM order ≠ visual order). Use the tracked + // drag direction to identify which browser endpoint is the real focus + // (the one furthest in the drag direction = the mouse position). + const draggingDown = tracked.anchor.row <= tracked.focus.row + const realFocus = draggingDown + ? anchor.row >= focus.row + ? anchor + : focus + : anchor.row <= focus.row + ? anchor + : focus + return { anchor: tracked.anchor, focus: realFocus } + } + // Anchor is on-screen. If tracked focus is off-screen, preserve it — the + // browser's focus is a clamped/fallback value, not a real user selection. + if (tracked.focus.row < visible.min || tracked.focus.row > visible.max) { + return { anchor, focus: tracked.focus } + } + } + + // Also merge with caretPosition for Shift+click: the user clicked (setting caretPosition), + // scrolled away (caret's DOM node recycled), then Shift+clicked. The browser created a Range + // from a visible boundary to the Shift+click point, but the real anchor is caretPosition. + // Check either boundary — when a DOM node is removed, the browser typically collapses the + // caret toward the start of the parent (visible.min) regardless of the original direction. + if ( + caretPosition && + (caretPosition.row < visible.min || caretPosition.row > visible.max) && + (anchor.row === visible.min || anchor.row === visible.max) + ) { + return { anchor: caretPosition, focus } + } + + return { anchor, focus } + } + + function handleSelectionChange() { + // Consume hadMutation before any early returns so it doesn't stay stuck true + // when isReapplying catches all selectionchange events from a reapply cycle. + const wasMutation = hadMutation + hadMutation = false + + if (isReapplying) { + return + } if (document.activeElement !== node) { return } + // Handle Shift+click with off-screen caretPosition. The browser may produce a + // Caret, a Range with wrong anchor, or anchor on the root — we bypass all that + // and construct the selection from caretPosition + the click position. + if (shiftClickPending) { + shiftClickPending = false + const selection = window.getSelection() + if (selection && selection.rangeCount && caretPosition) { + // The click position is the focus for Range, or the anchor for Caret + const clickNode = selection.type === 'Range' ? selection.focusNode : selection.anchorNode + const clickOffset = selection.type === 'Range' ? selection.focusOffset : selection.anchorOffset + if (!clickNode) { + return + } + const clickPos = domPointToLogical(clickNode, clickOffset) + if (!clickPos) { + return + } + currentSelection = { anchor: caretPosition, focus: clickPos } + reapplySelection() + } + } + const selection = window.getSelection() - if (!selection || !selection.rangeCount || selection.type !== 'Range') { - const minIndex = getRootChildrenOffset(root) - const maxIndex = minIndex + root.children.length - 1 - if ( - selection && - selection.type === 'Caret' && - currentSelection && - typeof currentSelection === 'object' && - ((currentSelection.anchor.index[0] < minIndex && - currentSelection.focus.index[0] < minIndex) || - (currentSelection.anchor.index[0] > maxIndex && - currentSelection.focus.index[0] > maxIndex)) - ) { - return + if (!selection || !selection.rangeCount) { + if (!wasMutation) { + currentSelection = null + } + return + } + + // Caret (collapsed selection) + if (selection.type !== 'Range') { + // Record caret position from user clicks (not from DOM mutations) so that + // Shift+click can extend to it even after the original DOM node was recycled. + if (expectCaretFromClick) { + expectCaretFromClick = false + if (selection.anchorNode && root.contains(selection.anchorNode)) { + const pos = domPointToLogical(selection.anchorNode, selection.anchorOffset) + if (pos) caretPosition = pos + } + } + // Selection collapsed — decide whether to preserve the tracked selection. + if (currentSelection && typeof currentSelection === 'object') { + // If this collapse was triggered by a DOM mutation (virtua recycling nodes), + // always preserve — reapplySelection already restored the visible portion. + if (wasMutation) { + return + } + // Otherwise check if any tracked endpoint is off-screen. If so, the collapse + // is likely from scrolling (the anchor/focus node left the DOM). Preserve it + // so reapplySelection can restore the visible portion on the next mutation. + const visible = getVisibleRange(root) + if (visible) { + const { anchor, focus } = currentSelection + if ( + anchor.row < visible.min || + anchor.row > visible.max || + focus.row < visible.min || + focus.row > visible.max + ) { + return // preserve tracked selection + } + } } currentSelection = null return } - invariant(selection.anchorNode && selection.focusNode, 'a') - if (selection.anchorNode === node) { + invariant(selection.anchorNode && selection.focusNode, 'Selection must have anchor and focus') + + // If anchor is on the root node itself, it could be: + // (a) A real select-all (Ctrl+A) — no tracked selection or not dragging + // (b) Firefox's fallback when the anchor's DOM node was recycled during a drag — + // Firefox places the anchor on the container/root instead of at a visible boundary + // like Chrome does. In this case, preserve the tracked anchor and read focus from + // the browser's selection. + if (selection.anchorNode === root || selection.anchorNode === node) { + if (currentSelection && typeof currentSelection === 'object') { + // Firefox fallback — when the anchor's DOM node is recycled during a drag, + // Firefox places the anchor on the container/root instead of at a visible + // boundary like Chrome does. The focusNode may also land on root/node if + // Firefox can't resolve either endpoint. + // + // When we have a tracked selection and are dragging (or the tracked anchor + // is off-screen), preserve the tracked selection. If the browser's focusNode + // resolves to a row, use it as the updated focus; otherwise keep the tracked + // focus — reapplySelection will clamp it to the visible range. + const visible = getVisibleRange(root) + const trackedAnchorOffscreen = + visible && + (currentSelection.anchor.row < visible.min || currentSelection.anchor.row > visible.max) + if (trackedAnchorOffscreen || isDragging) { + const browserFocus = selection.focusNode + ? domPointToLogical(selection.focusNode, selection.focusOffset) + : null + const focus = browserFocus ?? currentSelection.focus + // Signal to MO callback that the browser can't maintain the selection + // natively — we must reapply and extend focus ourselves even during drag. + browserSelectionBroken = true + currentSelection = { anchor: currentSelection.anchor, focus } + return + } + } currentSelection = 'all' return } - const anchor = getElementLocation(root, selection.anchorNode) - const focus = getElementLocation(root, selection.focusNode) + // Check if selection is within our root + if (!root.contains(selection.anchorNode)) { + // Selection leaked outside — act like selectScope: select all children + if (!currentSelection) { + selection.selectAllChildren(node) + } + return + } + + let anchor = domPointToLogical(selection.anchorNode, selection.anchorOffset) + let focus = domPointToLogical(selection.focusNode, selection.focusOffset) if (!anchor || !focus) { return } - { - const rootChildrenOffset = getRootChildrenOffset(root) - anchor.index[0] += rootChildrenOffset - focus.index[0] += rootChildrenOffset + + // When not actively dragging and no DOM mutation triggered this event, a Range + // selectionchange is likely a late/stale event from our own reapplySelection + // (fired after isReapplying was cleared by setTimeout). If the tracked selection + // has off-screen endpoints, the browser's Range contains clamped values — not + // the real selection. Preserve the tracked selection, same as the Caret handler. + if (!isDragging && !wasMutation && currentSelection && typeof currentSelection === 'object') { + const visible = getVisibleRange(root) + if (visible) { + const { anchor: tA, focus: tF } = currentSelection + if ( + tA.row < visible.min || tA.row > visible.max || + tF.row < visible.min || tF.row > visible.max + ) { + return + } + } } - anchor.offsetLocal += selection.anchorOffset - focus.offsetLocal += selection.focusOffset - anchor.offsetAbsolute += selection.anchorOffset - focus.offsetAbsolute += selection.focusOffset - currentSelection = { - anchor, - focus + + const beforeMerge = { anchor: { ...anchor }, focus: { ...focus } } + expectCaretFromClick = false + + // When virtua removes a DOM node that was part of the selection (e.g. the anchor + // scrolled off-screen during a drag), the browser creates a new Range with a + // fallback endpoint at the visible boundary. With absolute positioning, the browser + // may also flip anchor/focus (DOM order ≠ visual order). Merge with tracked values. + const visible = getVisibleRange(root) + if (visible) { + ;({ anchor, focus } = mergeWithTracked(anchor, focus, visible)) } - } - // As a workaround returns the first position in an element, not last, but it is not important since - const getLastLocationInElement = (element: Element, parentIndex: number): Location | null => { - return { index: [parentIndex], offsetLocal: 0, offsetAbsolute: 0 } + currentSelection = { anchor, focus } } function reapplySelection() { + if (currentSelection === null || currentSelection === 'all') { + return + } + const selection = window.getSelection() if (!selection) { return } - if (currentSelection === null || currentSelection === 'all') { + + const visible = getVisibleRange(root) + if (!visible) { return } - document.removeEventListener('selectionchange', handleUserSelectContain) - invariant(selection) - selection.removeAllRanges() - const minIndex = getRootChildrenOffset(root) - const maxIndex = minIndex + root.children.length - 1 - const getCroppedLocation = (index: number): Location | null => { - return index < minIndex - ? { index: [minIndex], offsetLocal: 0, offsetAbsolute: 0 } - : index > maxIndex - ? getLastLocationInElement(root.children.item(maxIndex)!, maxIndex) - : null - } - let anchor = getCroppedLocation(currentSelection.anchor.index[0]) - let focus = getCroppedLocation(currentSelection.focus.index[0]) + const { anchor, focus } = currentSelection + // Clamp to visible range + const clampedAnchor = clampToVisible(anchor, visible) + const clampedFocus = clampToVisible(focus, visible) + + // setBaseAndExtent triggers selectionchange asynchronously (task), but isReapplying + // is cleared synchronously. Use setTimeout to keep the flag true until after the + // pending selectionchange events are processed — this prevents handleSelectionChange + // from overwriting the tracked selection with clamped values. + isReapplying = true + const endReapply = () => + setTimeout(() => { + isReapplying = false + }) + // If both clamped to same boundary with same col, set a collapsed range if ( - anchor && - focus && - anchor.index[0] === focus.index[0] && - anchor.offsetAbsolute === focus.offsetAbsolute + clampedAnchor.row === clampedFocus.row && + clampedAnchor.col === clampedFocus.col && + (clampedAnchor.col === Infinity || clampedAnchor.col === 0) ) { - setTimeout(() => document.addEventListener('selectionchange', handleUserSelectContain)) - // selection.setBaseAndExtent(root.children.item(0)!, 0, root.children.item(0)!, 0) + // Both off-screen on same side — just collapse at boundary + const boundaryRow = findRowElement(root, clampedAnchor.row) + if (!boundaryRow) { + endReapply() + return + } + selection.removeAllRanges() const range = document.createRange() - range.setStart(root.children.item(0)!, 0) - range.setEnd(root.children.item(0)!, 0) + range.setStart(boundaryRow, 0) + range.setEnd(boundaryRow, 0) selection.addRange(range) + endReapply() return } - anchor ??= currentSelection.anchor - focus ??= currentSelection.focus - - selection.setBaseAndExtent( - getChildElementAtPath(root, anchor.index, -minIndex)!, - anchor.offsetLocal, - getChildElementAtPath(root, focus.index, -minIndex)!, - focus.offsetLocal - ) - // document.addEventListener('selectionchange', handleUserSelectContain) - setTimeout(() => document.addEventListener('selectionchange', handleUserSelectContain)) - } - const getRowLocation = (location: Location) => ({ - row: location.index[0], - col: location.offsetAbsolute - }) + const anchorRow = findRowElement(root, clampedAnchor.row) + const focusRow = findRowElement(root, clampedFocus.row) + if (!anchorRow || !focusRow) { + endReapply() + return + } - function oncopy(e: ClipboardEvent) { - if (currentSelection === null) { + const anchorDOM = resolveToDOM(clampedAnchor, anchorRow) + const focusDOM = resolveToDOM(clampedFocus, focusRow) + if (!anchorDOM || !focusDOM) { + endReapply() return } + + try { + selection.setBaseAndExtent(anchorDOM.node, anchorDOM.offset, focusDOM.node, focusDOM.offset) + } catch { + // setBaseAndExtent can throw if nodes were removed between check and call + } + endReapply() + } + + function oncopy(e: ClipboardEvent) { + if (currentSelection === null) return let content: string if (currentSelection === 'all') { content = getCopyContent('all') } else { - const flip = - currentSelection.anchor.index[0] > currentSelection.focus.index[0] || - (currentSelection.anchor.index[0] === currentSelection.focus.index[0] && - currentSelection.anchor.offsetAbsolute > currentSelection.focus.offsetAbsolute) + // Normalize so start <= end regardless of selection direction + const { anchor, focus } = currentSelection + const flip = anchor.row > focus.row || (anchor.row === focus.row && anchor.col > focus.col) content = getCopyContent({ - start: getRowLocation(flip ? currentSelection.focus : currentSelection.anchor), - end: getRowLocation(flip ? currentSelection.anchor : currentSelection.focus) - }) - content = getCopyContent({ - [flip ? 'end' : 'start']: getRowLocation(currentSelection.anchor), - [flip ? 'start' : 'end']: getRowLocation(currentSelection.focus) - } as { - start: RowLocation - end: RowLocation + start: flip ? focus : anchor, + end: flip ? anchor : focus }) } e.clipboardData!.setData('text/plain', content) @@ -261,15 +528,145 @@ export const virtualSelect = ( } const mutationObserver = new MutationObserver(() => { - setTimeout(() => reapplySelection(), 100) + // Set flag before reapply — MutationObserver callbacks (microtasks) run before + // the selectionchange event (task) that the browser fires when DOM removal + // collapses the selection. This lets handleSelectionChange know the collapse + // was caused by DOM recycling, not a user action. + hadMutation = true + + // When dragging outside the container, or when Firefox's selection is broken + // (anchor placed on root — detected by browserSelectionBroken flag), proactively + // extend the focus to the viewport-visible boundary in the drag direction. + // Use getBoundingClientRect to exclude virtua's overscan/buffer rows that are in + // the DOM but scrolled outside the viewport. + // + // Determine the drag direction: use dragOutside if set (pointerleave fired), + // otherwise infer from tracked selection when browserSelectionBroken is set + // (Firefox near-edge auto-scroll where the mouse stays inside the container). + let effectiveDragDir: 'above' | 'below' | null = dragOutside + if ( + !effectiveDragDir && + browserSelectionBroken && + isDragging && + currentSelection && + typeof currentSelection === 'object' + ) { + // Infer direction from anchor vs focus + effectiveDragDir = + currentSelection.anchor.row > currentSelection.focus.row ? 'above' : 'below' + } + + if (effectiveDragDir && currentSelection && typeof currentSelection === 'object') { + const visible = getViewportVisibleRange() + if (visible) { + const prevFocus = { ...currentSelection.focus } + if (effectiveDragDir === 'above') { + currentSelection = { ...currentSelection, focus: { row: visible.min, col: 0 } } + } else { + const lastRow = findRowElement(root, visible.max) + const col = lastRow ? lastRow.innerText.length : 0 + currentSelection = { + ...currentSelection, + focus: { row: visible.max, col } + } + } + } + } + + // Don't call reapplySelection during an active drag inside the container — + // setBaseAndExtent conflicts with the browser's native drag-selection tracking + // and can invert the selection direction. The browser handles the visual selection; + // we just track logical positions via handleSelectionChange. + // When mouse is outside (dragOutside), we must reapply to extend to new rows. + // When not dragging (scroll-then-view), we must reapply to restore the selection. + // + // Exception: when browserSelectionBroken is set (Firefox placed the anchor on + // the container root), the browser can't maintain the selection natively and + // we must reapply even during a drag inside the container. + const needsReapply = !isDragging || !!dragOutside || browserSelectionBroken + if (needsReapply) { + reapplySelection() + } }) + + // Clear tracked selection on left-click. pointerdown fires before selectionchange, + // so this ensures "click to deselect" works even when tracked endpoints are off-screen + // (where the off-screen check in handleSelectionChange would otherwise preserve them). + // Starting a new drag-selection is unaffected: handleSelectionChange will set + // currentSelection when the Range appears. + function handlePointerDown(e: PointerEvent) { + if (e.button !== 0) return + // Ignore clicks on the scrollbar. We use elementFromPoint to detect this: + // if the hit element is the scroll container itself (not a descendant), the + // click landed on the scrollbar or empty padding. This works for both Chrome's + // classic scrollbar (which occupies space outside clientWidth) and Firefox's + // overlay scrollbar (which hovers over content and doesn't reduce clientWidth). + const hitEl = document.elementFromPoint(e.clientX, e.clientY) + if (hitEl === node) return + + // Shift+click with an off-screen caretPosition: the browser can't create the + // right Range (the original caret node was recycled), so we'll handle it in + // handleSelectionChange by using caretPosition as the anchor. + if (e.shiftKey && caretPosition) { + const visible = getVisibleRange(root) + if (visible && (caretPosition.row < visible.min || caretPosition.row > visible.max)) { + shiftClickPending = true + dragOutside = null + return + } + } + + currentSelection = null + dragOutside = null + isDragging = true + browserSelectionBroken = false + expectCaretFromClick = true + } + + function handlePointerLeave(e: PointerEvent) { + if (!(e.buttons & 1)) { + return // left button not held + } + const rect = node.getBoundingClientRect() + if (e.clientY <= rect.top) { + dragOutside = 'above' + } else if (e.clientY >= rect.bottom) { + dragOutside = 'below' + } + } + + function handlePointerEnter() { + dragOutside = null + } + + function handlePointerUp() { + const wasDragging = isDragging + isDragging = false + dragOutside = null + browserSelectionBroken = false + // reapplySelection was suppressed during drag inside the container. + // Apply once now to show the clamped selection. + if (wasDragging) { + reapplySelection() + } + } + mutationObserver.observe(root, { childList: true }) - document.addEventListener('selectionchange', handleUserSelectContain) + document.addEventListener('selectionchange', handleSelectionChange) + document.addEventListener('pointerup', handlePointerUp) node.addEventListener('copy', oncopy) + node.addEventListener('pointerdown', handlePointerDown) + node.addEventListener('pointerleave', handlePointerLeave) + node.addEventListener('pointerenter', handlePointerEnter) return { destroy() { - document.removeEventListener('selectionchange', handleUserSelectContain) + mutationObserver.disconnect() + document.removeEventListener('selectionchange', handleSelectionChange) + document.removeEventListener('pointerup', handlePointerUp) node.removeEventListener('copy', oncopy) + node.removeEventListener('pointerdown', handlePointerDown) + node.removeEventListener('pointerleave', handlePointerLeave) + node.removeEventListener('pointerenter', handlePointerEnter) } } } diff --git a/openapi.json b/openapi.json index 4cda20359f0..579bb5ea409 100644 --- a/openapi.json +++ b/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "MIT OR Apache-2.0" }, - "version": "0.284.0" + "version": "0.285.0" }, "paths": { "/config/authentication": { @@ -7997,6 +7997,13 @@ "uri" ], "properties": { + "checkpoint_interval": { + "type": "integer", + "format": "int32", + "description": "Checkpoint interval (i.e., the number of commits after which a new checkpoint should be created) for newly created Delta tables.\n\nThe option is only available when creating the Delta table (`mode = append` and there\nis no existing table at the target location or `mode = truncate`). It configures the `checkpointInterval`\ntable property, which determines the number of commits after which a new checkpoint should be created.\n\n0 means no checkpoints are created.\n\nDefault: 10.", + "nullable": true, + "minimum": 0 + }, "max_retries": { "type": "integer", "format": "int32", diff --git a/python/pyproject.toml b/python/pyproject.toml index e665fe67915..6a0fadc6328 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "feldera" readme = "README.md" description = "The feldera python client" -version = "0.284.0" +version = "0.285.0" license = "MIT" requires-python = ">=3.10" authors = [ diff --git a/python/uv.lock b/python/uv.lock index 3b57a5e44b5..69b56f36c59 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-03-31T08:08:05.379719977Z" +exclude-newer = "2026-04-01T07:56:11.489431225Z" exclude-newer-span = "P1W" [[package]] @@ -221,7 +221,7 @@ wheels = [ [[package]] name = "feldera" -version = "0.284.0" +version = "0.285.0" source = { editable = "." } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },