From fa114d5f6b4cd984256f9e5736a9d25b2733bf0e Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Wed, 4 Nov 2026 15:42:11 -0500 Subject: [PATCH 1/3] Loosen layout validation Signed-off-by: Andrew Stein --- .github/workflows/build.yaml | 6 +- docs/md/how_to/javascript/layout.md | 18 +++-- .../src/rust/agent/tools.rs | 18 +++++ .../rust/components/main_panel/reconcile.rs | 3 - .../src/rust/config/viewer_config.rs | 4 +- .../src/rust/js/regular_layout.rs | 67 ++++--------------- .../src/rust/tasks/sync_update_panels.rs | 26 ++++++- .../test/js/multi_panel/harness.ts | 24 +------ .../restore_workspace_layout.spec.ts | 67 ++++++++++++------- 9 files changed, 113 insertions(+), 120 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 051236bfdd..baa24a8db6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -721,7 +721,7 @@ jobs: - name: Package if: ${{ steps.config-step.outputs.FULL_RUN }} - run: cargo package --config .cargo/release.toml --no-verify --allow-dirty -p perspective -p perspective-viewer -p perspective-js -p perspective-client -p perspective-server -p perspective-python + run: cargo package --config .cargo/release.toml --no-verify --allow-dirty -p perspective -p perspective-viewer -p perspective-js -p perspective-client -p perspective-server -p perspective-migrate -p perspective-python - uses: actions/upload-artifact@v4 if: ${{ steps.config-step.outputs.FULL_RUN }} @@ -1104,15 +1104,17 @@ jobs: - name: Set up path overrides for perspective .crate contents shell: bash run: | - (cd crates && tar xvf perspective-client-*.crate && tar xvf perspective-server-*.crate) + (cd crates && tar xvf perspective-client-*.crate && tar xvf perspective-server-*.crate && tar xvf perspective-migrate-[0-9]*.crate) CLIENT_CRATE_ROOT=$(ls -d "$PWD"/crates/perspective-client-*/ | head -n 1) SERVER_CRATE_ROOT=$(ls -d "$PWD"/crates/perspective-server-*/ | head -n 1) + MIGRATE_CRATE_ROOT=$(ls -d "$PWD"/crates/perspective-migrate-[0-9]*/ | head -n 1) mkdir -p $HOME/.cargo cat > $HOME/.cargo/config.toml <>(); + + let unplaced = layout.validate(&ids).map_err(ToolError)?; + if !unplaced.is_empty() { + return Err(ToolError(format!( + "Invalid layout: panels {unplaced:?} are missing - name every panel, or \ + remove it first with `remove_panel`" + ))); + } + } + let snapshot = self.agent_save_workspace().await?; if let Some(derived) = args.config.get("derived_tables") { self.agent_check_derived(&snapshot, derived).await?; diff --git a/rust/perspective-viewer/src/rust/components/main_panel/reconcile.rs b/rust/perspective-viewer/src/rust/components/main_panel/reconcile.rs index 4348dbc82c..9ca1a3090b 100644 --- a/rust/perspective-viewer/src/rust/components/main_panel/reconcile.rs +++ b/rust/perspective-viewer/src/rust/components/main_panel/reconcile.rs @@ -41,9 +41,6 @@ const LAYOUT_PHYSICS: LayoutPhysics = LayoutPhysics { }; impl MainPanel { - /// Size each STAGED panel's hidden `.psp-staging` wrapper to the exact - /// plugin box its promote insert will commit (see - /// `.plan/DUPLICATE_STAGING_PREDICTION_PLAN.md`). pub(super) fn size_staging_wrappers(&self, ctx: &Context) { let Some(root) = self.main_panel_ref.cast::() else { return; diff --git a/rust/perspective-viewer/src/rust/config/viewer_config.rs b/rust/perspective-viewer/src/rust/config/viewer_config.rs index 0b1176e4fe..93f1b80ef0 100644 --- a/rust/perspective-viewer/src/rust/config/viewer_config.rs +++ b/rust/perspective-viewer/src/rust/config/viewer_config.rs @@ -332,13 +332,13 @@ pub struct ViewerConfigInitial { pub plugin: Option, /// Panel title, shown in its tab. Absent renders the default title. - #[ts(optional)] + #[ts(optional = nullable)] pub title: Option, /// Theme NAME (e.g. `"Pro Dark"`) — not a CSS value. Valid names are /// the Perspective themes loaded on the page. Absent uses the /// default. - #[ts(optional)] + #[ts(optional = nullable)] pub theme: Option, /// Plugin-wide settings (as opposed to the per-column diff --git a/rust/perspective-viewer/src/rust/js/regular_layout.rs b/rust/perspective-viewer/src/rust/js/regular_layout.rs index 74a636e158..fddb6a8bdb 100644 --- a/rust/perspective-viewer/src/rust/js/regular_layout.rs +++ b/rust/perspective-viewer/src/rust/js/regular_layout.rs @@ -317,32 +317,16 @@ impl Layout { } } - /// Check that this tree names each of `ids` exactly once and is - /// well-formed, describing the first violation found. - pub fn validate(&self, ids: &[String]) -> Result<(), String> { - if let Layout::Split(split) = self - && split.children.is_empty() - && split.sizes.is_empty() - && ids.is_empty() - { - return Ok(()); - } - + /// Check that this tree is well-formed and names each of `ids` at most + /// once, returning the `ids` it leaves unplaced. + pub fn validate(&self, ids: &[String]) -> Result, String> { let mut seen = Vec::new(); self.validate_node("layout", ids, &mut seen)?; - let missing = ids + Ok(ids .iter() .filter(|id| !seen.contains(id)) - .collect::>(); - - if !missing.is_empty() { - return Err(format!( - "Invalid layout: panels {missing:?} are missing - a layout must name every panel \ - exactly once (panels: {ids:?})" - )); - } - - Ok(()) + .cloned() + .collect()) } fn validate_node( @@ -353,32 +337,20 @@ impl Layout { ) -> Result<(), String> { match self { Layout::Split(split) => { - if split.children.is_empty() { - return Err(format!("Invalid layout: {path}.children is empty")); - } - if split.sizes.len() != split.children.len() { return Err(format!( - "Invalid layout: {path}.sizes has {} entries for {} children - give one \ - size per child", + "Invalid layout: {path}.sizes has {} entries for {} children", split.sizes.len(), split.children.len() )); } - if let Some(size) = split.sizes.iter().find(|x| !x.is_finite() || **x <= 0.0) { - return Err(format!( - "Invalid layout: {path}.sizes contains {size} - sizes must be positive \ - fractions" - )); + if let Some(size) = split.sizes.iter().find(|x| !x.is_finite() || **x < 0.0) { + return Err(format!("Invalid layout: {path}.sizes contains {size}")); } - let total: f64 = split.sizes.iter().sum(); - if (total - 1.0).abs() > 1e-6 { - return Err(format!( - "Invalid layout: {path}.sizes sum to {total} - sizes are fractions of the \ - split and must sum to 1" - )); + if !split.sizes.is_empty() && split.sizes.iter().sum::() <= 0.0 { + return Err(format!("Invalid layout: {path}.sizes sum to zero")); } for (index, child) in split.children.iter().enumerate() { @@ -388,20 +360,6 @@ impl Layout { Ok(()) }, Layout::Tab(tab) => { - if tab.tabs.is_empty() { - return Err(format!("Invalid layout: {path}.tabs is empty")); - } - - if let Some(selected) = tab.selected - && selected >= tab.tabs.len() - { - return Err(format!( - "Invalid layout: {path}.selected is {selected} but {path}.tabs has {} \ - entries", - tab.tabs.len() - )); - } - for name in &tab.tabs { if !ids.contains(name) { return Err(format!( @@ -412,8 +370,7 @@ impl Layout { if seen.contains(name) { return Err(format!( - "Invalid layout: panel \"{name}\" appears more than once - a layout \ - must name every panel exactly once" + "Invalid layout: panel \"{name}\" appears more than once" )); } diff --git a/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs b/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs index 8e20a1fee7..21af6efc71 100644 --- a/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs +++ b/rust/perspective-viewer/src/rust/tasks/sync_update_panels.rs @@ -48,7 +48,7 @@ pub fn sync_update_panels( version, active, layout, - panels, + mut panels, derived_tables, global_filters, masters, @@ -68,15 +68,24 @@ pub fn sync_update_panels( let old_ids = this.workspace.panel_ids(); let replace = panels.is_some(); + let mut unplaced = Vec::new(); if let Some(layout) = &layout { let ids = match &panels { Some(panels) => panels.keys().cloned().collect::>(), None => old_ids.iter().map(|id| id.as_str().to_owned()).collect(), }; - layout + unplaced = layout .validate(&ids) .map_err(|e| ApiError::from(JsValue::from_str(&e)))?; + + for id in &unplaced { + tracing::warn!("`layout` does not place panel \"{id}\", dropping it"); + } + + if let Some(panels) = &mut panels { + panels.retain(|id, _| !unplaced.contains(id)); + } } // Phase 1 — models only, NO renders and NO draws @@ -124,7 +133,12 @@ pub fn sync_update_panels( }, None => { for id in &old_ids { - id_map.insert(id.as_str().to_owned(), id.as_str().to_owned()); + if !unplaced.iter().any(|x| x == id.as_str()) { + id_map.insert(id.as_str().to_owned(), id.as_str().to_owned()); + } else if let Some(panel) = this.workspace.remove_panel(id) { + panel.renderer.retire_slot(); + eject_tasks.push(eject_panel(panel, Disposal::Reject)); + } } }, } @@ -164,6 +178,12 @@ pub fn sync_update_panels( active_target .clone() .or_else(|| this.workspace.active_id().map(|id| id.as_str().to_owned())) + .or_else(|| { + this.workspace + .panel_ids() + .first() + .map(|id| id.as_str().to_owned()) + }) } else { None }; diff --git a/rust/perspective-viewer/test/js/multi_panel/harness.ts b/rust/perspective-viewer/test/js/multi_panel/harness.ts index 9cabb41919..2da5e7f337 100644 --- a/rust/perspective-viewer/test/js/multi_panel/harness.ts +++ b/rust/perspective-viewer/test/js/multi_panel/harness.ts @@ -10,24 +10,6 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -// Workspace structural invariants (see `.plan/WORKSPACE_TEST_PLAN.md` §1). -// Each helper asserts a layer the model-level APIs cannot see, locking the -// failure classes of the panel-materialization arc: -// -// - I1 `armLayoutCanary`/`assertLayoutStable`: the `` ELEMENT -// is never replaced (a Yew unkeyed list diff once recreated it, silently -// re-booting an EMPTY layout with no listeners). -// - I2 `assertTreeSane`: `layout.save()` sizes are finite, positive and -// normalized (an insert into an empty split once poisoned the tree with -// `NaN` sizes that rendered plausibly). -// - I3 `assertCoherent`: model panel set ≡ layout tree ≡ frame geometry ≡ -// slotted plugin projection (a duplicate once committed into an empty tree, -// orphaning the original panel from the layout while the model kept both — -// and the two frames rendered pixel-superimposed). -// -// `armInvariants(test)` retrofits I1+I2+I3 onto a whole spec file; the -// invariants then gate EVERY test's end state. - import type { Page, TestType } from "@playwright/test"; import { expect } from "../helpers.ts"; @@ -104,7 +86,7 @@ function stackGroups(tree: LayoutTree | null): string[][] { return tree.children.flatMap(stackGroups); } -/// I2 — every `sizes` entry finite and positive, per-split sum ≈ 1, arity +/// I2 — every `sizes` entry finite and non-negative, per-split sum > 0, arity /// matches, no duplicate or empty slots. The empty root split (zero panels) /// is legal. export function assertTreeSane(tree: LayoutTree | null): void { @@ -137,11 +119,11 @@ export function assertTreeSane(tree: LayoutTree | null): void { let total = 0; for (const size of node.sizes) { expect(Number.isFinite(size), `non-finite size ${size}`).toBe(true); - expect(size, "non-positive size").toBeGreaterThan(0); + expect(size, "negative size").toBeGreaterThanOrEqual(0); total += size; } - expect(Math.abs(total - 1), "sizes not normalized").toBeLessThan(1e-6); + expect(total, "sizes sum to zero").toBeGreaterThan(0); for (const child of node.children) { walk(child, false); } diff --git a/rust/perspective-viewer/test/js/multi_panel/restore_workspace_layout.spec.ts b/rust/perspective-viewer/test/js/multi_panel/restore_workspace_layout.spec.ts index 320c125fcf..6484ca91d7 100644 --- a/rust/perspective-viewer/test/js/multi_panel/restore_workspace_layout.spec.ts +++ b/rust/perspective-viewer/test/js/multi_panel/restore_workspace_layout.spec.ts @@ -87,11 +87,6 @@ const INVALID_LAYOUTS: [string, any, string][] = [ ), 'unknown panel "nope"', ], - [ - "an omitted panel", - split("horizontal", [0.5, 0.5], [tab("one"), tab("two")]), - "missing", - ], [ "a duplicated panel", split( @@ -104,12 +99,7 @@ const INVALID_LAYOUTS: [string, any, string][] = [ [ "a sizes length mismatch", split("horizontal", [0.5, 0.5], [tab("one"), tab("two"), tab("three")]), - "one size per child", - ], - [ - "sizes that do not sum to 1", - split("horizontal", [1, 1, 1], [tab("one"), tab("two"), tab("three")]), - "must sum to 1", + "has 2 entries for 3 children", ], [ "a negative size", @@ -118,21 +108,12 @@ const INVALID_LAYOUTS: [string, any, string][] = [ [1.5, -0.25, -0.25], [tab("one"), tab("two"), tab("three")], ), - "positive", - ], - [ - "an empty tab stack", - split( - "horizontal", - [0.5, 0.5], - [tab("one", "two", "three"), { type: "tab-layout", tabs: [] }], - ), - "tabs is empty", + "contains -0.25", ], [ - "an out-of-range selected tab", - { type: "tab-layout", tabs: ["one", "two", "three"], selected: 3 }, - "selected is 3", + "all-zero sizes", + split("horizontal", [0, 0, 0], [tab("one"), tab("two"), tab("three")]), + "sum to zero", ], ]; @@ -163,6 +144,44 @@ test.describe("restoreWorkspace layout validation", () => { expect(result.panels).toEqual(result.names); }); + test("drops an existing panel the layout omits", async ({ page }) => { + const layout = split("horizontal", [0.5, 0.5], [tab("one"), tab("two")]); + const result = await try_restore(page, { layout }); + expect(result.error).toBeNull(); + expect(result.panels).toEqual(["one", "two"]); + expect(result.after.layout).toEqual(layout); + expect(Object.keys(result.after.panels).sort()).toEqual(["one", "two"]); + }); + + test("drops an incoming panel the layout omits", async ({ page }) => { + const layout = split("horizontal", [0.5, 0.5], [tab("a"), tab("b")]); + const result = await try_restore(page, { + layout, + panels: { + a: { table: TABLE, plugin: "Debug" }, + b: { table: TABLE, plugin: "Debug" }, + c: { table: TABLE, plugin: "Debug" }, + }, + }); + + expect(result.error).toBeNull(); + expect(result.panels).toEqual(["a", "b"]); + expect(result.after.layout).toEqual(layout); + }); + + test("accepts sizes that do not sum to 1", async ({ page }) => { + const result = await try_restore(page, { + layout: split( + "horizontal", + [1, 2, 1], + [tab("one"), tab("two"), tab("three")], + ), + }); + + expect(result.error).toBeNull(); + expect(result.panels).toEqual(result.names); + }); + test("rearranges the existing panels into a two-over-one grid", async ({ page, }) => { From 11e7c32e2c9c304ec95aa5a6c31ca87ec29396ff Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Wed, 4 Nov 2026 19:18:36 -0500 Subject: [PATCH 2/3] Efficient reset for cross client `table(view)` Signed-off-by: Andrew Stein --- docs/md/explanation/view/advanced.md | 13 +- rust/perspective-client/perspective.proto | 1 + rust/perspective-client/src/rust/client.rs | 10 +- .../src/rust/utils/logging.rs | 1 + rust/perspective-client/src/rust/view.rs | 10 +- .../src/rust/virtual_server/server.rs | 3 +- rust/perspective-js/test/js/delta.spec.js | 32 +++ .../test/js/derived/lifecycle.spec.ts | 20 ++ .../test/js/multi_server.spec.js | 213 ++++++++++++++++++ .../perspective/tests/core/test_async.py | 2 +- .../perspective/tests/server/test_server.py | 10 +- .../perspective/tests/table/test_ports.py | 6 +- .../perspective/tests/table/test_table.py | 2 +- .../perspective/tests/table/test_view.py | 67 +++--- .../src/client/client_async.rs | 18 +- .../cpp/perspective/src/cpp/gnode.cpp | 26 ++- .../cpp/perspective/src/cpp/server.cpp | 1 + .../src/include/perspective/gnode.h | 10 +- 18 files changed, 383 insertions(+), 62 deletions(-) diff --git a/docs/md/explanation/view/advanced.md b/docs/md/explanation/view/advanced.md index 49014787bc..7a023b4cd8 100644 --- a/docs/md/explanation/view/advanced.md +++ b/docs/md/explanation/view/advanced.md @@ -222,7 +222,7 @@ view.remove_update(callback);
```python -def on_update(port_id, delta): +def on_update(port_id, delta, reset): print("View updated", port_id) view.on_update(on_update, mode="row") @@ -233,16 +233,19 @@ view.remove_update(on_update) When `mode` is set to `"row"`, the callback receives a delta of only the rows that changed (as Apache Arrow), which is useful for efficiently synchronizing -tables across clients. +tables across clients. The callback also receives `reset`, which is `true` when +the update step began with `clear()` or `replace()`; the delta is then relative +to an empty `Table`, so a replica should apply it with `replace()` rather than +`update()`. ## Remove Callbacks Register a callback to be notified whenever rows are removed from the underlying `Table` by `remove()`, which requires an `index`. The callback receives the `port_id` and the removed `index` column values as an Apache Arrow with one -column per index column, named after that column. It fires once per update step, only for rows which -existed before that step; `replace()` reports the keys it does not re-supply, -and `clear()` reports every key: +column per index column, named after that column. It fires once per update step, +only for rows which existed before that step; `replace()` reports the keys it +does not re-supply, and `clear()` reports every key:
diff --git a/rust/perspective-client/perspective.proto b/rust/perspective-client/perspective.proto index 3538cda304..27fb29c6db 100644 --- a/rust/perspective-client/perspective.proto +++ b/rust/perspective-client/perspective.proto @@ -515,6 +515,7 @@ message ViewOnUpdateReq { message ViewOnUpdateResp { optional bytes delta = 1; uint32 port_id = 2; + bool reset = 3; } message ViewOnDeleteReq {} diff --git a/rust/perspective-client/src/rust/client.rs b/rust/perspective-client/src/rust/client.rs index 8497d13969..41adae37dc 100644 --- a/rust/perspective-client/src/rust/client.rs +++ b/rust/perspective-client/src/rust/client.rs @@ -627,9 +627,13 @@ impl Client { let table_ = table.clone(); let callback = asyncfn!(table_, update, async move |update: OnUpdateData| { - let update = UpdateData::Arrow(update.delta.expect("Malformed message").into()); - let options = crate::UpdateOptions::default(); - table_.update(update, options).await.unwrap_or_log(); + let data = UpdateData::Arrow(update.delta.expect("Malformed message").into()); + if update.reset { + table_.replace(data).await.unwrap_or_log(); + } else { + let options = crate::UpdateOptions::default(); + table_.update(data, options).await.unwrap_or_log(); + } }); let options = OnUpdateOptions { diff --git a/rust/perspective-client/src/rust/utils/logging.rs b/rust/perspective-client/src/rust/utils/logging.rs index b9cb02d893..6d6ebb53e2 100644 --- a/rust/perspective-client/src/rust/utils/logging.rs +++ b/rust/perspective-client/src/rust/utils/logging.rs @@ -145,6 +145,7 @@ impl std::fmt::Display for Response { client_resp: Some(ClientResp::ViewOnUpdateResp(ViewOnUpdateResp { delta: x.delta.as_ref().map(|_| vec![]), port_id: x.port_id, + reset: x.reset, })), ..msg.clone() }, diff --git a/rust/perspective-client/src/rust/view.rs b/rust/perspective-client/src/rust/view.rs index 8a1c235472..7edf5a7d5e 100644 --- a/rust/perspective-client/src/rust/view.rs +++ b/rust/perspective-client/src/rust/view.rs @@ -566,16 +566,18 @@ impl View { /// Register a callback with this [`View`]. Whenever the view's underlying /// table emits an update, this callback will be invoked with an object - /// containing `port_id`, indicating which port the update fired on, and + /// containing `port_id`, indicating which port the update fired on, + /// `reset`, which is `true` when the step began with [`Table::clear`] or + /// [`Table::replace`] so `delta` is relative to an empty table, and /// optionally `delta`, which is the new data that was updated for each /// cell or each row. /// /// # Arguments /// /// - `on_update` - A callback function invoked on update, which receives an - /// object with two keys: `port_id`, indicating which port the update was - /// triggered on, and `delta`, whose value is dependent on the mode - /// parameter. + /// object with three keys: `port_id`, indicating which port the update + /// was triggered on, `reset`, and `delta`, whose value is dependent on + /// the mode parameter. /// - `options` - If this is provided as `OnUpdateOptions { mode: /// Some(OnUpdateMode::Row) }`, then `delta` is an Arrow of the updated /// rows. Otherwise `delta` will be [`Option::None`]. diff --git a/rust/perspective-client/src/rust/virtual_server/server.rs b/rust/perspective-client/src/rust/virtual_server/server.rs index bc34edb686..a974f14b7b 100644 --- a/rust/perspective-client/src/rust/virtual_server/server.rs +++ b/rust/perspective-client/src/rust/virtual_server/server.rs @@ -651,7 +651,8 @@ impl VirtualServer { ViewOnUpdateReq(_) => { respond!(msg, ViewOnUpdateResp { delta: None, - port_id: 0 + port_id: 0, + reset: false }) }, ViewOnDeleteReq(_) => { diff --git a/rust/perspective-js/test/js/delta.spec.js b/rust/perspective-js/test/js/delta.spec.js index b2da4f17e8..898375f47a 100644 --- a/rust/perspective-js/test/js/delta.spec.js +++ b/rust/perspective-js/test/js/delta.spec.js @@ -87,6 +87,38 @@ function it_old_behavior(name, capture) { await table.update(partial_change_y); }); + test("`reset` marks steps that began with `replace` or `clear`", async function () { + const table = await perspective.table(data, { index: "x" }); + const view = await table.view(); + const steps = []; + let resolve; + const next = () => + new Promise((x) => { + resolve = x; + }); + + await view.on_update( + (updated) => { + steps.push(updated.reset); + resolve(); + }, + { mode: "row" }, + ); + + let promise = next(); + await table.update(partial_change_y); + await promise; + promise = next(); + await table.replace(data); + await promise; + promise = next(); + await table.clear(); + await promise; + expect(steps).toEqual([false, true, true]); + await view.delete(); + await table.delete(); + }); + it_old_behavior( "returns changed rows, hidden sort", async function (done) { diff --git a/rust/perspective-js/test/js/derived/lifecycle.spec.ts b/rust/perspective-js/test/js/derived/lifecycle.spec.ts index d94ba3976a..eb5c430d71 100644 --- a/rust/perspective-js/test/js/derived/lifecycle.spec.ts +++ b/rust/perspective-js/test/js/derived/lifecycle.spec.ts @@ -119,4 +119,24 @@ test.describe("Derived table lifecycle", function () { await source.delete(); }); } + for (const [name, config] of Object.entries({ + unit: {}, + flat: PARENTS.flat, + })) { + test(`${name}: unindexed parent clear then replace`, async function () { + const source = await perspective.table(SCHEMA); + await source.update(rows(24)); + const view = await source.view(config); + const derived = await perspective.table(view); + await source.clear(); + await expect_flat_parity(view, derived); + await source.update(rows(8, 3)); + await expect_flat_parity(view, derived); + await source.replace(rows(5, 50)); + await expect_flat_parity(view, derived); + await derived.delete(); + await view.delete(); + await source.delete(); + }); + } }); diff --git a/rust/perspective-js/test/js/multi_server.spec.js b/rust/perspective-js/test/js/multi_server.spec.js index 9a707d1e29..cf2bf2e7cf 100644 --- a/rust/perspective-js/test/js/multi_server.spec.js +++ b/rust/perspective-js/test/js/multi_server.spec.js @@ -108,5 +108,218 @@ import perspective from "./perspective_client"; expect(json0).toEqual([{ x: 3, y: 4 }]); expect(json0).toEqual(json1); }); + + test("Replace transfers to an unindexed remote Table", async function ({ + page, + }) { + await page.goto("/rust/perspective-js/test/html/test.html"); + const [json0, json1] = await page.evaluate(async () => { + let perspective = await import( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/cdn/perspective.js" + ); + + perspective.init_client( + fetch( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/wasm/perspective-js.wasm", + ), + ); + + const settle = async (a, b) => { + for (let i = 0; i < 50; i++) { + const [x, y] = await Promise.all([ + a.to_json(), + b.to_json(), + ]); + if (JSON.stringify(x) === JSON.stringify(y)) { + return; + } + + await new Promise((resolve) => + setTimeout(resolve, 100), + ); + } + }; + + const worker0 = await perspective.worker(); + const worker1 = await perspective.worker(); + const table0 = await worker0.table("x,y\n1,2\n3,4"); + const view0 = await table0.view(); + const table1 = await worker1.table(view0); + const view1 = await table1.view(); + await table0.replace([ + { x: 5, y: 6 }, + { x: 7, y: 8 }, + ]); + + await settle(view0, view1); + return [await view0.to_json(), await view1.to_json()]; + }); + + expect(json0).toEqual([ + { x: 5, y: 6 }, + { x: 7, y: 8 }, + ]); + + expect(json1).toEqual(json0); + }); + + test("Clear transfers to an unindexed remote Table", async function ({ + page, + }) { + await page.goto("/rust/perspective-js/test/html/test.html"); + const [json0, json1] = await page.evaluate(async () => { + let perspective = await import( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/cdn/perspective.js" + ); + + perspective.init_client( + fetch( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/wasm/perspective-js.wasm", + ), + ); + + const settle = async (a, b) => { + for (let i = 0; i < 50; i++) { + const [x, y] = await Promise.all([ + a.to_json(), + b.to_json(), + ]); + if (JSON.stringify(x) === JSON.stringify(y)) { + return; + } + + await new Promise((resolve) => + setTimeout(resolve, 100), + ); + } + }; + + const worker0 = await perspective.worker(); + const worker1 = await perspective.worker(); + const table0 = await worker0.table("x,y\n1,2\n3,4"); + const view0 = await table0.view(); + const table1 = await worker1.table(view0); + const view1 = await table1.view(); + await table0.clear(); + await settle(view0, view1); + return [await view0.to_json(), await view1.to_json()]; + }); + + expect(json0).toEqual([]); + expect(json1).toEqual([]); + }); + + test("Replace transfers to a remote Table whose View omits the index", async function ({ + page, + }) { + await page.goto("/rust/perspective-js/test/html/test.html"); + const [json0, json1] = await page.evaluate(async () => { + let perspective = await import( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/cdn/perspective.js" + ); + + perspective.init_client( + fetch( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/wasm/perspective-js.wasm", + ), + ); + + const settle = async (a, b) => { + for (let i = 0; i < 50; i++) { + const [x, y] = await Promise.all([ + a.to_json(), + b.to_json(), + ]); + if (JSON.stringify(x) === JSON.stringify(y)) { + return; + } + + await new Promise((resolve) => + setTimeout(resolve, 100), + ); + } + }; + + const worker0 = await perspective.worker(); + const worker1 = await perspective.worker(); + const table0 = await worker0.table("x,y\n1,2\n3,4", { + index: "x", + }); + + const view0 = await table0.view({ columns: ["y"] }); + const table1 = await worker1.table(view0); + const view1 = await table1.view(); + await table0.replace([{ x: 1, y: 20 }]); + await settle(view0, view1); + return [await view0.to_json(), await view1.to_json()]; + }); + + expect(json0).toEqual([{ y: 20 }]); + expect(json1).toEqual(json0); + }); + + test("Replace and clear transfer to an indexed remote Table", async function ({ + page, + }) { + await page.goto("/rust/perspective-js/test/html/test.html"); + const [json0, json1] = await page.evaluate(async () => { + let perspective = await import( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/cdn/perspective.js" + ); + + perspective.init_client( + fetch( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/wasm/perspective-js.wasm", + ), + ); + + const settle = async (a, b) => { + for (let i = 0; i < 50; i++) { + const [x, y] = await Promise.all([ + a.to_json(), + b.to_json(), + ]); + if (JSON.stringify(x) === JSON.stringify(y)) { + return; + } + + await new Promise((resolve) => + setTimeout(resolve, 100), + ); + } + }; + + const worker0 = await perspective.worker(); + const worker1 = await perspective.worker(); + const table0 = await worker0.table("x,y\n1,2\n3,4", { + index: "x", + }); + + const view0 = await table0.view(); + const table1 = await worker1.table(view0); + const view1 = await table1.view(); + await table0.replace([ + { x: 3, y: 40 }, + { x: 5, y: 6 }, + ]); + + await settle(view0, view1); + const replaced = [await view0.to_json(), await view1.to_json()]; + await table0.clear(); + await settle(view0, view1); + return [ + replaced, + [await view0.to_json(), await view1.to_json()], + ]; + }); + + expect(json0[0]).toEqual([ + { x: 3, y: 40 }, + { x: 5, y: 6 }, + ]); + + expect(json0[1]).toEqual(json0[0]); + expect(json1).toEqual([[], []]); + }); }); })(perspective); diff --git a/rust/perspective-python/perspective/tests/core/test_async.py b/rust/perspective-python/perspective/tests/core/test_async.py index 4c4f3b2444..8ca7091af4 100644 --- a/rust/perspective-python/perspective/tests/core/test_async.py +++ b/rust/perspective-python/perspective/tests/core/test_async.py @@ -265,7 +265,7 @@ def call_loop(fn, *args): view = tbl.view() tbl2 = client.table(view.to_arrow()) - def _update(port_id, delta): + def _update(port_id, delta, reset): print("Updating tbl2", delta) tbl2.update(delta) diff --git a/rust/perspective-python/perspective/tests/server/test_server.py b/rust/perspective-python/perspective/tests/server/test_server.py index d238ef7468..ef132022f5 100644 --- a/rust/perspective-python/perspective/tests/server/test_server.py +++ b/rust/perspective-python/perspective/tests/server/test_server.py @@ -425,7 +425,7 @@ def test_server_create_view_and_update_table(self): def test_server_on_update_rows(self, sentinel): s = sentinel(0) - def update_callback(port_id, delta): + def update_callback(port_id, delta, reset): table = Table(delta) assert table.size() == 1 assert table.schema() == {"a": "integer", "b": "string"} @@ -459,7 +459,7 @@ def update_callback(*args, **kwargs): def test_server_on_update_rows_with_port_id(self, sentinel): s = sentinel(0) - def update_callback(port_id, delta): + def update_callback(port_id, delta, reset): table = Table(delta) assert table.size() == 1 assert table.schema() == {"a": "integer", "b": "string"} @@ -525,7 +525,7 @@ def update_callback(port_id, delta): def test_server_remove_update(self, sentinel): s = sentinel(0) - def update_callback(port_id, delta): + def update_callback(port_id, delta, reset): s.set(s.get() + 1) # create a table and view using client @@ -706,7 +706,7 @@ def post_update(msg, binary=False): def test_server_on_update_rows_with_port_id_through_wire_API(self, sentinel): s = sentinel(0) - def update_callback(port_id, delta): + def update_callback(port_id, delta, reset): table = Table(delta) assert table.size() == 1 assert table.schema() == {"a": "integer", "b": "string"} @@ -808,7 +808,7 @@ def post_update(msg, binary=False): def test_server_remove_update_through_wire_API(self, sentinel): s = sentinel(0) - def update_callback(port_id, delta): + def update_callback(port_id, delta, reset): s.set(s.get() + 1) # create a table and view using client diff --git a/rust/perspective-python/perspective/tests/table/test_ports.py b/rust/perspective-python/perspective/tests/table/test_ports.py index 1e907d0b84..0953fca648 100644 --- a/rust/perspective-python/perspective/tests/table/test_ports.py +++ b/rust/perspective-python/perspective/tests/table/test_ports.py @@ -106,7 +106,7 @@ def test_ports_should_have_unique_deltas(self): port: [{"a": port, "b": str(port), "c": True}] for port in ports_to_update } - def callback(port_id, delta): + def callback(port_id, delta, reset): assert port_id in ports_to_update _t = Table(delta) _v = _t.view() @@ -157,13 +157,13 @@ def test_ports_multiple_tables_with_different_ports(self): # when the client updates, check whether the port id matches that # of the server, and complete the test. - def client_callback(port_id, delta): + def client_callback(port_id, delta, reset): if port_id == client_port: print("UPDATING SERVER") server.update(delta, port_id=server_port_for_client) # when the server updates, pass the update back to the client - def server_callback(port_id, delta): + def server_callback(port_id, delta, reset): print("UPDATING CLIENT") assert port_id == server_port_for_client assert server.size() == 8 diff --git a/rust/perspective-python/perspective/tests/table/test_table.py b/rust/perspective-python/perspective/tests/table/test_table.py index 1edb245512..08aefd937e 100644 --- a/rust/perspective-python/perspective/tests/table/test_table.py +++ b/rust/perspective-python/perspective/tests/table/test_table.py @@ -672,7 +672,7 @@ def test_table_replace_should_fire_on_update_with_delta(self, sentinel): s = sentinel(False) - def updater(port_id, delta): + def updater(port_id, delta, reset): # assert port_id == 0 table2 = Table(delta) assert table2.view().to_records() == data2 diff --git a/rust/perspective-python/perspective/tests/table/test_view.py b/rust/perspective-python/perspective/tests/table/test_view.py index 7532b7e412..5919683564 100644 --- a/rust/perspective-python/perspective/tests/table/test_view.py +++ b/rust/perspective-python/perspective/tests/table/test_view.py @@ -876,7 +876,7 @@ def test_view_variance_multi_update_delta(self): # 2 here should result in null var because the group size is 1 update_data = {"a": [15.12, 9.102, 0.99, 12.8], "b": [1, 0, 1, 2]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): table2 = Table(delta) view2 = table2.view() result = view2.to_columns() @@ -1025,7 +1025,7 @@ def test_view_variance_multi_update_indexed_delta(self): "c": [0, 4, 1, 6], } - def cb1(port_id, delta): + def cb1(port_id, delta, reset): table2 = Table(delta) view2 = table2.view() result = view2.to_columns() @@ -1236,7 +1236,7 @@ def test_view_standard_deviation_multi_update_delta(self): # 2 here should result in null stddev because the group size is 1 update_data = {"a": [15.12, 9.102, 0.99, 12.8], "b": [1, 0, 1, 2]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): table2 = Table(delta) view2 = table2.view() result = view2.to_columns() @@ -1385,7 +1385,7 @@ def test_view_standard_deviation_multi_update_indexed_delta(self): "c": [0, 4, 1, 6], } - def cb1(port_id, delta): + def cb1(port_id, delta, reset): table2 = Table(delta) view2 = table2.view() result = view2.to_columns() @@ -1931,7 +1931,7 @@ def test_view_row_delta_zero(self, util): data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] update_data = {"a": [5], "b": [6]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, update_data) tbl = Table(data) @@ -1939,11 +1939,26 @@ def cb1(port_id, delta): view.on_update(cb1, mode="row") tbl.update(update_data) + def test_view_row_delta_reset_flag(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + steps = [] + + def cb1(port_id, delta, reset): + steps.append(reset) + + tbl = Table(data, index="a") + view = tbl.view() + view.on_update(cb1, mode="row") + tbl.update([{"a": 1, "b": 5}]) + tbl.replace(data) + tbl.clear() + assert steps == [False, True, True] + def test_view_row_delta_zero_column_subset(self, util): data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] update_data = {"a": [5], "b": [6]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"b": [6]}) tbl = Table(data) @@ -1954,7 +1969,7 @@ def cb1(port_id, delta): def test_view_row_delta_zero_from_schema(self, util): update_data = {"a": [5], "b": [6]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, update_data) tbl = Table({"a": "integer", "b": "integer"}) @@ -1965,7 +1980,7 @@ def cb1(port_id, delta): def test_view_row_delta_zero_from_schema_column_subset(self, util): update_data = {"a": [5], "b": [6]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"b": [6]}) tbl = Table({"a": "integer", "b": "integer"}) @@ -1977,7 +1992,7 @@ def cb1(port_id, delta): def test_view_row_delta_zero_from_schema_filtered(self, util): update_data = {"a": [8, 9, 10, 11], "b": [1, 2, 3, 4]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"a": [11], "b": [4]}) tbl = Table({"a": "integer", "b": "integer"}) @@ -1988,7 +2003,7 @@ def cb1(port_id, delta): def test_view_row_delta_zero_from_schema_indexed(self, util): update_data = {"a": ["a", "b", "a"], "b": [1, 2, 3]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"a": ["a", "b"], "b": [3, 2]}) tbl = Table({"a": "string", "b": "integer"}, index="a") @@ -2001,7 +2016,7 @@ def cb1(port_id, delta): def test_view_row_delta_zero_from_schema_indexed_filtered(self, util): update_data = {"a": [8, 9, 10, 11, 11], "b": [1, 2, 3, 4, 5]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"a": [11], "b": [5]}) tbl = Table({"a": "integer", "b": "integer"}, index="a") @@ -2013,7 +2028,7 @@ def test_view_row_delta_one(self, util): data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] update_data = {"a": [5], "b": [6]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"a": [9, 5], "b": [12, 6]}) tbl = Table(data) @@ -2029,7 +2044,7 @@ def cb1(port_id, delta): def test_view_row_delta_one_from_schema(self, util): update_data = {"a": [1, 2, 3, 4, 5], "b": [6, 7, 8, 9, 10]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"a": [15, 1, 2, 3, 4, 5], "b": [40, 6, 7, 8, 9, 10]}) tbl = Table({"a": "integer", "b": "integer"}) @@ -2040,7 +2055,7 @@ def cb1(port_id, delta): def test_view_row_delta_one_from_schema_sorted(self, util): update_data = {"a": [1, 2, 3, 4, 5], "b": [6, 7, 8, 9, 10]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"a": [15, 5, 4, 3, 2, 1], "b": [40, 10, 9, 8, 7, 6]}) tbl = Table({"a": "integer", "b": "integer"}) @@ -2051,7 +2066,7 @@ def cb1(port_id, delta): def test_view_row_delta_one_from_schema_filtered(self, util): update_data = {"a": [1, 2, 3, 4, 5], "b": [6, 7, 8, 9, 10]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"a": [9, 4, 5], "b": [19, 9, 10]}) tbl = Table({"a": "integer", "b": "integer"}) @@ -2062,7 +2077,7 @@ def cb1(port_id, delta): def test_view_row_delta_one_from_schema_sorted_filtered(self, util): update_data = {"a": [1, 2, 3, 4, 5], "b": [6, 7, 8, 9, 10]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"a": [9, 5, 4], "b": [19, 10, 9]}) tbl = Table({"a": "integer", "b": "integer"}) @@ -2073,7 +2088,7 @@ def cb1(port_id, delta): def test_view_row_delta_one_from_schema_indexed(self, util): update_data = {"a": [1, 2, 3, 4, 5, 5, 4], "b": [6, 7, 8, 9, 10, 11, 12]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"a": [15, 1, 2, 3, 4, 5], "b": [44, 6, 7, 8, 12, 11]}) tbl = Table({"a": "integer", "b": "integer"}, index="a") @@ -2086,7 +2101,7 @@ def cb1(port_id, delta): def test_view_row_delta_one_from_schema_sorted_indexed(self, util): update_data = {"a": [1, 2, 3, 4, 5, 5, 4], "b": [6, 7, 8, 9, 10, 11, 12]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"a": [15, 4, 5, 3, 2, 1], "b": [44, 12, 11, 8, 7, 6]}) tbl = Table({"a": "integer", "b": "integer"}, index="a") @@ -2099,7 +2114,7 @@ def cb1(port_id, delta): def test_view_row_delta_one_from_schema_filtered_indexed(self, util): update_data = {"a": [1, 2, 3, 4, 5, 5, 4], "b": [6, 7, 8, 9, 10, 11, 12]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta(delta, {"a": [9, 4, 5], "b": [23, 12, 11]}) tbl = Table({"a": "integer", "b": "integer"}, index="a") @@ -2113,7 +2128,7 @@ def test_view_row_delta_two(self, util): data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] update_data = {"a": [5], "b": [6]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta( delta, { @@ -2141,7 +2156,7 @@ def cb1(port_id, delta): def test_view_row_delta_two_from_schema(self, util): data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta( delta, { @@ -2160,7 +2175,7 @@ def cb1(port_id, delta): def test_view_row_delta_two_from_schema_indexed(self, util): data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"a": 3, "b": 5}] - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta( delta, { @@ -2180,7 +2195,7 @@ def test_view_row_delta_two_column_only(self, util): data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] update_data = {"a": [5], "b": [6]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta( delta, { @@ -2208,7 +2223,7 @@ def test_view_row_delta_two_column_only_indexed(self, util): data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"a": 3, "b": 5}] update_data = {"a": [5], "b": [6]} - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta( delta, { @@ -2235,7 +2250,7 @@ def cb1(port_id, delta): def test_view_row_delta_two_column_only_from_schema(self, util): data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta( delta, { @@ -2254,7 +2269,7 @@ def cb1(port_id, delta): def test_view_row_delta_two_column_only_from_schema_indexed(self, util): data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"a": 3, "b": 5}] - def cb1(port_id, delta): + def cb1(port_id, delta, reset): compare_delta( delta, { diff --git a/rust/perspective-python/src/client/client_async.rs b/rust/perspective-python/src/client/client_async.rs index 2dad3c0455..1301fb2f36 100644 --- a/rust/perspective-python/src/client/client_async.rs +++ b/rust/perspective-python/src/client/client_async.rs @@ -963,17 +963,17 @@ impl AsyncView { } /// Register a callback with this [`View`]. Whenever the view's underlying - /// table emits an update, this callback will be invoked with an object - /// containing `port_id`, indicating which port the update fired on, and - /// optionally `delta`, which is the new data that was updated for each - /// cell or each row. + /// table emits an update, this callback will be invoked with `port_id`, + /// indicating which port the update fired on, and in `"row"` mode also + /// with `delta`, the new data that was updated for each row, and `reset`, + /// which is `True` when the step began with `clear()` or `replace()` so + /// `delta` is relative to an empty table. /// /// # Arguments /// - /// - `on_update` - A callback function invoked on update, which receives an - /// object with two keys: `port_id`, indicating which port the update was - /// triggered on, and `delta`, whose value is dependent on the mode - /// parameter. + /// - `on_update` - A callback function invoked on update, which receives + /// `port_id`, indicating which port the update was triggered on, and in + /// `"row"` mode `delta` and `reset`. /// - `options` - If this is provided as `OnUpdateOptions { mode: /// Some(OnUpdateMode::Row) }`, then `delta` is an Arrow of the updated /// rows. Otherwise `delta` will be [`Option::None`]. @@ -988,7 +988,7 @@ impl AsyncView { match &x.delta { None => callback.call1(py, (x.port_id,))?, Some(delta) => { - callback.call1(py, (x.port_id, PyBytes::new(py, delta)))? + callback.call1(py, (x.port_id, PyBytes::new(py, delta), x.reset))? }, }; diff --git a/rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp b/rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp index 3c41f942e1..f2afb1db0d 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp @@ -337,13 +337,16 @@ t_gnode::_process_mask_existed_rows(t_process_state& process_state) { void t_gnode::_take_reset_removes( - const t_column* pkey_col, t_uindex flattened_num_rows + const t_column* pkey_col, + const t_column* op_col, + t_uindex flattened_num_rows ) { if (!m_reset_pending) { return; } m_reset_pending = false; + m_was_reset = true; if (!m_reset_pkeys) { return; } @@ -351,6 +354,10 @@ t_gnode::_take_reset_removes( tsl::hopscotch_set incoming; incoming.reserve(flattened_num_rows); for (t_uindex idx = 0; idx < flattened_num_rows; ++idx) { + if (*(op_col->get_nth(idx)) == OP_DELETE) { + continue; + } + incoming.insert(pkey_col->get_scalar(idx)); } @@ -609,6 +616,7 @@ t_gnode::_process_table(t_uindex port_id) { } m_reset_pending = false; + m_was_reset = true; m_removed_pkeys = std::move(m_reset_pkeys); m_reset_pkeys = nullptr; m_was_updated = true; @@ -633,7 +641,9 @@ t_gnode::_process_table(t_uindex port_id) { row_lookup[idx] = m_gstate->lookup(pkey); } - _take_reset_removes(pkey_col, flattened_num_rows); + _take_reset_removes( + pkey_col, flattened->_get_const_column("psp_op"), flattened_num_rows + ); // first update - master table is empty if (m_gstate->mapping_size() == 0) { @@ -895,6 +905,7 @@ t_gnode::process_derived(const t_derived_step& step) { PSP_TRACE_SENTINEL(); PSP_VERBOSE_ASSERT(m_init, "Cannot `process_derived` on an uninited gnode."); m_was_updated = false; + m_was_reset = false; m_removed_pkeys = nullptr; const std::shared_ptr& flattened = step.m_flattened; @@ -905,6 +916,7 @@ t_gnode::process_derived(const t_derived_step& step) { } m_reset_pending = false; + m_was_reset = true; m_removed_pkeys = std::move(m_reset_pkeys); m_reset_pkeys = nullptr; m_was_updated = true; @@ -923,7 +935,9 @@ t_gnode::process_derived(const t_derived_step& step) { identity_lookup[idx] = t_rlookup(idx, exists); } - _take_reset_removes(pkey_col, flattened_num_rows); + _take_reset_removes( + pkey_col, flattened->_get_const_column("psp_op"), flattened_num_rows + ); if (m_gstate->mapping_size() == 0) { m_gstate->commit_derived(flattened, step.m_rows); @@ -2105,6 +2119,7 @@ t_gnode::clear_output_ports() { m_oport->get_table()->clear(); } m_removed_pkeys = nullptr; + m_was_reset = false; } std::shared_ptr @@ -2112,6 +2127,11 @@ t_gnode::get_removed_pkeys() const { return m_removed_pkeys; } +bool +t_gnode::get_was_reset() const { + return m_was_reset; +} + void t_gnode::set_removes_enabled(bool enabled) { m_removes_enabled = enabled; diff --git a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp index f0fb4229d5..6f17aacbed 100644 --- a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp +++ b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp @@ -4088,6 +4088,7 @@ ProtoServer::_notify_table( out.set_entity_id(view_id); auto* r = out.mutable_view_on_update_resp(); r->set_port_id(port_id); + r->set_reset(table->get_gnode()->get_was_reset()); if (view->get_deltas_enabled()) { *r->mutable_delta() = *view->get_row_delta_as_arrow(); } diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h b/rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h index bbeb1450ae..abd705030e 100644 --- a/rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h @@ -172,6 +172,11 @@ class PERSPECTIVE_EXPORT t_gnode { */ std::shared_ptr get_removed_pkeys() const; + /** + * @brief Whether the most recent step consumed a pending `reset()`. + */ + bool get_was_reset() const; + void set_removes_enabled(bool enabled); bool get_removes_enabled() const; @@ -355,7 +360,9 @@ class PERSPECTIVE_EXPORT t_gnode { * `pkey_col` as removed. */ void _take_reset_removes( - const t_column* pkey_col, t_uindex flattened_num_rows + const t_column* pkey_col, + const t_column* op_col, + t_uindex flattened_num_rows ); t_mask _compute_transitions( @@ -482,6 +489,7 @@ class PERSPECTIVE_EXPORT t_gnode { bool m_was_updated; bool m_removes_enabled = false; bool m_reset_pending = false; + bool m_was_reset = false; std::shared_ptr m_removed_pkeys; std::shared_ptr m_reset_pkeys; From d99be52b0d9607fd0b22da0bc069e124c58958e4 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Thu, 5 Nov 2026 04:29:39 -0500 Subject: [PATCH 3/3] Streamline migrations Signed-off-by: Andrew Stein --- Cargo.lock | 9 -- Cargo.toml | 1 - docs/md/how_to/rust/migrate.md | 2 +- rust/perspective-js/build.mjs | 6 +- .../src/ts/perspective.browser.ts | 2 +- rust/perspective-js/src/ts/perspective.cdn.ts | 2 +- .../perspective-js/src/ts/perspective.node.ts | 2 +- rust/perspective-js/src/ts/wasm/migrate.ts | 4 +- rust/perspective-js/src/ts/wasm/registry.ts | 4 +- rust/perspective-js/test/js/cdn_url.spec.ts | 135 ------------------ .../test/js/multi_server.spec.js | 130 ++++++++++------- rust/perspective-js/test/js/registry.spec.js | 2 +- rust/perspective-migrate-js/Cargo.toml | 33 ----- rust/perspective-migrate-js/src/lib.rs | 41 ------ rust/perspective-migrate/Cargo.toml | 3 + rust/perspective-migrate/src/lib.rs | 28 ++++ rust/perspective-viewer/src/ts/bootstrap.ts | 2 +- .../src/ts/perspective-viewer.cdn.ts | 2 +- .../restore_workspace_layout.spec.ts | 6 +- 19 files changed, 128 insertions(+), 286 deletions(-) delete mode 100644 rust/perspective-js/test/js/cdn_url.spec.ts delete mode 100644 rust/perspective-migrate-js/Cargo.toml delete mode 100644 rust/perspective-migrate-js/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 42e2614fb4..74bdab38bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2016,15 +2016,6 @@ version = "5.6.1" dependencies = [ "perspective-client", "serde", - "serde_json", -] - -[[package]] -name = "perspective-migrate-js" -version = "5.6.1" -dependencies = [ - "perspective-migrate", - "serde", "serde-wasm-bindgen", "serde_json", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 39b8633aa7..4d5741b820 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,6 @@ members = [ "rust/perspective-client", "rust/perspective-js", "rust/perspective-migrate", - "rust/perspective-migrate-js", "rust/perspective-python", "rust/perspective-server", "rust/perspective-viewer", diff --git a/docs/md/how_to/rust/migrate.md b/docs/md/how_to/rust/migrate.md index 748fa52859..4ced98c459 100644 --- a/docs/md/how_to/rust/migrate.md +++ b/docs/md/how_to/rust/migrate.md @@ -43,7 +43,7 @@ await viewer.restoreWorkspace(value); ``` `MIGRATE_WASM` is -`@perspective-dev/client/dist/wasm/perspective-migrate-js.wasm`. In Node.js, +`@perspective-dev/client/dist/wasm/perspective-migrate.wasm`. In Node.js, `perspective.migrate()` needs no registration. ```python diff --git a/rust/perspective-js/build.mjs b/rust/perspective-js/build.mjs index bf696e7d0b..009b3ce362 100644 --- a/rust/perspective-js/build.mjs +++ b/rust/perspective-js/build.mjs @@ -140,13 +140,13 @@ async function build_rust() { ); execSync( - `PSP_ROOT_DIR=../.. cargo bundle --target=${get_host()} -- perspective_migrate_js ${release_flag} --no-tracing`, + `PSP_ROOT_DIR=../.. cargo bundle --target=${get_host()} -- perspective_migrate ${release_flag} --no-tracing`, INHERIT, ); await compress( - "dist/wasm/perspective-migrate-js.wasm", - "dist/wasm/perspective-migrate-js.wasm", + "dist/wasm/perspective-migrate.wasm", + "dist/wasm/perspective-migrate.wasm", ); } diff --git a/rust/perspective-js/src/ts/perspective.browser.ts b/rust/perspective-js/src/ts/perspective.browser.ts index 8e75499c5f..d6431aa0f5 100644 --- a/rust/perspective-js/src/ts/perspective.browser.ts +++ b/rust/perspective-js/src/ts/perspective.browser.ts @@ -152,7 +152,7 @@ function is_wasm_source( * perspective.init_server({ * wasm32: () => fetch("perspective-server.wasm"), * wasm64: () => fetch("perspective-server.memory64.wasm"), - * migrate: () => fetch("perspective-migrate-js.wasm"), + * migrate: () => fetch("perspective-migrate.wasm"), * }); * ``` */ diff --git a/rust/perspective-js/src/ts/perspective.cdn.ts b/rust/perspective-js/src/ts/perspective.cdn.ts index d0d7d3851f..124886212e 100644 --- a/rust/perspective-js/src/ts/perspective.cdn.ts +++ b/rust/perspective-js/src/ts/perspective.cdn.ts @@ -27,7 +27,7 @@ register_default({ ), ), migrate: () => - fetch(new URL("../wasm/perspective-migrate-js.wasm", import.meta.url)), + fetch(new URL("../wasm/perspective-migrate.wasm", import.meta.url)), }); export default perspective; diff --git a/rust/perspective-js/src/ts/perspective.node.ts b/rust/perspective-js/src/ts/perspective.node.ts index 35b80afde8..23554c1a8d 100644 --- a/rust/perspective-js/src/ts/perspective.node.ts +++ b/rust/perspective-js/src/ts/perspective.node.ts @@ -86,7 +86,7 @@ await perspective_client.default({ module_or_path: uncompressed_client_wasm }); const MIGRATE = new MigrateLoader(); MIGRATE.register(async () => { const buffer = await fs.readFile( - path.join(__dirname, "../../dist/wasm/perspective-migrate-js.wasm"), + path.join(__dirname, "../../dist/wasm/perspective-migrate.wasm"), ); return await load_wasm_stage_0(buffer.buffer as ArrayBuffer); diff --git a/rust/perspective-js/src/ts/wasm/migrate.ts b/rust/perspective-js/src/ts/wasm/migrate.ts index d108bc3762..09f40ae457 100644 --- a/rust/perspective-js/src/ts/wasm/migrate.ts +++ b/rust/perspective-js/src/ts/wasm/migrate.ts @@ -10,7 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import * as migrate_wasm from "../../../dist/wasm/perspective-migrate-js.js"; +import * as migrate_wasm from "../../../dist/wasm/perspective-migrate.js"; /** * The kind of persisted config `migrate()` upgrades: an engine `ViewConfig`, @@ -57,7 +57,7 @@ export class MigrateLoader { ): Promise> { if (this.module === undefined) { if (this.source === undefined) { - throw new Error("Missing perspective-migrate-js.wasm"); + throw new Error("Missing perspective-migrate.wasm"); } const source = this.source; diff --git a/rust/perspective-js/src/ts/wasm/registry.ts b/rust/perspective-js/src/ts/wasm/registry.ts index f0409f2c90..724c933137 100644 --- a/rust/perspective-js/src/ts/wasm/registry.ts +++ b/rust/perspective-js/src/ts/wasm/registry.ts @@ -345,7 +345,7 @@ export function register( * - `wasm` - Binaries by role: `client` (`perspective-js.wasm`), `viewer` * (`perspective-viewer.wasm`, which also backs every `Client`), `server` * (one engine build) or `server32` / `server64` (selected by Memory64 - * support), and `migrate` (`perspective-migrate-js.wasm`). + * support), and `migrate` (`perspective-migrate.wasm`). * - `options` - `disable_stage_0` skips decompression for these sources. * * # Examples @@ -500,7 +500,7 @@ export function resolve_migrate(): Promise { if (entry === undefined) { return Promise.reject( new Error( - "Missing perspective-migrate-js.wasm: register `migrate` with init()", + "Missing perspective-migrate.wasm: register `migrate` with init()", ), ); } diff --git a/rust/perspective-js/test/js/cdn_url.spec.ts b/rust/perspective-js/test/js/cdn_url.spec.ts deleted file mode 100644 index 1afa1ce160..0000000000 --- a/rust/perspective-js/test/js/cdn_url.spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import { test, expect } from "@perspective-dev/test"; -import { - resolve_package_wasm_url, - resolve_server_wasm_url, -} from "../../src/ts/wasm/cdn.ts"; - -test.describe("resolve_server_wasm_url", function () { - test("preserves exact version tags on jsdelivr URLs", function () { - expect( - resolve_server_wasm_url( - "https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.5.1/dist/cdn/perspective.js", - ), - ).toEqual( - "https://cdn.jsdelivr.net/npm/@perspective-dev/server@4.5.1/dist/wasm/perspective-server.wasm", - ); - }); - - test("preserves range and dist-tag aliases", function () { - expect( - resolve_server_wasm_url( - "https://cdn.jsdelivr.net/npm/@perspective-dev/client@4/dist/cdn/perspective.js", - ), - ).toEqual( - "https://cdn.jsdelivr.net/npm/@perspective-dev/server@4/dist/wasm/perspective-server.wasm", - ); - - expect( - resolve_server_wasm_url( - "https://cdn.jsdelivr.net/npm/@perspective-dev/client@latest/dist/cdn/perspective.js", - ), - ).toEqual( - "https://cdn.jsdelivr.net/npm/@perspective-dev/server@latest/dist/wasm/perspective-server.wasm", - ); - }); - - test("resolves alternate binaries with the version tag preserved", function () { - expect( - resolve_server_wasm_url( - "https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.5.1/dist/cdn/perspective.js", - "perspective-server.memory64.wasm", - ), - ).toEqual( - "https://cdn.jsdelivr.net/npm/@perspective-dev/server@4.5.1/dist/wasm/perspective-server.memory64.wasm", - ); - - expect( - resolve_server_wasm_url( - "http://localhost:6598/node_modules/@perspective-dev/client/dist/cdn/perspective.js", - "perspective-server.memory64.wasm", - ), - ).toEqual( - "http://localhost:6598/node_modules/@perspective-dev/server/dist/wasm/perspective-server.memory64.wasm", - ); - }); - - test("preserves version tags on unpkg URLs", function () { - expect( - resolve_server_wasm_url( - "https://unpkg.com/@perspective-dev/client@4.5.1/dist/cdn/perspective.js", - ), - ).toEqual( - "https://unpkg.com/@perspective-dev/server@4.5.1/dist/wasm/perspective-server.wasm", - ); - }); - - test("resolves untagged node_modules layouts to the sibling package", function () { - expect( - resolve_server_wasm_url( - "http://localhost:6598/node_modules/@perspective-dev/client/dist/cdn/perspective.js", - ), - ).toEqual( - "http://localhost:6598/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm", - ); - }); - - test("matches minified and query-string variants", function () { - expect( - resolve_server_wasm_url( - "https://cdn.jsdelivr.net/npm/@perspective-dev/client@4.5.1/dist/cdn/perspective.min.js?v=1", - ), - ).toEqual( - "https://cdn.jsdelivr.net/npm/@perspective-dev/server@4.5.1/dist/wasm/perspective-server.wasm", - ); - }); - - test("falls back to relative resolution for unrecognized layouts", function () { - expect( - resolve_server_wasm_url( - "https://example.com/assets/perspective.js", - ), - ).toEqual( - "https://example.com/server/dist/wasm/perspective-server.wasm", - ); - }); -}); - -test.describe("resolve_package_wasm_url", function () { - test("resolves the client's migrate binary from the viewer bundle", function () { - expect( - resolve_package_wasm_url( - "https://cdn.jsdelivr.net/npm/@perspective-dev/viewer@5.7.0/dist/cdn/perspective-viewer.js", - "viewer", - "client", - "perspective-migrate-js.wasm", - ), - ).toEqual( - "https://cdn.jsdelivr.net/npm/@perspective-dev/client@5.7.0/dist/wasm/perspective-migrate-js.wasm", - ); - }); - - test("resolves node_modules layouts to the sibling package", function () { - expect( - resolve_package_wasm_url( - "http://localhost:6598/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js", - "viewer", - "client", - "perspective-migrate-js.wasm", - ), - ).toEqual( - "http://localhost:6598/node_modules/@perspective-dev/client/dist/wasm/perspective-migrate-js.wasm", - ); - }); -}); diff --git a/rust/perspective-js/test/js/multi_server.spec.js b/rust/perspective-js/test/js/multi_server.spec.js index cf2bf2e7cf..a8ad73b914 100644 --- a/rust/perspective-js/test/js/multi_server.spec.js +++ b/rust/perspective-js/test/js/multi_server.spec.js @@ -124,20 +124,26 @@ import perspective from "./perspective_client"; ), ); - const settle = async (a, b) => { - for (let i = 0; i < 50; i++) { + const sync = async (view0, view1, action) => { + const { promise, resolve } = Promise.withResolvers(); + const token = await view1.on_update(async () => { const [x, y] = await Promise.all([ - a.to_json(), - b.to_json(), + view0.to_json(), + view1.to_json(), ]); + if (JSON.stringify(x) === JSON.stringify(y)) { - return; + resolve(); } + }); + + await action(); + await Promise.race([ + promise, + new Promise((resolve) => setTimeout(resolve, 2000)), + ]); - await new Promise((resolve) => - setTimeout(resolve, 100), - ); - } + await view1.remove_update(token); }; const worker0 = await perspective.worker(); @@ -146,12 +152,13 @@ import perspective from "./perspective_client"; const view0 = await table0.view(); const table1 = await worker1.table(view0); const view1 = await table1.view(); - await table0.replace([ - { x: 5, y: 6 }, - { x: 7, y: 8 }, - ]); + await sync(view0, view1, () => + table0.replace([ + { x: 5, y: 6 }, + { x: 7, y: 8 }, + ]), + ); - await settle(view0, view1); return [await view0.to_json(), await view1.to_json()]; }); @@ -178,20 +185,26 @@ import perspective from "./perspective_client"; ), ); - const settle = async (a, b) => { - for (let i = 0; i < 50; i++) { + const sync = async (view0, view1, action) => { + const { promise, resolve } = Promise.withResolvers(); + const token = await view1.on_update(async () => { const [x, y] = await Promise.all([ - a.to_json(), - b.to_json(), + view0.to_json(), + view1.to_json(), ]); + if (JSON.stringify(x) === JSON.stringify(y)) { - return; + resolve(); } + }); + + await action(); + await Promise.race([ + promise, + new Promise((resolve) => setTimeout(resolve, 2000)), + ]); - await new Promise((resolve) => - setTimeout(resolve, 100), - ); - } + await view1.remove_update(token); }; const worker0 = await perspective.worker(); @@ -200,8 +213,7 @@ import perspective from "./perspective_client"; const view0 = await table0.view(); const table1 = await worker1.table(view0); const view1 = await table1.view(); - await table0.clear(); - await settle(view0, view1); + await sync(view0, view1, () => table0.clear()); return [await view0.to_json(), await view1.to_json()]; }); @@ -224,20 +236,26 @@ import perspective from "./perspective_client"; ), ); - const settle = async (a, b) => { - for (let i = 0; i < 50; i++) { + const sync = async (view0, view1, action) => { + const { promise, resolve } = Promise.withResolvers(); + const token = await view1.on_update(async () => { const [x, y] = await Promise.all([ - a.to_json(), - b.to_json(), + view0.to_json(), + view1.to_json(), ]); + if (JSON.stringify(x) === JSON.stringify(y)) { - return; + resolve(); } + }); - await new Promise((resolve) => - setTimeout(resolve, 100), - ); - } + await action(); + await Promise.race([ + promise, + new Promise((resolve) => setTimeout(resolve, 2000)), + ]); + + await view1.remove_update(token); }; const worker0 = await perspective.worker(); @@ -249,8 +267,10 @@ import perspective from "./perspective_client"; const view0 = await table0.view({ columns: ["y"] }); const table1 = await worker1.table(view0); const view1 = await table1.view(); - await table0.replace([{ x: 1, y: 20 }]); - await settle(view0, view1); + await sync(view0, view1, () => + table0.replace([{ x: 1, y: 20 }]), + ); + return [await view0.to_json(), await view1.to_json()]; }); @@ -273,20 +293,26 @@ import perspective from "./perspective_client"; ), ); - const settle = async (a, b) => { - for (let i = 0; i < 50; i++) { + const sync = async (view0, view1, action) => { + const { promise, resolve } = Promise.withResolvers(); + const token = await view1.on_update(async () => { const [x, y] = await Promise.all([ - a.to_json(), - b.to_json(), + view0.to_json(), + view1.to_json(), ]); + if (JSON.stringify(x) === JSON.stringify(y)) { - return; + resolve(); } + }); + + await action(); + await Promise.race([ + promise, + new Promise((resolve) => setTimeout(resolve, 2000)), + ]); - await new Promise((resolve) => - setTimeout(resolve, 100), - ); - } + await view1.remove_update(token); }; const worker0 = await perspective.worker(); @@ -298,15 +324,15 @@ import perspective from "./perspective_client"; const view0 = await table0.view(); const table1 = await worker1.table(view0); const view1 = await table1.view(); - await table0.replace([ - { x: 3, y: 40 }, - { x: 5, y: 6 }, - ]); + await sync(view0, view1, () => + table0.replace([ + { x: 3, y: 40 }, + { x: 5, y: 6 }, + ]), + ); - await settle(view0, view1); const replaced = [await view0.to_json(), await view1.to_json()]; - await table0.clear(); - await settle(view0, view1); + await sync(view0, view1, () => table0.clear()); return [ replaced, [await view0.to_json(), await view1.to_json()], diff --git a/rust/perspective-js/test/js/registry.spec.js b/rust/perspective-js/test/js/registry.spec.js index b938a1acb2..26abc2a560 100644 --- a/rust/perspective-js/test/js/registry.spec.js +++ b/rust/perspective-js/test/js/registry.spec.js @@ -22,7 +22,7 @@ const URLS = { cdn: `${CLIENT}/dist/cdn/perspective.js`, viewer_cdn: `${VIEWER}/dist/cdn/perspective-viewer.js`, client_wasm: `${CLIENT}/dist/wasm/perspective-js.wasm`, - migrate_wasm: `${CLIENT}/dist/wasm/perspective-migrate-js.wasm`, + migrate_wasm: `${CLIENT}/dist/wasm/perspective-migrate.wasm`, wasm32: `${SERVER}/dist/wasm/perspective-server.wasm`, wasm64: `${SERVER}/dist/wasm/perspective-server.memory64.wasm`, }; diff --git a/rust/perspective-migrate-js/Cargo.toml b/rust/perspective-migrate-js/Cargo.toml deleted file mode 100644 index 83bd0ca42d..0000000000 --- a/rust/perspective-migrate-js/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -# ┃ Copyright (c) 2017, the Perspective Authors. ┃ -# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -# ┃ This file is part of the Perspective library, distributed under the terms ┃ -# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -[package] -name = "perspective-migrate-js" -version = "5.6.1" -authors = ["Andrew Stein "] -edition = "2024" -description = "WebAssembly bindings for `perspective-migrate`, shipped with `@perspective-dev/client`." -repository = "https://github.com/perspective-dev/perspective" -license = "Apache-2.0" -homepage = "https://perspective-dev.github.io" -publish = false - -[lib] -crate-type = ["cdylib", "rlib"] -path = "src/lib.rs" - -[dependencies] -perspective-migrate = { version = "5.6.1" } -serde = { version = "1.0", features = ["derive"] } -serde_json = { version = "1.0.107", features = ["preserve_order"] } -serde-wasm-bindgen = "0.6.0" -wasm-bindgen = { version = "=0.2.108" } diff --git a/rust/perspective-migrate-js/src/lib.rs b/rust/perspective-migrate-js/src/lib.rs deleted file mode 100644 index 3e8b063640..0000000000 --- a/rust/perspective-migrate-js/src/lib.rs +++ /dev/null @@ -1,41 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -use perspective_migrate::{Kind, Migrator}; -use serde::Serialize; -use wasm_bindgen::prelude::*; - -/// Upgrade a persisted Perspective config to the current schema, returning -/// `{value, notices, steps}`. -#[wasm_bindgen] -pub fn migrate(kind: &str, from: Option, value: JsValue) -> Result { - let kind = match kind { - "view" => Kind::View, - "viewer" => Kind::Viewer, - "workspace" => Kind::Workspace, - _ => { - return Err(JsError::new(&format!( - "Unknown config kind `{kind}`; expected `view`, `viewer` or `workspace`" - ))); - }, - }; - - let value: serde_json::Value = serde_wasm_bindgen::from_value(value)?; - let migrated = Migrator::new().migrate(kind, from.as_deref(), value)?; - let result = serde_json::json!({ - "value": migrated.value, - "notices": migrated.notices, - "steps": migrated.steps, - }); - - Ok(result.serialize(&serde_wasm_bindgen::Serializer::json_compatible())?) -} diff --git a/rust/perspective-migrate/Cargo.toml b/rust/perspective-migrate/Cargo.toml index f49c44cdce..fa3d7df0f6 100644 --- a/rust/perspective-migrate/Cargo.toml +++ b/rust/perspective-migrate/Cargo.toml @@ -25,11 +25,14 @@ documentation = "https://docs.rs/perspective-migrate" include = ["src/**/*", "Cargo.toml"] [lib] +crate-type = ["cdylib", "rlib"] path = "src/lib.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0.107", features = ["preserve_order"] } +serde-wasm-bindgen = "0.6.0" +wasm-bindgen = { version = "=0.2.108" } [dev-dependencies] perspective-client = { path = "../perspective-client" } diff --git a/rust/perspective-migrate/src/lib.rs b/rust/perspective-migrate/src/lib.rs index b178f580b0..a3c2838f0d 100644 --- a/rust/perspective-migrate/src/lib.rs +++ b/rust/perspective-migrate/src/lib.rs @@ -268,3 +268,31 @@ fn stamp(kind: Kind, version: &str, value: &mut Value) { }, } } + +use wasm_bindgen::prelude::*; + +/// Upgrade a persisted Perspective config to the current schema, returning +/// `{value, notices, steps}`. +#[wasm_bindgen] +pub fn migrate(kind: &str, from: Option, value: JsValue) -> Result { + let kind = match kind { + "view" => Kind::View, + "viewer" => Kind::Viewer, + "workspace" => Kind::Workspace, + _ => { + return Err(JsError::new(&format!( + "Unknown config kind `{kind}`; expected `view`, `viewer` or `workspace`" + ))); + }, + }; + + let value: serde_json::Value = serde_wasm_bindgen::from_value(value)?; + let migrated = Migrator::new().migrate(kind, from.as_deref(), value)?; + let result = serde_json::json!({ + "value": migrated.value, + "notices": migrated.notices, + "steps": migrated.steps, + }); + + Ok(result.serialize(&serde_wasm_bindgen::Serializer::json_compatible())?) +} diff --git a/rust/perspective-viewer/src/ts/bootstrap.ts b/rust/perspective-viewer/src/ts/bootstrap.ts index 2037aad5c2..43aa9fc1e4 100644 --- a/rust/perspective-viewer/src/ts/bootstrap.ts +++ b/rust/perspective-viewer/src/ts/bootstrap.ts @@ -109,7 +109,7 @@ function is_registration( * ```javascript * await init_client({ * viewer: () => fetch("perspective-viewer.wasm"), - * migrate: () => fetch("perspective-migrate-js.wasm"), + * migrate: () => fetch("perspective-migrate.wasm"), * }); * ``` */ diff --git a/rust/perspective-viewer/src/ts/perspective-viewer.cdn.ts b/rust/perspective-viewer/src/ts/perspective-viewer.cdn.ts index 3c96e1e282..a98b7ecf03 100644 --- a/rust/perspective-viewer/src/ts/perspective-viewer.cdn.ts +++ b/rust/perspective-viewer/src/ts/perspective-viewer.cdn.ts @@ -27,7 +27,7 @@ register_default({ import.meta.url, "viewer", "client", - "perspective-migrate-js.wasm", + "perspective-migrate.wasm", ), ), }); diff --git a/rust/perspective-viewer/test/js/multi_panel/restore_workspace_layout.spec.ts b/rust/perspective-viewer/test/js/multi_panel/restore_workspace_layout.spec.ts index 6484ca91d7..f5970176a2 100644 --- a/rust/perspective-viewer/test/js/multi_panel/restore_workspace_layout.spec.ts +++ b/rust/perspective-viewer/test/js/multi_panel/restore_workspace_layout.spec.ts @@ -145,7 +145,11 @@ test.describe("restoreWorkspace layout validation", () => { }); test("drops an existing panel the layout omits", async ({ page }) => { - const layout = split("horizontal", [0.5, 0.5], [tab("one"), tab("two")]); + const layout = split( + "horizontal", + [0.5, 0.5], + [tab("one"), tab("two")], + ); const result = await try_restore(page, { layout }); expect(result.error).toBeNull(); expect(result.panels).toEqual(["one", "two"]);