@perspective-dev/viewer 4.5.1 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cdn/perspective-viewer.js +20 -1
- package/dist/cdn/perspective-viewer.js.map +4 -4
- package/dist/css/botanical.css +1 -1
- package/dist/css/dracula.css +1 -1
- package/dist/css/gruvbox-dark.css +1 -1
- package/dist/css/gruvbox.css +1 -1
- package/dist/css/icons.css +1 -1
- package/dist/css/monokai.css +1 -1
- package/dist/css/phosphor.css +1 -1
- package/dist/css/pro-dark.css +1 -1
- package/dist/css/pro.css +1 -1
- package/dist/css/solarized-dark.css +1 -1
- package/dist/css/solarized.css +1 -1
- package/dist/css/themes.css +1 -1
- package/dist/css/vaporwave.css +1 -1
- package/dist/esm/extensions.d.ts +22 -3
- package/dist/esm/perspective-viewer.cdn.d.ts +3 -0
- package/dist/esm/perspective-viewer.d.ts +9 -0
- package/dist/esm/perspective-viewer.inline.d.ts +3 -0
- package/dist/esm/perspective-viewer.inline.js +20 -1
- package/dist/esm/perspective-viewer.inline.js.map +4 -4
- package/dist/esm/perspective-viewer.js +20 -1
- package/dist/esm/perspective-viewer.js.map +4 -4
- package/dist/esm/plugin.d.ts +71 -5
- package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
- package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
- package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
- package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
- package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
- package/dist/esm/ts-rs/Layout.d.ts +11 -0
- package/dist/esm/ts-rs/OnUpdateData.d.ts +6 -0
- package/dist/esm/ts-rs/Orientation.d.ts +4 -0
- package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
- package/dist/esm/ts-rs/PanelViewerConfig.d.ts +39 -0
- package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
- package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
- package/dist/esm/ts-rs/ViewOnUpdateResp.d.ts +4 -0
- package/dist/esm/ts-rs/ViewerConfig.d.ts +6 -2
- package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
- package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
- package/dist/wasm/perspective-viewer.d.ts +213 -79
- package/dist/wasm/perspective-viewer.js +537 -189
- package/dist/wasm/perspective-viewer.wasm +0 -0
- package/dist/wasm/perspective-viewer.wasm.d.ts +36 -28
- package/dist/wasm/snippets/perspective-viewer-daeef4ceb066e6c7/inline0.js +6 -0
- package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline3.js → perspective-viewer-daeef4ceb066e6c7/inline4.js} +5 -0
- package/package.json +3 -2
- package/src/css/_column_dropdown.css +14 -0
- package/src/css/_context_menu.css +14 -0
- package/src/css/_dropdown_menu.css +14 -0
- package/src/css/_filter_dropdown.css +14 -0
- package/src/css/_function_dropdown.css +14 -0
- package/src/css/_viewer_bundle.css +41 -0
- package/src/css/column-settings-panel.css +1 -1
- package/src/css/containers/context-menu.css +101 -0
- package/src/css/panel-tab.css +193 -0
- package/src/css/plugin-selector.css +7 -10
- package/src/css/status-bar.css +75 -81
- package/src/css/viewer.css +181 -71
- package/src/rust/components/column_dropdown.rs +43 -33
- package/src/rust/components/column_selector/active_column.rs +7 -18
- package/src/rust/components/column_selector/aggregate_selector.rs +4 -10
- package/src/rust/components/column_selector/config_selector.rs +33 -84
- package/src/rust/components/column_selector/empty_column.rs +0 -3
- package/src/rust/components/column_selector/filter_column.rs +5 -18
- package/src/rust/components/column_selector/inactive_column.rs +3 -8
- package/src/rust/components/column_selector/invalid_column.rs +0 -4
- package/src/rust/components/column_selector/sort_column.rs +3 -6
- package/src/rust/components/column_selector.rs +5 -14
- package/src/rust/components/column_settings_sidebar/style_tab/symbol/symbol_pairs.rs +0 -3
- package/src/rust/components/column_settings_sidebar/style_tab/symbol.rs +0 -3
- package/src/rust/components/column_settings_sidebar.rs +0 -3
- package/src/rust/components/containers/dropdown_menu.rs +1 -3
- package/src/rust/components/containers/scroll_panel.rs +1 -3
- package/src/rust/components/containers/split_panel.rs +175 -66
- package/src/rust/components/containers/tab_list.rs +0 -3
- package/src/rust/components/context_menu.rs +130 -0
- package/src/rust/components/datetime_column_style.rs +0 -3
- package/src/rust/components/empty_row.rs +0 -3
- package/src/rust/components/expression_editor.rs +2 -3
- package/src/rust/components/filter_dropdown.rs +3 -3
- package/src/rust/components/font_loader.rs +2 -2
- package/src/rust/components/form/code_editor.rs +0 -3
- package/src/rust/components/form/debug.rs +3 -4
- package/src/rust/components/function_dropdown.rs +3 -3
- package/src/rust/components/global_filter_bar.rs +76 -0
- package/src/rust/components/main_panel/frame_theme.rs +186 -0
- package/src/rust/components/main_panel/msg.rs +62 -0
- package/src/rust/components/main_panel/presize.rs +220 -0
- package/src/rust/components/main_panel/reconcile.rs +224 -0
- package/src/rust/components/main_panel/render.rs +385 -0
- package/src/rust/components/main_panel/update.rs +184 -0
- package/src/rust/components/main_panel.rs +311 -71
- package/src/rust/components/mod.rs +4 -0
- package/src/rust/components/number_series_style.rs +0 -3
- package/src/rust/components/panel_menu.rs +486 -0
- package/src/rust/components/panel_tab.rs +505 -0
- package/src/rust/components/plugin_selector.rs +0 -3
- package/src/rust/components/plugin_tab.rs +1 -6
- package/src/rust/components/portal.rs +5 -2
- package/src/rust/components/render_warning.rs +0 -3
- package/src/rust/components/settings_panel.rs +13 -5
- package/src/rust/components/status_bar.rs +113 -180
- package/src/rust/components/status_indicator.rs +6 -9
- package/src/rust/components/string_column_style.rs +0 -3
- package/src/rust/components/style/mod.rs +65 -27
- package/src/rust/components/style/style_provider.rs +51 -16
- package/src/rust/components/style_controls/number_string_format.rs +1 -8
- package/src/rust/components/type_icon.rs +1 -4
- package/src/rust/components/viewer/filters.rs +133 -0
- package/src/rust/components/viewer/msg.rs +180 -0
- package/src/rust/components/viewer/panels.rs +326 -0
- package/src/rust/components/viewer/render.rs +243 -0
- package/src/rust/components/viewer/settings.rs +402 -0
- package/src/rust/components/viewer/snapshots.rs +134 -0
- package/src/rust/components/viewer/wiring.rs +505 -0
- package/src/rust/components/viewer.rs +243 -758
- package/src/rust/config/export_method.rs +1 -1
- package/src/rust/config/mod.rs +4 -0
- package/src/rust/config/options.rs +71 -0
- package/src/rust/config/viewer_config.rs +24 -2
- package/src/rust/config/workspace_config.rs +92 -0
- package/src/rust/custom_elements/copy_dropdown.rs +9 -3
- package/src/rust/custom_elements/debug_plugin.rs +20 -1
- package/src/rust/custom_elements/export_dropdown.rs +9 -3
- package/src/rust/custom_elements/viewer.rs +1015 -286
- package/src/rust/custom_events.rs +212 -61
- package/src/rust/js/mod.rs +2 -0
- package/src/rust/js/plugin.rs +297 -10
- package/src/rust/js/regular_layout.rs +258 -0
- package/src/rust/lib.rs +20 -2
- package/src/rust/presentation.rs +66 -33
- package/src/rust/queries/get_viewer_config.rs +29 -11
- package/src/rust/queries/plugin_column_styles.rs +2 -2
- package/src/rust/renderer/activate.rs +41 -14
- package/src/rust/renderer/dispatch.rs +436 -0
- package/src/rust/renderer/plugin_config.rs +495 -0
- package/src/rust/renderer/registry.rs +18 -6
- package/src/rust/renderer/render_timer.rs +29 -10
- package/src/rust/renderer.rs +350 -827
- package/src/rust/session/column_defaults_update.rs +26 -8
- package/src/rust/session/metadata.rs +11 -1
- package/src/rust/session/view_subscription.rs +30 -15
- package/src/rust/session.rs +753 -251
- package/src/rust/tasks/apply_global_filters.rs +89 -0
- package/src/rust/tasks/auto_pause.rs +171 -0
- package/src/rust/tasks/copy_export.rs +29 -19
- package/src/rust/tasks/create_panel.rs +243 -0
- package/src/rust/tasks/dismiss_render_warning.rs +3 -1
- package/src/rust/tasks/edit_expression.rs +4 -4
- package/src/rust/tasks/eject.rs +64 -23
- package/src/rust/tasks/mod.rs +28 -4
- package/src/rust/tasks/pipeline.rs +411 -0
- package/src/rust/tasks/presize_panels.rs +324 -0
- package/src/rust/tasks/reset_all.rs +22 -12
- package/src/rust/tasks/resize_observer.rs +124 -87
- package/src/rust/tasks/restore_and_render.rs +80 -86
- package/src/rust/tasks/restore_panel.rs +145 -0
- package/src/rust/tasks/send_column_config.rs +5 -2
- package/src/rust/tasks/send_plugin_config.rs +5 -2
- package/src/rust/tasks/set_edit_mode.rs +59 -0
- package/src/rust/tasks/sync_update_panels.rs +152 -0
- package/src/rust/tasks/table_lifecycle.rs +158 -0
- package/src/rust/tasks/update_theme.rs +56 -12
- package/src/rust/utils/completion.rs +56 -0
- package/src/rust/utils/custom_element.rs +5 -0
- package/src/rust/utils/debounce.rs +130 -7
- package/src/rust/utils/mod.rs +6 -0
- package/src/rust/utils/pending_effects.rs +62 -0
- package/src/rust/{components/style/local_style.rs → utils/spawn.rs} +17 -19
- package/src/rust/utils/weak_scope.rs +0 -10
- package/src/rust/workspace.rs +846 -0
- package/src/svg/active-indicator.svg +3 -0
- package/src/themes/botanical.css +7 -2
- package/src/themes/defaults.css +54 -2
- package/src/themes/dracula.css +8 -3
- package/src/themes/gruvbox-dark.css +7 -2
- package/src/themes/gruvbox.css +5 -2
- package/src/themes/icons.css +4 -2
- package/src/themes/monokai.css +7 -2
- package/src/themes/phosphor.css +7 -2
- package/src/themes/pro-dark.css +7 -2
- package/src/themes/pro.css +2 -1
- package/src/themes/solarized-dark.css +5 -2
- package/src/themes/solarized.css +5 -2
- package/src/themes/vaporwave.css +7 -2
- package/src/ts/bootstrap.ts +8 -7
- package/src/ts/extensions.ts +30 -2
- package/src/ts/perspective-viewer.ts +10 -0
- package/src/ts/plugin.ts +72 -5
- package/src/ts/ts-rs/ClientOptions.ts +7 -0
- package/src/ts/ts-rs/ExportMethod.ts +3 -0
- package/src/ts/ts-rs/ExportOptions.ts +8 -0
- package/src/ts/ts-rs/GetClientOptions.ts +7 -0
- package/src/ts/ts-rs/GetTableOptions.ts +7 -0
- package/src/ts/ts-rs/Layout.ts +9 -0
- package/src/ts/ts-rs/Orientation.ts +6 -0
- package/src/ts/ts-rs/PanelOptions.ts +7 -0
- package/src/ts/ts-rs/PanelViewerConfig.ts +15 -0
- package/src/ts/ts-rs/SplitLayout.ts +5 -0
- package/src/ts/ts-rs/TabLayout.ts +11 -0
- package/src/ts/ts-rs/ViewerConfig.ts +6 -2
- package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
- package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
- package/tsconfig.json +1 -1
- package/src/rust/components/style/style_cache.rs +0 -136
- package/src/rust/tasks/intersection_observer.rs +0 -98
- package/src/rust/tasks/update_and_render.rs +0 -110
- /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline0.js → perspective-viewer-daeef4ceb066e6c7/inline1.js} +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline1.js → perspective-viewer-daeef4ceb066e6c7/inline2.js} +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline2.js → perspective-viewer-daeef4ceb066e6c7/inline3.js} +0 -0
- /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline4.js → perspective-viewer-daeef4ceb066e6c7/inline5.js} +0 -0
package/src/rust/session.rs
CHANGED
|
@@ -17,7 +17,7 @@ mod props;
|
|
|
17
17
|
pub(crate) mod replace_expression_update;
|
|
18
18
|
mod view_subscription;
|
|
19
19
|
|
|
20
|
-
use std::cell::{Ref, RefCell};
|
|
20
|
+
use std::cell::{Cell, Ref, RefCell};
|
|
21
21
|
use std::collections::{HashMap, HashSet};
|
|
22
22
|
use std::future::Future;
|
|
23
23
|
use std::ops::Deref;
|
|
@@ -55,12 +55,44 @@ pub struct SessionHandle {
|
|
|
55
55
|
pub table_updated: PubSub<()>,
|
|
56
56
|
pub table_loaded: PubSub<()>,
|
|
57
57
|
pub table_unloaded: PubSub<bool>,
|
|
58
|
+
|
|
59
|
+
/// Fires when a `View` was CREATED — literally: `bind_view`'s REBUILD
|
|
60
|
+
/// path constructed and bound a new engine `View`. Nothing else may
|
|
61
|
+
/// emit this (a SKIP/REUSE/paused reconcile is [`Self::commit_reconciled`],
|
|
62
|
+
/// which REBUILD also emits) — overloading it broke consumers that
|
|
63
|
+
/// relied on the name's meaning (see `UPDATE_COUNT_REGRESSION_PLAN.md`).
|
|
58
64
|
pub view_created: PubSub<()>,
|
|
65
|
+
|
|
66
|
+
/// Fires exactly once per locked run that RECONCILED the committed
|
|
67
|
+
/// config against the bound render state — on every `bind_view` exit
|
|
68
|
+
/// (SKIP / REUSE / auto-PAUSED / REBUILD). This is the "a commit was
|
|
69
|
+
/// applied; a `perspective-config-update` may need dispatching" signal;
|
|
70
|
+
/// it does NOT imply a `View` was constructed (that is
|
|
71
|
+
/// [`Self::view_created`]).
|
|
72
|
+
pub commit_reconciled: PubSub<()>,
|
|
73
|
+
|
|
59
74
|
pub view_config_changed: PubSub<()>,
|
|
60
75
|
pub title_changed: PubSub<Option<String>>,
|
|
61
76
|
|
|
77
|
+
/// Count of in-flight CONFIG-DRIVEN pipeline runs (commit → run
|
|
78
|
+
/// settled), incremented by [`Session::begin_config_run`] and
|
|
79
|
+
/// decremented when the returned [`ConfigRunToken`] drops. Drives the
|
|
80
|
+
/// `StatusIndicator` "updating" spinner via [`Self::run_state_changed`].
|
|
81
|
+
/// Pure redraws (resize, activation nudges, `just_render`) do NOT
|
|
82
|
+
/// count — the spinner means "a config requery is in flight".
|
|
83
|
+
in_flight_config_runs: Cell<u32>,
|
|
84
|
+
|
|
85
|
+
/// Fires with the ABSOLUTE [`Self::in_flight_config_runs`] count after
|
|
86
|
+
/// every change. LEVEL-triggered by design: subscribers ASSIGN the
|
|
87
|
+
/// payload rather than accumulate deltas, so a dropped or reordered
|
|
88
|
+
/// notification is corrected by the next one — the delta-counting
|
|
89
|
+
/// (`view_config_changed`+1 / `view_created`−1) design this replaces
|
|
90
|
+
/// drifted whenever those streams weren't perfectly paired (see
|
|
91
|
+
/// `UPDATE_COUNT_REGRESSION_PLAN.md`).
|
|
92
|
+
pub run_state_changed: PubSub<u32>,
|
|
93
|
+
|
|
62
94
|
/// Fires when the user clicks the status indicator while in
|
|
63
|
-
/// [`StatusIconState::Normal`]. `
|
|
95
|
+
/// [`StatusIconState::Normal`]. `wire_panel_events` is the only
|
|
64
96
|
/// listener and fans this out as the `perspective-status-indicator-click`
|
|
65
97
|
/// `CustomEvent`.
|
|
66
98
|
pub status_indicator_clicked: PubSub<()>,
|
|
@@ -69,7 +101,7 @@ pub struct SessionHandle {
|
|
|
69
101
|
/// `fetch_column_abs_max` task and consumed by the schema-query
|
|
70
102
|
/// path so plugins emit gradient defaults without
|
|
71
103
|
/// per-render `View::get_min_max` round trips. Cleared
|
|
72
|
-
/// synchronously inside [`Session::
|
|
104
|
+
/// synchronously inside [`Session::commit_view_config`].
|
|
73
105
|
column_stats: RefCell<HashMap<String, ColumnStats>>,
|
|
74
106
|
|
|
75
107
|
/// Memoized snapshots used by [`Session::to_props`] to keep
|
|
@@ -82,6 +114,52 @@ pub struct SessionHandle {
|
|
|
82
114
|
cached_config: RefCell<Option<PtrEqRc<ViewConfig>>>,
|
|
83
115
|
cached_metadata: RefCell<Option<SessionMetadataRc>>,
|
|
84
116
|
|
|
117
|
+
/// Dedup cell for `perspective-config-update`: the last [`ViewerConfig`]
|
|
118
|
+
/// this session dispatched, so an unchanged re-fire is suppressed. Held
|
|
119
|
+
/// per-`Session` (i.e. per panel) — was element-level on `Presentation`,
|
|
120
|
+
/// which cross-suppressed when N panels shared one cell — so each panel
|
|
121
|
+
/// dedups only against its own last dispatch. `Rc`-wrapped so the same
|
|
122
|
+
/// allocation is shared with the event's lazy `getConfig()` closure without
|
|
123
|
+
/// copying the config.
|
|
124
|
+
pub last_dispatched_config: RefCell<Option<std::rc::Rc<crate::config::ViewerConfig>>>,
|
|
125
|
+
|
|
126
|
+
/// Monotone load counter, bumped once per `load()` call
|
|
127
|
+
/// ([`Session::begin_pending_load`]). Identifies the LATEST load so a
|
|
128
|
+
/// stale async classification — React binds `load` to a prop and fires it
|
|
129
|
+
/// repeatedly, unawaited and out of order — no-ops instead of clobbering a
|
|
130
|
+
/// newer load ([`Session::is_current_load`]).
|
|
131
|
+
load_generation: Cell<u32>,
|
|
132
|
+
|
|
133
|
+
/// Open between a `load()` call and its payload's classification as
|
|
134
|
+
/// `Table`/`Client` ([`PendingLoad`]). While `Some` the payload type is
|
|
135
|
+
/// unknown, so its RESET/no-RESET disposition cannot be decided yet — but
|
|
136
|
+
/// the window's POSITION on the config-commit stream is fixed at the
|
|
137
|
+
/// `load()` call site. During the window, config commits still apply live
|
|
138
|
+
/// (so `save()` stays coherent — I1) but ALSO append their raw delta to
|
|
139
|
+
/// the journal, and config-driven binds DEFER ([`Session::snapshot`]'s
|
|
140
|
+
/// caller [`crate::tasks::bind_snapshot`]) — the incoming config must
|
|
141
|
+
/// never draw against the still-bound outgoing table (hold the last
|
|
142
|
+
/// frame). Classified as `Table` the journal replays over a reset base
|
|
143
|
+
/// (`reset ∘ Δ₁ ∘ Δ₂ …`, the program-order result the old synchronous
|
|
144
|
+
/// reset guaranteed); as `Client` it is dropped (no reset — live already
|
|
145
|
+
/// carries every Δ). See `SESSION_CONFIG_COHERENCE_PLAN.md`.
|
|
146
|
+
pending_load: RefCell<Option<PendingLoad>>,
|
|
147
|
+
|
|
148
|
+
/// Coalesces `view_config_changed`: multiple synchronous commits in one
|
|
149
|
+
/// task emit ONE event on the next microtask — the cadence the deleted
|
|
150
|
+
/// `is_clean` flag provided by accident, now deliberate.
|
|
151
|
+
config_event_scheduled: Cell<bool>,
|
|
152
|
+
|
|
153
|
+
/// Count of in-flight `perspective-config-update` dispatch tasks for this
|
|
154
|
+
/// panel (see [`Session::track_dispatch`]). `flush()` joins these via
|
|
155
|
+
/// [`Session::settle_dispatches`], so the "config-update fires before
|
|
156
|
+
/// `flush()` resolves" contract holds by construction instead of by
|
|
157
|
+
/// microtask luck.
|
|
158
|
+
pending_dispatches: Cell<u32>,
|
|
159
|
+
|
|
160
|
+
/// Fires when [`SessionHandle::pending_dispatches`] returns to zero.
|
|
161
|
+
dispatches_settled: PubSub<()>,
|
|
162
|
+
|
|
85
163
|
/// Fires when [`SessionHandle::column_stats`] is updated (insert or
|
|
86
164
|
/// clear). Subscribers re-render and re-query the schema with the
|
|
87
165
|
/// new value.
|
|
@@ -110,14 +188,20 @@ impl Deref for SessionHandle {
|
|
|
110
188
|
pub struct SessionData {
|
|
111
189
|
client: Option<perspective_client::Client>,
|
|
112
190
|
table: Option<perspective_client::Table>,
|
|
191
|
+
pending_table: Option<String>,
|
|
113
192
|
metadata: SessionMetadata,
|
|
114
|
-
old_config: Option<ViewConfig>,
|
|
115
193
|
config: ViewConfig,
|
|
194
|
+
global_filter: Vec<Filter>,
|
|
116
195
|
view_sub: Option<ViewSubscription>,
|
|
117
196
|
stats: Option<ViewStats>,
|
|
118
197
|
is_loading: bool,
|
|
119
|
-
is_clean: bool,
|
|
120
198
|
is_paused: bool,
|
|
199
|
+
|
|
200
|
+
/// Memo for [`Session::validate_snapshot`]: the expression set validated
|
|
201
|
+
/// by the last successful server round trip; an equal snapshot skips the
|
|
202
|
+
/// round trip. Written only under the draw lock; cleared on table
|
|
203
|
+
/// (re)bind and expression reset.
|
|
204
|
+
last_validated_expressions: Option<Expressions>,
|
|
121
205
|
error: Option<TableErrorState>,
|
|
122
206
|
title: Option<String>,
|
|
123
207
|
}
|
|
@@ -164,6 +248,15 @@ pub enum TableIntermediateState {
|
|
|
164
248
|
Reloaded,
|
|
165
249
|
}
|
|
166
250
|
|
|
251
|
+
/// The open-load window state (see [`SessionHandle::pending_load`]): the
|
|
252
|
+
/// generation that opened it (for supersession detection) and the raw config
|
|
253
|
+
/// deltas committed while it was open, in commit order, awaiting replay over a
|
|
254
|
+
/// reset base if the payload classifies as a `Table`.
|
|
255
|
+
pub struct PendingLoad {
|
|
256
|
+
generation: u32,
|
|
257
|
+
journal: Vec<ViewConfigUpdate>,
|
|
258
|
+
}
|
|
259
|
+
|
|
167
260
|
/// Options for [`Session::reset`]
|
|
168
261
|
#[derive(Default)]
|
|
169
262
|
pub struct ResetOptions {
|
|
@@ -186,6 +279,25 @@ pub struct ResetOptions {
|
|
|
186
279
|
#[derive(Clone)]
|
|
187
280
|
pub struct Session(Rc<SessionHandle>);
|
|
188
281
|
|
|
282
|
+
/// RAII spinner accounting for one config-driven pipeline run (see
|
|
283
|
+
/// [`Session::begin_config_run`] and `UPDATE_COUNT_REGRESSION_PLAN.md`):
|
|
284
|
+
/// created when the run is scheduled — immediately after its commit — and
|
|
285
|
+
/// moved INTO the run future, so `Drop` settles the count on every exit
|
|
286
|
+
/// path: completion, error, cancellation, and runs that never reach
|
|
287
|
+
/// `bind_view` (the deferred-draw restore). A stranded count is
|
|
288
|
+
/// unrepresentable; there is no other writer.
|
|
289
|
+
pub struct ConfigRunToken(Session);
|
|
290
|
+
|
|
291
|
+
impl Drop for ConfigRunToken {
|
|
292
|
+
fn drop(&mut self) {
|
|
293
|
+
let count = self.0.0.in_flight_config_runs.get();
|
|
294
|
+
debug_assert!(count > 0, "ConfigRunToken underflow");
|
|
295
|
+
let count = count.saturating_sub(1);
|
|
296
|
+
self.0.0.in_flight_config_runs.set(count);
|
|
297
|
+
self.0.run_state_changed.emit(count);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
189
301
|
impl ImplicitClone for Session {}
|
|
190
302
|
|
|
191
303
|
impl Deref for Session {
|
|
@@ -233,7 +345,6 @@ impl Session {
|
|
|
233
345
|
/// deleted. TODO Table should be an immutable constructor parameter to
|
|
234
346
|
/// `Session`.
|
|
235
347
|
pub fn reset(&self, options: ResetOptions) -> impl Future<Output = ApiResult<()>> + use<> {
|
|
236
|
-
self.borrow_mut().is_clean = false;
|
|
237
348
|
let view = self.0.borrow_mut().view_sub.take();
|
|
238
349
|
let err = self.get_error();
|
|
239
350
|
self.borrow_mut().error = None;
|
|
@@ -245,34 +356,91 @@ impl Session {
|
|
|
245
356
|
self.borrow_mut().config.reset(options.expressions);
|
|
246
357
|
}
|
|
247
358
|
|
|
359
|
+
if options.expressions {
|
|
360
|
+
self.borrow_mut().last_validated_expressions = None;
|
|
361
|
+
}
|
|
362
|
+
|
|
248
363
|
match options.table {
|
|
249
364
|
Some(TableIntermediateState::Ejected) => {
|
|
250
365
|
self.borrow_mut().is_loading = false;
|
|
251
366
|
self.borrow_mut().table = None;
|
|
252
|
-
self.borrow_mut().
|
|
367
|
+
self.borrow_mut().pending_table = None;
|
|
253
368
|
},
|
|
254
369
|
Some(TableIntermediateState::Reloaded) => {
|
|
255
370
|
self.borrow_mut().is_loading = true;
|
|
256
371
|
self.borrow_mut().table = None;
|
|
257
|
-
self.borrow_mut().
|
|
372
|
+
self.borrow_mut().pending_table = None;
|
|
258
373
|
},
|
|
259
374
|
_ => {
|
|
260
375
|
self.borrow_mut().is_loading = false;
|
|
261
376
|
},
|
|
262
377
|
};
|
|
263
378
|
|
|
264
|
-
|
|
265
|
-
|
|
379
|
+
// A config reset that KEEPS its `Table` is itself a commit, and every
|
|
380
|
+
// commit normalizes (I1/I4): re-fill the emptied `columns` from the
|
|
381
|
+
// table's defaults NOW, synchronously — previously this fill lived in
|
|
382
|
+
// the async validate write-back and ran on the next draw, so skipping
|
|
383
|
+
// it here left `save()` reporting `columns: []` after `reset()`. An
|
|
384
|
+
// ejecting/reloading reset skips it (no table to fill from — `load()`
|
|
385
|
+
// runs its own table-bind commit).
|
|
386
|
+
if options.config && options.table.is_none() {
|
|
387
|
+
self.commit_table_defaults();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let session = self.clone();
|
|
266
391
|
async move {
|
|
267
392
|
let res = view.delete().await;
|
|
268
393
|
if options.table.is_some() {
|
|
269
|
-
table_unloaded.emit(true)
|
|
394
|
+
session.table_unloaded.emit(true)
|
|
270
395
|
}
|
|
271
396
|
|
|
272
397
|
if let Some(err) = err { Err(err) } else { res }
|
|
273
398
|
}
|
|
274
399
|
}
|
|
275
400
|
|
|
401
|
+
/// Open a pending-load window (see [`SessionHandle::pending_load`]) at the
|
|
402
|
+
/// `load()` call site. Bumps the load generation and returns it — the
|
|
403
|
+
/// caller carries it into the async classification to detect supersession
|
|
404
|
+
/// by a later `load()`. Sets `is_loading` so a FRESH panel shows the
|
|
405
|
+
/// spinner while a slow payload resolves (a RELOAD keeps its bound table,
|
|
406
|
+
/// hence [`TableLoadState::Loaded`], throughout — no spinner flicker).
|
|
407
|
+
pub fn begin_pending_load(&self) -> u32 {
|
|
408
|
+
let generation = self.0.load_generation.get() + 1;
|
|
409
|
+
self.0.load_generation.set(generation);
|
|
410
|
+
*self.0.pending_load.borrow_mut() = Some(PendingLoad {
|
|
411
|
+
generation,
|
|
412
|
+
journal: Vec::new(),
|
|
413
|
+
});
|
|
414
|
+
self.borrow_mut().is_loading = true;
|
|
415
|
+
generation
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/// Whether a `load()` payload is still awaiting classification. A `true`
|
|
419
|
+
/// value makes every config-driven bind DEFER
|
|
420
|
+
/// ([`crate::tasks::bind_snapshot`]) — the incoming config must never draw
|
|
421
|
+
/// against the outgoing table.
|
|
422
|
+
pub fn has_pending_load(&self) -> bool {
|
|
423
|
+
self.0.pending_load.borrow().is_some()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/// Close the pending-load window for `generation`, returning its journal
|
|
427
|
+
/// (the raw deltas committed while it was open, in order) for replay.
|
|
428
|
+
/// Returns `None` when a later `load()` already superseded this one (or it
|
|
429
|
+
/// was already closed), which the caller treats as "abandon this
|
|
430
|
+
/// classification, hold the frame". Clears `is_loading` — the
|
|
431
|
+
/// classification that follows sets the real table state.
|
|
432
|
+
pub fn take_pending_load(&self, generation: u32) -> Option<Vec<ViewConfigUpdate>> {
|
|
433
|
+
let mut slot = self.0.pending_load.borrow_mut();
|
|
434
|
+
if slot.as_ref().map(|p| p.generation) != Some(generation) {
|
|
435
|
+
return None;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let journal = slot.take().map(|p| p.journal);
|
|
439
|
+
drop(slot);
|
|
440
|
+
self.borrow_mut().is_loading = false;
|
|
441
|
+
journal
|
|
442
|
+
}
|
|
443
|
+
|
|
276
444
|
pub(crate) fn has_table(&self) -> Option<TableLoadState> {
|
|
277
445
|
let data = self.borrow();
|
|
278
446
|
if data.table.is_some() {
|
|
@@ -302,26 +470,69 @@ impl Session {
|
|
|
302
470
|
self.borrow().client.clone()
|
|
303
471
|
}
|
|
304
472
|
|
|
473
|
+
/// The configured table name awaiting a host, if any (see
|
|
474
|
+
/// [`SessionData::pending_table`]).
|
|
475
|
+
pub fn pending_table(&self) -> Option<String> {
|
|
476
|
+
self.borrow().pending_table.clone()
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/// Suspend a BOUND table to PENDING: delete the `View`, drop the `Table`
|
|
480
|
+
/// handle (freeing the engine name for a lazy `Table::delete` to
|
|
481
|
+
/// complete), and record the name as pending — the view config is KEPT,
|
|
482
|
+
/// so the reactive rebind (`tasks::table_lifecycle`) restores this panel
|
|
483
|
+
/// as it was when the name is hosted again. `None` when no table is
|
|
484
|
+
/// bound.
|
|
485
|
+
pub fn suspend_table(&self) -> Option<impl Future<Output = ApiResult<()>> + use<>> {
|
|
486
|
+
let name = self.borrow().table.as_ref()?.get_name().to_owned();
|
|
487
|
+
let fut = self.reset(ResetOptions {
|
|
488
|
+
table: Some(TableIntermediateState::Ejected),
|
|
489
|
+
stats: true,
|
|
490
|
+
..ResetOptions::default()
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
self.borrow_mut().pending_table = Some(name);
|
|
494
|
+
Some(fut)
|
|
495
|
+
}
|
|
496
|
+
|
|
305
497
|
/// Reset this `Session`'s state with a new `Table`. Implicitly clears the
|
|
306
498
|
/// `ViewSubscription`, which will need to be re-initialized later via
|
|
307
499
|
/// `create_view()`.
|
|
308
500
|
///
|
|
309
501
|
/// # Arguments
|
|
310
502
|
///
|
|
311
|
-
/// - `table_name` The name of the `Table` to load
|
|
312
|
-
/// loaded `Client`.
|
|
503
|
+
/// - `table_name` The name of the `Table` to load.
|
|
313
504
|
///
|
|
314
505
|
/// # Returns
|
|
315
506
|
///
|
|
316
507
|
/// `table_name` is unique per `Client`, so if this value has not changed,
|
|
317
508
|
/// `Session::set_table` does nothing and returns `Ok(false)`.
|
|
509
|
+
///
|
|
510
|
+
/// A name NO loaded client hosts (yet) is not an error: the session
|
|
511
|
+
/// records it PENDING ([`SessionData::pending_table`]) and returns
|
|
512
|
+
/// `Ok(false)`; the element's hosted-tables subscription
|
|
513
|
+
/// (`tasks::table_lifecycle`) re-runs the bind when the table is created.
|
|
318
514
|
pub async fn set_table(&self, table_name: String) -> ApiResult<bool> {
|
|
319
515
|
if Some(table_name.as_str()) == self.0.borrow().table.as_ref().map(|x| x.get_name()) {
|
|
516
|
+
self.0.borrow_mut().pending_table = None;
|
|
320
517
|
return Ok(false);
|
|
321
518
|
}
|
|
322
519
|
|
|
323
520
|
let client = self.0.borrow().client.clone().into_apierror()?;
|
|
324
|
-
let table = client.open_table(table_name.clone()).await
|
|
521
|
+
let table = match client.open_table(table_name.clone()).await {
|
|
522
|
+
Ok(table) => table,
|
|
523
|
+
Err(e) => {
|
|
524
|
+
// Distinguish "not hosted (yet)" — which PENDS — from a real
|
|
525
|
+
// open failure by the hosted set, not the error's text.
|
|
526
|
+
let hosted = client.get_hosted_table_names().await.unwrap_or_default();
|
|
527
|
+
if hosted.iter().any(|n| n == &table_name) {
|
|
528
|
+
return Err(e.into());
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
self.0.borrow_mut().pending_table = Some(table_name);
|
|
532
|
+
return Ok(false);
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
|
|
325
536
|
match SessionMetadata::from_table(&table).await {
|
|
326
537
|
Ok(metadata) => {
|
|
327
538
|
let client = table.get_client();
|
|
@@ -352,8 +563,9 @@ impl Session {
|
|
|
352
563
|
let sub = self.borrow_mut().view_sub.take();
|
|
353
564
|
self.borrow_mut().metadata = metadata;
|
|
354
565
|
self.borrow_mut().table = Some(table);
|
|
566
|
+
self.borrow_mut().pending_table = None;
|
|
355
567
|
self.borrow_mut().is_loading = false;
|
|
356
|
-
self.borrow_mut().
|
|
568
|
+
self.borrow_mut().last_validated_expressions = None;
|
|
357
569
|
sub.delete().await?;
|
|
358
570
|
self.table_loaded.emit(());
|
|
359
571
|
Ok(true)
|
|
@@ -362,14 +574,6 @@ impl Session {
|
|
|
362
574
|
}
|
|
363
575
|
}
|
|
364
576
|
|
|
365
|
-
pub fn update_column_defaults(&self, config_static: &PluginStaticConfig) {
|
|
366
|
-
if self.borrow().config.columns.is_empty() {
|
|
367
|
-
let mut update = ViewConfigUpdate::default();
|
|
368
|
-
self.set_update_column_defaults(&mut update, config_static);
|
|
369
|
-
self.borrow_mut().config.apply_update(update);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
577
|
pub async fn set_error(&self, reset_table: bool, err: ApiError) -> ApiResult<()> {
|
|
374
578
|
let session = self.clone();
|
|
375
579
|
let poll_loop = LocalPollLoop::new(move |()| {
|
|
@@ -398,7 +602,6 @@ impl Session {
|
|
|
398
602
|
|
|
399
603
|
let sub = self.borrow_mut().view_sub.take();
|
|
400
604
|
if reset_table {
|
|
401
|
-
self.borrow_mut().metadata = SessionMetadata::default();
|
|
402
605
|
self.borrow_mut().table = None;
|
|
403
606
|
}
|
|
404
607
|
|
|
@@ -407,7 +610,6 @@ impl Session {
|
|
|
407
610
|
}
|
|
408
611
|
|
|
409
612
|
pub fn set_pause(&self, pause: bool) -> bool {
|
|
410
|
-
self.borrow_mut().is_clean = false;
|
|
411
613
|
if pause == self.borrow().is_paused {
|
|
412
614
|
false
|
|
413
615
|
} else if pause {
|
|
@@ -447,7 +649,7 @@ impl Session {
|
|
|
447
649
|
reconnect().await?;
|
|
448
650
|
self.borrow_mut().is_loading = false;
|
|
449
651
|
self.borrow_mut().error = None;
|
|
450
|
-
self.borrow_mut().
|
|
652
|
+
self.borrow_mut().last_validated_expressions = None;
|
|
451
653
|
self.borrow_mut().view_sub = None;
|
|
452
654
|
self.table_loaded.emit(());
|
|
453
655
|
}
|
|
@@ -470,6 +672,34 @@ impl Session {
|
|
|
470
672
|
Ref::map(self.borrow(), |x| &x.config)
|
|
471
673
|
}
|
|
472
674
|
|
|
675
|
+
/// The effective [`ViewConfig`] the `View` is built from: the stored config
|
|
676
|
+
/// with the transient element-level [`SessionData::global_filter`] clauses
|
|
677
|
+
/// appended. Used at view-creation only; the stored `config` (hence
|
|
678
|
+
/// `savePanel`/the settings UI) is unaffected.
|
|
679
|
+
fn effective_view_config(&self) -> ViewConfig {
|
|
680
|
+
let data = self.borrow();
|
|
681
|
+
if data.global_filter.is_empty() {
|
|
682
|
+
return data.config.clone();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
let mut config = data.config.clone();
|
|
686
|
+
config.filter.extend(data.global_filter.iter().cloned());
|
|
687
|
+
config
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/// Replace the transient element-level global filters and re-render the
|
|
691
|
+
/// view (no-op if unchanged). These are applied on top of the panel's
|
|
692
|
+
/// own config at view-creation but never persisted into it.
|
|
693
|
+
pub fn set_global_filter(&self, filter: Vec<Filter>) -> bool {
|
|
694
|
+
if self.borrow().global_filter != filter {
|
|
695
|
+
self.borrow_mut().global_filter = filter;
|
|
696
|
+
self.notify_view_config_changed();
|
|
697
|
+
true
|
|
698
|
+
} else {
|
|
699
|
+
false
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
473
703
|
/// Snapshot of the [`ViewConfig`] the currently-bound `View` was
|
|
474
704
|
/// constructed from. Returns `None` if no `View` has been created
|
|
475
705
|
/// yet (e.g., the post-`load`/pre-render window, or after a reset).
|
|
@@ -477,8 +707,8 @@ impl Session {
|
|
|
477
707
|
/// Prefer this over [`Self::get_view_config`] when you need a
|
|
478
708
|
/// value consistent with what the active plugin is rendering.
|
|
479
709
|
/// `get_view_config` returns the live config, which is mutated
|
|
480
|
-
/// synchronously by [`Self::
|
|
481
|
-
///
|
|
710
|
+
/// synchronously by [`Self::commit_view_config`] ahead of the next
|
|
711
|
+
/// queued run and so may temporarily disagree with the bound `View`.
|
|
482
712
|
pub fn get_rendered_view_config(&self) -> Option<Rc<ViewConfig>> {
|
|
483
713
|
self.borrow().view_sub.as_ref().map(|s| s.get_view_config())
|
|
484
714
|
}
|
|
@@ -496,9 +726,32 @@ impl Session {
|
|
|
496
726
|
)
|
|
497
727
|
}
|
|
498
728
|
|
|
499
|
-
///
|
|
500
|
-
///
|
|
501
|
-
|
|
729
|
+
/// Rollup-mode-only subset of [`Self::set_update_column_defaults`], for
|
|
730
|
+
/// restores that do NOT swap plugins: the plugin-advised
|
|
731
|
+
/// `group_rollup_mode` must be (re-)enforced on every restore commit —
|
|
732
|
+
/// a preceding `reset` may have wiped it — but the column-defaulting
|
|
733
|
+
/// half must not run (it would rewrite a partial update's `columns`).
|
|
734
|
+
pub fn set_update_rollup_defaults(
|
|
735
|
+
&self,
|
|
736
|
+
config_update: &mut ViewConfigUpdate,
|
|
737
|
+
config_static: &PluginStaticConfig,
|
|
738
|
+
) {
|
|
739
|
+
use self::column_defaults_update::*;
|
|
740
|
+
config_update.set_update_rollup_defaults(&self.metadata(), config_static)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/// Apply a `ViewConfigUpdate` to the live config — the ONLY view-config
|
|
744
|
+
/// mutator (invariant I1: synchronous and total; no `await` separates any
|
|
745
|
+
/// read of the config from this write, so a lost update is
|
|
746
|
+
/// unrepresentable).
|
|
747
|
+
///
|
|
748
|
+
/// Validation is SYNCHRONOUS and happens before anything is applied: an
|
|
749
|
+
/// update naming an unknown column is rejected with `Err` and the config
|
|
750
|
+
/// is untouched (I4 — invalid state is never entered, so no rollback
|
|
751
|
+
/// path exists). Server-side expression compilation is deliberately NOT
|
|
752
|
+
/// checked here; it is a property of a pipeline run
|
|
753
|
+
/// ([`Self::validate_snapshot`]) and fails that run, never the commit.
|
|
754
|
+
pub fn commit_view_config(&self, config_update: ViewConfigUpdate) -> ApiResult<()> {
|
|
502
755
|
if let Some(x) = self.borrow().error.as_ref() {
|
|
503
756
|
tracing::warn!("Errored state");
|
|
504
757
|
|
|
@@ -506,16 +759,208 @@ impl Session {
|
|
|
506
759
|
return Err(ApiError::new(x.0.clone()));
|
|
507
760
|
}
|
|
508
761
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
762
|
+
// A `load()` is classifying its payload (see
|
|
763
|
+
// [`SessionHandle::pending_load`]): record the RAW delta so a `Table`
|
|
764
|
+
// classification can replay it over the reset base, validated against
|
|
765
|
+
// the INCOMING table's schema. The live apply below is a best-effort
|
|
766
|
+
// preview (kept for `save()` coherence, I1) that SKIPS name validation
|
|
767
|
+
// — the delta may legitimately name the incoming table's columns,
|
|
768
|
+
// absent from the outgoing one — mirroring the "no table bound yet"
|
|
769
|
+
// leniency already in `validate_names`. Authoritative validation is
|
|
770
|
+
// deferred to the replay.
|
|
771
|
+
let pending = self.has_pending_load();
|
|
772
|
+
let mut candidate = self.borrow().config.clone();
|
|
773
|
+
let journal_entry = pending.then(|| config_update.clone());
|
|
774
|
+
if !candidate.apply_update(config_update) {
|
|
775
|
+
return Ok(());
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if let Some(entry) = journal_entry
|
|
779
|
+
&& let Some(p) = self.0.pending_load.borrow_mut().as_mut()
|
|
780
|
+
{
|
|
781
|
+
p.journal.push(entry);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if !pending {
|
|
785
|
+
self.validate_names(&candidate)?;
|
|
514
786
|
}
|
|
515
787
|
|
|
788
|
+
self.normalize_view_config(&mut candidate);
|
|
789
|
+
self.borrow_mut().config = candidate;
|
|
790
|
+
self.notify_view_config_changed();
|
|
516
791
|
Ok(())
|
|
517
792
|
}
|
|
518
793
|
|
|
794
|
+
/// Table-bind commit: normalize the (possibly empty) config against the
|
|
795
|
+
/// newly-bound table's metadata — the default-view materialization that
|
|
796
|
+
/// previously happened inside the async validate write-back. SYNC;
|
|
797
|
+
/// called immediately after `set_table().await` inside the binding run,
|
|
798
|
+
/// so it is ordered like any other commit.
|
|
799
|
+
pub fn commit_table_defaults(&self) {
|
|
800
|
+
let mut candidate = self.borrow().config.clone();
|
|
801
|
+
self.normalize_view_config(&mut candidate);
|
|
802
|
+
if candidate != self.borrow().config {
|
|
803
|
+
self.borrow_mut().config = candidate;
|
|
804
|
+
self.notify_view_config_changed();
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/// SYNC name validation for a candidate config (I4): every referenced
|
|
809
|
+
/// column must be a table column or an expression present in the
|
|
810
|
+
/// candidate itself (syntactic presence — server-side compilability is a
|
|
811
|
+
/// run property, not a commit property). Skipped when no table is bound
|
|
812
|
+
/// yet: the config rides along until `load()` binds one, and the engine
|
|
813
|
+
/// surfaces any residual error on that run.
|
|
814
|
+
fn validate_names(&self, config: &ViewConfig) -> ApiResult<()> {
|
|
815
|
+
let table_columns = self.all_columns();
|
|
816
|
+
if table_columns.is_empty() {
|
|
817
|
+
return Ok(());
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
let mut allowed: HashSet<&str> = table_columns.iter().map(|x| x.as_str()).collect();
|
|
821
|
+
allowed.extend(config.expressions.0.keys().map(|x| x.as_str()));
|
|
822
|
+
let named = config
|
|
823
|
+
.columns
|
|
824
|
+
.iter()
|
|
825
|
+
.flatten()
|
|
826
|
+
.map(|x| ("columns", x))
|
|
827
|
+
.chain(config.group_by.iter().map(|x| ("group_by", x)))
|
|
828
|
+
.chain(config.split_by.iter().map(|x| ("split_by", x)))
|
|
829
|
+
.chain(config.sort.iter().map(|x| ("sort", &x.0)));
|
|
830
|
+
|
|
831
|
+
for (field, column) in named {
|
|
832
|
+
if !allowed.contains(column.as_str()) {
|
|
833
|
+
return Err(apierror!(InvalidViewerConfigError(
|
|
834
|
+
field,
|
|
835
|
+
column.to_owned()
|
|
836
|
+
)));
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
for filter in config.filter.iter() {
|
|
841
|
+
// TODO check filter op
|
|
842
|
+
if !allowed.contains(filter.column()) {
|
|
843
|
+
return Err(apierror!(InvalidViewerConfigError(
|
|
844
|
+
"filter",
|
|
845
|
+
filter.column().to_owned()
|
|
846
|
+
)));
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
Ok(())
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/// SYNC normalization at commit time (previously the async validate's
|
|
854
|
+
/// write-back): fill empty `columns` from the table, prune `aggregates`
|
|
855
|
+
/// to referenced columns.
|
|
856
|
+
fn normalize_view_config(&self, config: &mut ViewConfig) {
|
|
857
|
+
let table_columns = self.all_columns();
|
|
858
|
+
if table_columns.is_empty() {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if config.columns.is_empty() {
|
|
863
|
+
config.columns = table_columns.iter().cloned().map(Some).collect();
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
let view_columns: HashSet<String> = config
|
|
867
|
+
.columns
|
|
868
|
+
.iter()
|
|
869
|
+
.flatten()
|
|
870
|
+
.cloned()
|
|
871
|
+
.chain(config.group_by.iter().cloned())
|
|
872
|
+
.chain(config.split_by.iter().cloned())
|
|
873
|
+
.chain(config.sort.iter().map(|x| x.0.clone()))
|
|
874
|
+
.chain(config.filter.iter().map(|x| x.column().to_owned()))
|
|
875
|
+
.collect();
|
|
876
|
+
|
|
877
|
+
config
|
|
878
|
+
.aggregates
|
|
879
|
+
.retain(|column, _| view_columns.contains(column.as_str()));
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/// Run `fut` as a TRACKED dispatch task: its errors are owned (logged,
|
|
883
|
+
/// tagged) and its completion is joinable via
|
|
884
|
+
/// [`Self::settle_dispatches`]. Used by the `perspective-config-update`
|
|
885
|
+
/// dispatcher, whose landing `flush()` must be able to await.
|
|
886
|
+
pub fn track_dispatch(&self, fut: impl std::future::Future<Output = ApiResult<()>> + 'static) {
|
|
887
|
+
self.0
|
|
888
|
+
.pending_dispatches
|
|
889
|
+
.set(self.0.pending_dispatches.get() + 1);
|
|
890
|
+
let session = self.clone();
|
|
891
|
+
ApiFuture::spawn(async move {
|
|
892
|
+
let result = fut.await;
|
|
893
|
+
let remaining = session.0.pending_dispatches.get() - 1;
|
|
894
|
+
session.0.pending_dispatches.set(remaining);
|
|
895
|
+
if remaining == 0 {
|
|
896
|
+
session.0.dispatches_settled.emit(());
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if let Err(e) = result {
|
|
900
|
+
tracing::error!("[config-update dispatch] {}", e);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
Ok(())
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/// Resolve once every in-flight tracked dispatch for this panel has
|
|
908
|
+
/// landed. Immediate when none are pending.
|
|
909
|
+
pub async fn settle_dispatches(&self) -> ApiResult<()> {
|
|
910
|
+
while self.0.pending_dispatches.get() > 0 {
|
|
911
|
+
self.0.dispatches_settled.read_next().await?;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
Ok(())
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/// Emit `view_config_changed` coalesced to one event per microtask batch.
|
|
918
|
+
/// Stats are cleared synchronously per commit (idempotent — an
|
|
919
|
+
/// already-empty cache no-ops, so rapid commit sequences fetch once).
|
|
920
|
+
///
|
|
921
|
+
/// CONTRACT: this channel is COALESCED and DEFERRED — N same-batch
|
|
922
|
+
/// commits produce ONE emission, on a microtask AFTER the mutation.
|
|
923
|
+
/// Subscribers must be batch-tolerant snapshot refreshers (they re-read
|
|
924
|
+
/// live state; today: the root `SessionProps` snapshot and the debug
|
|
925
|
+
/// panel). It is NOT suitable for per-commit accounting — pairing its
|
|
926
|
+
/// edges against any other event stream WILL drift (the stuck-spinner
|
|
927
|
+
/// regression, `UPDATE_COUNT_REGRESSION_PLAN.md`); run accounting is
|
|
928
|
+
/// [`Self::begin_config_run`]'s RAII token instead.
|
|
929
|
+
fn notify_view_config_changed(&self) {
|
|
930
|
+
self.clear_column_stats();
|
|
931
|
+
if !self.0.config_event_scheduled.replace(true) {
|
|
932
|
+
let session = self.clone();
|
|
933
|
+
ApiFuture::spawn(async move {
|
|
934
|
+
session.0.config_event_scheduled.set(false);
|
|
935
|
+
session.view_config_changed.emit(());
|
|
936
|
+
Ok(())
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/// Begin spinner accounting for ONE config-driven pipeline run: call
|
|
942
|
+
/// immediately after the run's commit, and move the returned token INTO
|
|
943
|
+
/// the run future. `Drop` settles it on every exit — completion, error,
|
|
944
|
+
/// cancellation, and runs that never reach `bind_view` (the
|
|
945
|
+
/// deferred-draw restore) — so, unlike edge-counting PubSub pairs, the
|
|
946
|
+
/// count cannot strand by construction.
|
|
947
|
+
/// [`SessionHandle::run_state_changed`] broadcasts the ABSOLUTE count
|
|
948
|
+
/// on both edges.
|
|
949
|
+
pub fn begin_config_run(&self) -> ConfigRunToken {
|
|
950
|
+
let count = self.0.in_flight_config_runs.get() + 1;
|
|
951
|
+
self.0.in_flight_config_runs.set(count);
|
|
952
|
+
self.run_state_changed.emit(count);
|
|
953
|
+
ConfigRunToken(self.clone())
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/// The number of config-driven pipeline runs currently in flight (see
|
|
957
|
+
/// [`Self::begin_config_run`]). Read directly when (re)targeting a
|
|
958
|
+
/// panel — level-triggered consumers initialize from this and then
|
|
959
|
+
/// track [`SessionHandle::run_state_changed`].
|
|
960
|
+
pub fn in_flight_config_runs(&self) -> u32 {
|
|
961
|
+
self.0.in_flight_config_runs.get()
|
|
962
|
+
}
|
|
963
|
+
|
|
519
964
|
/// Read the cached `ColumnStats` for a column. Returns `None` if no
|
|
520
965
|
/// fetch has populated this column yet (or the cache was just
|
|
521
966
|
/// cleared by a view-config change).
|
|
@@ -544,167 +989,239 @@ impl Session {
|
|
|
544
989
|
}
|
|
545
990
|
}
|
|
546
991
|
|
|
547
|
-
///
|
|
548
|
-
///
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
992
|
+
/// Immutable input for one pipeline run (invariant I2): the persisted
|
|
993
|
+
/// config and its EFFECTIVE companion (global-filter overlay appended),
|
|
994
|
+
/// captured at the same synchronous instant. Requires the
|
|
995
|
+
/// [`RenderGuard`] witness — snapshots exist only inside a locked run,
|
|
996
|
+
/// so run *N+1*'s snapshot is provably at least as fresh as every commit
|
|
997
|
+
/// that preceded run *N*'s completion (invariant I3).
|
|
998
|
+
pub fn snapshot(&self, _guard: &RenderGuard) -> ConfigSnapshot {
|
|
999
|
+
ConfigSnapshot {
|
|
1000
|
+
config: Rc::new(self.borrow().config.clone()),
|
|
1001
|
+
effective: Rc::new(self.effective_view_config()),
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
555
1004
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1005
|
+
/// Validate a snapshot's expressions against the server (the only
|
|
1006
|
+
/// inherently-async validation), updating the metadata expression
|
|
1007
|
+
/// schema. Reads nothing from — and writes nothing to — the live
|
|
1008
|
+
/// config; a failure fails this RUN, never the committed config (I4).
|
|
1009
|
+
///
|
|
1010
|
+
/// Memoized: when the snapshot's expressions equal the last successfully
|
|
1011
|
+
/// validated set, the round trip is skipped entirely — safe because the
|
|
1012
|
+
/// memo key is an immutable snapshot field and the recorded set is
|
|
1013
|
+
/// written only under the draw lock.
|
|
1014
|
+
pub async fn validate_snapshot(
|
|
1015
|
+
&self,
|
|
1016
|
+
_guard: &RenderGuard,
|
|
1017
|
+
snap: ConfigSnapshot,
|
|
1018
|
+
) -> ApiResult<ValidatedSnapshot> {
|
|
1019
|
+
let memo_hit =
|
|
1020
|
+
self.borrow().last_validated_expressions.as_ref() == Some(&snap.effective.expressions);
|
|
1021
|
+
if !memo_hit {
|
|
1022
|
+
let supports_expressions = self
|
|
1023
|
+
.metadata()
|
|
1024
|
+
.get_features()
|
|
1025
|
+
.map(|x| x.expressions)
|
|
1026
|
+
.unwrap_or_default();
|
|
1027
|
+
|
|
1028
|
+
if supports_expressions {
|
|
1029
|
+
let table = self
|
|
1030
|
+
.borrow()
|
|
1031
|
+
.table
|
|
1032
|
+
.as_ref()
|
|
1033
|
+
.ok_or_else(|| apierror!(NoTableError))?
|
|
1034
|
+
.clone();
|
|
1035
|
+
|
|
1036
|
+
let valid_recs = table
|
|
1037
|
+
.validate_expressions(snap.effective.expressions.clone())
|
|
1038
|
+
.await?;
|
|
566
1039
|
|
|
567
|
-
|
|
568
|
-
err.clone(),
|
|
569
|
-
Some(ReconnectCallback::new(move || {
|
|
570
|
-
clone!(poll_loop);
|
|
571
|
-
Box::pin(async move {
|
|
572
|
-
poll_loop.poll(()).await;
|
|
573
|
-
Ok(())
|
|
574
|
-
})
|
|
575
|
-
})),
|
|
576
|
-
));
|
|
577
|
-
|
|
578
|
-
if let Some(config) = old {
|
|
579
|
-
self.borrow_mut().config = config;
|
|
580
|
-
} else {
|
|
581
|
-
self.reset(ResetOptions {
|
|
582
|
-
config: true,
|
|
583
|
-
expressions: true,
|
|
584
|
-
..ResetOptions::default()
|
|
585
|
-
})
|
|
586
|
-
.await?;
|
|
1040
|
+
self.metadata_mut().update_expressions(&valid_recs)?;
|
|
587
1041
|
}
|
|
588
1042
|
|
|
589
|
-
|
|
590
|
-
} else {
|
|
591
|
-
let old_config = Some(self.borrow().config.clone());
|
|
592
|
-
self.borrow_mut().old_config = old_config;
|
|
1043
|
+
self.borrow_mut().last_validated_expressions = Some(snap.effective.expressions.clone());
|
|
593
1044
|
}
|
|
594
1045
|
|
|
595
|
-
Ok(
|
|
1046
|
+
Ok(ValidatedSnapshot(snap))
|
|
596
1047
|
}
|
|
597
1048
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
1049
|
+
/// Bind the engine `View` for a validated snapshot: SKIP
|
|
1050
|
+
/// ([`BindDisposition::Unchanged`]) when the bound view was built from
|
|
1051
|
+
/// an equal effective config, REUSE ([`BindDisposition::Adopted`],
|
|
1052
|
+
/// in-place snapshot adoption) when engine-equivalent modulo
|
|
1053
|
+
/// `None`-column placeholders, else REBUILD
|
|
1054
|
+
/// ([`BindDisposition::Rebuilt`]) from the snapshot. The decision
|
|
1055
|
+
/// compares two immutable values owned by this run — there are no
|
|
1056
|
+
/// consumable flags for a concurrent run to steal — and the returned
|
|
1057
|
+
/// disposition is the ONLY source of `plugin.draw` eligibility (its
|
|
1058
|
+
/// `Rebuilt` arm mints the [`FreshView`] witness).
|
|
1059
|
+
pub async fn bind_view(
|
|
1060
|
+
&self,
|
|
1061
|
+
_guard: &RenderGuard,
|
|
1062
|
+
validated: ValidatedSnapshot,
|
|
1063
|
+
) -> ApiResult<BindDisposition> {
|
|
1064
|
+
// Whichever way a bound `View` was reconciled without construction,
|
|
1065
|
+
// classify it `Unchanged` (repaint-eligible, never full-draw); a
|
|
1066
|
+
// session with nothing bound is `Deferred`.
|
|
1067
|
+
fn unchanged_or_deferred(session: &Session) -> BindDisposition {
|
|
1068
|
+
match session.get_view() {
|
|
1069
|
+
Some(view) => BindDisposition::Unchanged(view),
|
|
1070
|
+
None => BindDisposition::Deferred,
|
|
1071
|
+
}
|
|
602
1072
|
}
|
|
603
|
-
}
|
|
604
1073
|
|
|
605
|
-
|
|
606
|
-
self.
|
|
607
|
-
|
|
608
|
-
.
|
|
609
|
-
.
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1074
|
+
let ConfigSnapshot { config, effective } = validated.0;
|
|
1075
|
+
if self.borrow().is_paused {
|
|
1076
|
+
// A paused bind still RECONCILES the committed config (no `View`
|
|
1077
|
+
// is constructed — `view_created` stays silent). Without this
|
|
1078
|
+
// emit, a commit landing while auto-paused (e.g. `load()` on a
|
|
1079
|
+
// disconnected element, once the IntersectionObserver's initial
|
|
1080
|
+
// not-intersecting entry wins the race to this check) never
|
|
1081
|
+
// announces its `config-update`: the dispatcher only ever runs
|
|
1082
|
+
// from `commit_reconciled`, so the event — which `flush()` joins
|
|
1083
|
+
// via the tracked-dispatch counter — would be deferred to the
|
|
1084
|
+
// unpause render. The unpause render's own dispatch is deduped
|
|
1085
|
+
// (`last_dispatched_config`), so this emit cannot double-fire.
|
|
1086
|
+
self.commit_reconciled.emit(());
|
|
1087
|
+
return Ok(unchanged_or_deferred(self));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
let needs_schema = !self.metadata().has_view_schema();
|
|
1091
|
+
if !needs_schema {
|
|
1092
|
+
let bound = self.borrow().view_sub.as_ref().map(|x| x.build_config());
|
|
1093
|
+
if let Some(bound) = bound {
|
|
1094
|
+
if *bound == *effective {
|
|
1095
|
+
// SKIP: the bound `View` already satisfies the commit.
|
|
1096
|
+
self.commit_reconciled.emit(());
|
|
1097
|
+
return Ok(unchanged_or_deferred(self));
|
|
1098
|
+
} else if bound.is_equivalent(&effective) {
|
|
1099
|
+
if let Some(sub) = self.borrow_mut().view_sub.as_mut() {
|
|
1100
|
+
sub.set_configs(config.clone(), effective.clone());
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// REUSE: snapshot adoption, no `View` constructed.
|
|
1104
|
+
self.commit_reconciled.emit(());
|
|
1105
|
+
return match self.get_view() {
|
|
1106
|
+
Some(view) => Ok(BindDisposition::Adopted(view)),
|
|
1107
|
+
None => Ok(BindDisposition::Deferred),
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
613
1112
|
|
|
614
|
-
async fn validate_view_config(&self) -> ApiResult<()> {
|
|
615
|
-
let mut config = self.borrow().config.clone();
|
|
616
|
-
let table_columns = self.all_columns();
|
|
617
|
-
let all_columns: HashSet<String> = table_columns.iter().cloned().collect();
|
|
618
|
-
let mut view_columns: HashSet<&str> = HashSet::new();
|
|
619
1113
|
let table = self
|
|
620
1114
|
.borrow()
|
|
621
1115
|
.table
|
|
622
|
-
.
|
|
623
|
-
.
|
|
624
|
-
|
|
1116
|
+
.clone()
|
|
1117
|
+
.ok_or("`restore()` called before `load()`")?;
|
|
1118
|
+
|
|
1119
|
+
// Populate the aggregates with defaults as a courtesy to the
|
|
1120
|
+
// virtual server api. Snapshot-local — never written back.
|
|
1121
|
+
let mut view_config = (*effective).clone();
|
|
1122
|
+
for col in view_config
|
|
1123
|
+
.columns
|
|
1124
|
+
.iter()
|
|
1125
|
+
.flatten()
|
|
1126
|
+
.chain(view_config.sort.iter().map(|x| &x.0))
|
|
1127
|
+
{
|
|
1128
|
+
if !view_config.aggregates.contains_key(col.as_str()) {
|
|
1129
|
+
let agg = self
|
|
1130
|
+
.metadata()
|
|
1131
|
+
.get_column_aggregates(col.as_str())
|
|
1132
|
+
.and_then(|mut aggs| aggs.next())
|
|
1133
|
+
.into_apierror();
|
|
1134
|
+
|
|
1135
|
+
match agg {
|
|
1136
|
+
Err(_) => {
|
|
1137
|
+
tracing::warn!("No default aggregate for column '{}' found, skipping", col)
|
|
1138
|
+
},
|
|
1139
|
+
Ok(agg) => _ = view_config.aggregates.insert(col.to_string(), agg),
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
let view = table.view(Some(view_config.into())).await?;
|
|
1145
|
+
let view_schema = view.schema().await?;
|
|
1146
|
+
self.metadata_mut().update_view_schema(&view_schema)?;
|
|
1147
|
+
let on_stats = Callback::from({
|
|
1148
|
+
let this = self.clone();
|
|
1149
|
+
move |stats| this.update_stats(stats)
|
|
1150
|
+
});
|
|
625
1151
|
|
|
626
|
-
let
|
|
627
|
-
let
|
|
628
|
-
.
|
|
629
|
-
.
|
|
1152
|
+
let sub = {
|
|
1153
|
+
let on_update = self
|
|
1154
|
+
.metadata()
|
|
1155
|
+
.get_features()
|
|
1156
|
+
.unwrap()
|
|
1157
|
+
.on_update
|
|
1158
|
+
.then(|| self.table_updated.callback());
|
|
630
1159
|
|
|
631
|
-
|
|
632
|
-
} else {
|
|
633
|
-
HashSet::default()
|
|
1160
|
+
ViewSubscription::new(view, config, effective, on_stats, on_update).await?
|
|
634
1161
|
};
|
|
635
1162
|
|
|
636
|
-
|
|
637
|
-
|
|
1163
|
+
let old = self.borrow_mut().view_sub.take();
|
|
1164
|
+
ApiFuture::spawn(old.delete());
|
|
1165
|
+
self.borrow_mut().view_sub = Some(sub);
|
|
1166
|
+
// REBUILD: a `View` was genuinely constructed and bound — the one
|
|
1167
|
+
// place `view_created` may fire, and the one place the `FreshView`
|
|
1168
|
+
// full-draw witness is minted from a rebuild.
|
|
1169
|
+
self.view_created.emit(());
|
|
1170
|
+
self.commit_reconciled.emit(());
|
|
1171
|
+
match self.get_view() {
|
|
1172
|
+
Some(view) => Ok(BindDisposition::Rebuilt(FreshView::assert_fresh(view))),
|
|
1173
|
+
None => Ok(BindDisposition::Deferred),
|
|
638
1174
|
}
|
|
1175
|
+
}
|
|
639
1176
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1177
|
+
/// Record a failed pipeline run: error state plus a reconnect affordance
|
|
1178
|
+
/// that resets the config (the error screen's reset button). Replaces
|
|
1179
|
+
/// the old `validate()` error path — the committed config is NOT rolled
|
|
1180
|
+
/// back (I4: it holds exactly what the caller committed; the failure
|
|
1181
|
+
/// belongs to the run).
|
|
1182
|
+
pub async fn set_run_error(&self, err: ApiError) -> ApiResult<()> {
|
|
1183
|
+
let session = self.clone();
|
|
1184
|
+
let poll_loop = LocalPollLoop::new(move |()| {
|
|
1185
|
+
ApiFuture::spawn(session.reset(ResetOptions {
|
|
1186
|
+
config: true,
|
|
1187
|
+
expressions: true,
|
|
1188
|
+
..ResetOptions::default()
|
|
1189
|
+
}));
|
|
1190
|
+
Ok(JsValue::UNDEFINED)
|
|
1191
|
+
});
|
|
650
1192
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
)
|
|
659
|
-
}
|
|
660
|
-
|
|
1193
|
+
self.borrow_mut().error = Some(TableErrorState(
|
|
1194
|
+
err.clone(),
|
|
1195
|
+
Some(ReconnectCallback::new(move || {
|
|
1196
|
+
clone!(poll_loop);
|
|
1197
|
+
Box::pin(async move {
|
|
1198
|
+
poll_loop.poll(()).await;
|
|
1199
|
+
Ok(())
|
|
1200
|
+
})
|
|
1201
|
+
})),
|
|
1202
|
+
));
|
|
661
1203
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
let _existed = view_columns.insert(column);
|
|
665
|
-
} else {
|
|
666
|
-
return Err(apierror!(InvalidViewerConfigError(
|
|
667
|
-
"split_by",
|
|
668
|
-
column.to_owned(),
|
|
669
|
-
)));
|
|
670
|
-
}
|
|
1204
|
+
if let Some(cb) = self.on_table_errored.borrow().as_ref() {
|
|
1205
|
+
cb.emit(());
|
|
671
1206
|
}
|
|
672
1207
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
let _existed = view_columns.insert(&sort.0);
|
|
676
|
-
} else {
|
|
677
|
-
return Err(apierror!(InvalidViewerConfigError(
|
|
678
|
-
"sort",
|
|
679
|
-
sort.0.to_owned(),
|
|
680
|
-
)));
|
|
681
|
-
}
|
|
682
|
-
}
|
|
1208
|
+
Err(err)
|
|
1209
|
+
}
|
|
683
1210
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
} else {
|
|
689
|
-
return Err(apierror!(InvalidViewerConfigError(
|
|
690
|
-
"filter",
|
|
691
|
-
filter.column().to_owned(),
|
|
692
|
-
)));
|
|
693
|
-
}
|
|
1211
|
+
fn update_stats(&self, stats: ViewStats) {
|
|
1212
|
+
self.borrow_mut().stats = Some(stats);
|
|
1213
|
+
if let Some(cb) = self.on_stats_changed.borrow().as_ref() {
|
|
1214
|
+
cb.emit(());
|
|
694
1215
|
}
|
|
695
|
-
|
|
696
|
-
config
|
|
697
|
-
.aggregates
|
|
698
|
-
.retain(|column, _| view_columns.contains(column.as_str()));
|
|
699
|
-
|
|
700
|
-
self.borrow_mut().config = config;
|
|
701
|
-
Ok(())
|
|
702
1216
|
}
|
|
703
1217
|
|
|
704
|
-
fn
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1218
|
+
fn all_columns(&self) -> Vec<String> {
|
|
1219
|
+
self.metadata()
|
|
1220
|
+
.get_table_columns()
|
|
1221
|
+
.into_iter()
|
|
1222
|
+
.flatten()
|
|
1223
|
+
.cloned()
|
|
1224
|
+
.collect()
|
|
708
1225
|
}
|
|
709
1226
|
|
|
710
1227
|
/// Snapshot the current session state as a [`SessionProps`] value suitable
|
|
@@ -752,92 +1269,77 @@ impl Session {
|
|
|
752
1269
|
}
|
|
753
1270
|
}
|
|
754
1271
|
|
|
755
|
-
///
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
pub
|
|
763
|
-
|
|
764
|
-
if !self.1 {
|
|
765
|
-
let config = self.0.borrow().config.clone();
|
|
766
|
-
if let Some(sub) = &mut self.0.borrow_mut().view_sub.as_mut() {
|
|
767
|
-
sub.update_view_config(Rc::new(config));
|
|
768
|
-
return Ok(Some(sub.get_view().clone()));
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
let table = self
|
|
773
|
-
.0
|
|
774
|
-
.borrow()
|
|
775
|
-
.table
|
|
776
|
-
.clone()
|
|
777
|
-
.ok_or("`restore()` called before `load()`")?;
|
|
778
|
-
|
|
779
|
-
let mut view_config = self.0.borrow().config.clone();
|
|
780
|
-
|
|
781
|
-
// Populate the aggreagtes with defaults as a courtesy to the
|
|
782
|
-
// virtual server api.
|
|
783
|
-
for col in view_config
|
|
784
|
-
.columns
|
|
785
|
-
.iter()
|
|
786
|
-
.flatten()
|
|
787
|
-
.chain(view_config.sort.iter().map(|x| &x.0))
|
|
788
|
-
{
|
|
789
|
-
if !view_config.aggregates.contains_key(col.as_str()) {
|
|
790
|
-
let agg = self
|
|
791
|
-
.0
|
|
792
|
-
.metadata()
|
|
793
|
-
.get_column_aggregates(col.as_str())
|
|
794
|
-
.and_then(|mut aggs| aggs.next())
|
|
795
|
-
.into_apierror();
|
|
796
|
-
|
|
797
|
-
match agg {
|
|
798
|
-
Err(_) => tracing::warn!(
|
|
799
|
-
"No default aggregate for column '{}' found, skipping",
|
|
800
|
-
col
|
|
801
|
-
),
|
|
802
|
-
Ok(agg) => _ = view_config.aggregates.insert(col.to_string(), agg),
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
let view = table.view(Some(view_config.into())).await?;
|
|
808
|
-
let view_schema = view.schema().await?;
|
|
809
|
-
self.0.metadata_mut().update_view_schema(&view_schema)?;
|
|
810
|
-
let on_stats = Callback::from({
|
|
811
|
-
let this = self.0.clone();
|
|
812
|
-
move |stats| this.update_stats(stats)
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
let sub = {
|
|
816
|
-
let config = self.0.borrow().config.clone();
|
|
817
|
-
let on_update = self
|
|
818
|
-
.0
|
|
819
|
-
.metadata()
|
|
820
|
-
.get_features()
|
|
821
|
-
.unwrap()
|
|
822
|
-
.on_update
|
|
823
|
-
.then(|| self.0.table_updated.callback());
|
|
824
|
-
|
|
825
|
-
ViewSubscription::new(view, config, on_stats, on_update).await?
|
|
826
|
-
};
|
|
1272
|
+
/// One pipeline run's frozen input (invariant I2): the persisted
|
|
1273
|
+
/// [`ViewConfig`] and its EFFECTIVE companion (global-filter overlay
|
|
1274
|
+
/// appended), captured at the same synchronous instant inside the draw
|
|
1275
|
+
/// lock by [`Session::snapshot`].
|
|
1276
|
+
#[derive(Clone)]
|
|
1277
|
+
pub struct ConfigSnapshot {
|
|
1278
|
+
pub config: Rc<ViewConfig>,
|
|
1279
|
+
pub effective: Rc<ViewConfig>,
|
|
1280
|
+
}
|
|
827
1281
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1282
|
+
/// Type-state token: proof this snapshot's expressions were validated by
|
|
1283
|
+
/// [`Session::validate_snapshot`]. [`Session::bind_view`] accepts only this
|
|
1284
|
+
/// token, so a `View` can never be built from an unvalidated snapshot.
|
|
1285
|
+
pub struct ValidatedSnapshot(ConfigSnapshot);
|
|
1286
|
+
|
|
1287
|
+
/// Type-state witness that a `View` is NEW for the plugin about to render
|
|
1288
|
+
/// it — `plugin.draw()`'s contract (see `PLUGIN_DRAW_INVARIANT_PLAN.md`:
|
|
1289
|
+
/// `draw` fires iff there is a new `View`, never otherwise). Minted in
|
|
1290
|
+
/// exactly two places: [`Session::bind_view`]'s REBUILD arm (a new engine
|
|
1291
|
+
/// `View` was constructed) and
|
|
1292
|
+
/// [`crate::renderer::Renderer::promote_first_paint`] (a freshly-selected
|
|
1293
|
+
/// plugin owes its first paint of the bound `View`). Do NOT construct it
|
|
1294
|
+
/// anywhere else — `Renderer::draw_fresh` is the only consumer, and this
|
|
1295
|
+
/// witness is what makes "full draw without a new `View`" (the stacked-chart
|
|
1296
|
+
/// activation regression) uncompilable.
|
|
1297
|
+
pub struct FreshView(View);
|
|
1298
|
+
|
|
1299
|
+
impl FreshView {
|
|
1300
|
+
/// See the type docs — two minting sites only.
|
|
1301
|
+
pub(crate) fn assert_fresh(view: View) -> Self {
|
|
1302
|
+
Self(view)
|
|
1303
|
+
}
|
|
832
1304
|
|
|
833
|
-
|
|
1305
|
+
pub fn view(&self) -> &View {
|
|
1306
|
+
&self.0
|
|
834
1307
|
}
|
|
835
1308
|
}
|
|
836
1309
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1310
|
+
/// How [`Session::bind_view`] reconciled a validated snapshot against the
|
|
1311
|
+
/// bound render state. Only the `Rebuilt` arm carries a [`FreshView`] — the
|
|
1312
|
+
/// witness `Renderer::draw_fresh` (the sole `plugin.draw` dispatch)
|
|
1313
|
+
/// requires — so which runs may FULL-draw is decided here, by type, not by
|
|
1314
|
+
/// call-site convention.
|
|
1315
|
+
pub enum BindDisposition {
|
|
1316
|
+
/// A new engine `View` was constructed and bound (`view_created`).
|
|
1317
|
+
Rebuilt(FreshView),
|
|
1318
|
+
|
|
1319
|
+
/// REUSE: an engine-equivalent snapshot (placeholder-only diff) was
|
|
1320
|
+
/// adopted in place — the plugin-visible config changed, the `View`
|
|
1321
|
+
/// did not. Repaint via `plugin.update`.
|
|
1322
|
+
Adopted(View),
|
|
1323
|
+
|
|
1324
|
+
/// SKIP (or paused-with-a-bound-`View`): the bound `View` already
|
|
1325
|
+
/// satisfies the commit. Repaint via `plugin.update` if the caller has
|
|
1326
|
+
/// a reason to repaint (today: always, preserving the no-op-commit
|
|
1327
|
+
/// repaint idioms — indicator click, toggle-debug, warning-dismiss).
|
|
1328
|
+
Unchanged(View),
|
|
1329
|
+
|
|
1330
|
+
/// Nothing to render: paused or deferred-draw (no table yet) with no
|
|
1331
|
+
/// bound `View`.
|
|
1332
|
+
Deferred,
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
impl BindDisposition {
|
|
1336
|
+
/// The bound `View`, whichever way it was reconciled (`None` for
|
|
1337
|
+
/// [`Self::Deferred`]) — for building the run's `RenderContext`.
|
|
1338
|
+
pub fn view(&self) -> Option<&View> {
|
|
1339
|
+
match self {
|
|
1340
|
+
Self::Rebuilt(fresh) => Some(fresh.view()),
|
|
1341
|
+
Self::Adopted(view) | Self::Unchanged(view) => Some(view),
|
|
1342
|
+
Self::Deferred => None,
|
|
1343
|
+
}
|
|
842
1344
|
}
|
|
843
1345
|
}
|