@perspective-dev/viewer 4.5.2 → 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.
Files changed (178) hide show
  1. package/dist/cdn/perspective-viewer.js +20 -1
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/monokai.css +1 -1
  9. package/dist/css/phosphor.css +1 -1
  10. package/dist/css/pro-dark.css +1 -1
  11. package/dist/css/pro.css +1 -1
  12. package/dist/css/solarized-dark.css +1 -1
  13. package/dist/css/solarized.css +1 -1
  14. package/dist/css/themes.css +1 -1
  15. package/dist/css/vaporwave.css +1 -1
  16. package/dist/esm/extensions.d.ts +22 -3
  17. package/dist/esm/perspective-viewer.d.ts +9 -0
  18. package/dist/esm/perspective-viewer.inline.js +20 -1
  19. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  20. package/dist/esm/perspective-viewer.js +20 -1
  21. package/dist/esm/perspective-viewer.js.map +4 -4
  22. package/dist/esm/plugin.d.ts +71 -5
  23. package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
  24. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
  25. package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
  26. package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
  27. package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
  28. package/dist/esm/ts-rs/Layout.d.ts +11 -0
  29. package/dist/esm/ts-rs/Orientation.d.ts +4 -0
  30. package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
  31. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +39 -0
  32. package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
  33. package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
  34. package/dist/esm/ts-rs/ViewerConfig.d.ts +6 -2
  35. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
  36. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
  37. package/dist/wasm/perspective-viewer.d.ts +211 -77
  38. package/dist/wasm/perspective-viewer.js +528 -180
  39. package/dist/wasm/perspective-viewer.wasm +0 -0
  40. package/dist/wasm/perspective-viewer.wasm.d.ts +34 -26
  41. package/dist/wasm/snippets/perspective-viewer-daeef4ceb066e6c7/inline0.js +6 -0
  42. package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline3.js → perspective-viewer-daeef4ceb066e6c7/inline4.js} +5 -0
  43. package/package.json +3 -2
  44. package/src/css/_context_menu.css +14 -0
  45. package/src/css/column-settings-panel.css +1 -1
  46. package/src/css/containers/context-menu.css +101 -0
  47. package/src/css/panel-tab.css +193 -0
  48. package/src/css/plugin-selector.css +7 -10
  49. package/src/css/status-bar.css +75 -81
  50. package/src/css/viewer.css +180 -70
  51. package/src/rust/components/column_dropdown.rs +40 -30
  52. package/src/rust/components/column_selector/active_column.rs +7 -18
  53. package/src/rust/components/column_selector/aggregate_selector.rs +4 -7
  54. package/src/rust/components/column_selector/config_selector.rs +33 -81
  55. package/src/rust/components/column_selector/filter_column.rs +5 -15
  56. package/src/rust/components/column_selector/inactive_column.rs +3 -8
  57. package/src/rust/components/column_selector/sort_column.rs +3 -6
  58. package/src/rust/components/column_selector.rs +5 -11
  59. package/src/rust/components/containers/split_panel.rs +175 -62
  60. package/src/rust/components/context_menu.rs +130 -0
  61. package/src/rust/components/form/debug.rs +3 -0
  62. package/src/rust/components/global_filter_bar.rs +76 -0
  63. package/src/rust/components/main_panel/frame_theme.rs +186 -0
  64. package/src/rust/components/main_panel/msg.rs +62 -0
  65. package/src/rust/components/main_panel/presize.rs +220 -0
  66. package/src/rust/components/main_panel/reconcile.rs +224 -0
  67. package/src/rust/components/main_panel/render.rs +385 -0
  68. package/src/rust/components/main_panel/update.rs +184 -0
  69. package/src/rust/components/main_panel.rs +311 -71
  70. package/src/rust/components/mod.rs +4 -0
  71. package/src/rust/components/panel_menu.rs +486 -0
  72. package/src/rust/components/panel_tab.rs +505 -0
  73. package/src/rust/components/plugin_tab.rs +1 -1
  74. package/src/rust/components/settings_panel.rs +13 -5
  75. package/src/rust/components/status_bar.rs +110 -178
  76. package/src/rust/components/status_indicator.rs +6 -9
  77. package/src/rust/components/style/mod.rs +5 -0
  78. package/src/rust/components/style/style_provider.rs +2 -0
  79. package/src/rust/components/viewer/filters.rs +133 -0
  80. package/src/rust/components/viewer/msg.rs +180 -0
  81. package/src/rust/components/viewer/panels.rs +326 -0
  82. package/src/rust/components/viewer/render.rs +243 -0
  83. package/src/rust/components/viewer/settings.rs +402 -0
  84. package/src/rust/components/viewer/snapshots.rs +134 -0
  85. package/src/rust/components/viewer/wiring.rs +505 -0
  86. package/src/rust/components/viewer.rs +243 -756
  87. package/src/rust/config/export_method.rs +1 -1
  88. package/src/rust/config/mod.rs +4 -0
  89. package/src/rust/config/options.rs +71 -0
  90. package/src/rust/config/viewer_config.rs +24 -2
  91. package/src/rust/config/workspace_config.rs +92 -0
  92. package/src/rust/custom_elements/copy_dropdown.rs +3 -1
  93. package/src/rust/custom_elements/debug_plugin.rs +20 -1
  94. package/src/rust/custom_elements/export_dropdown.rs +3 -1
  95. package/src/rust/custom_elements/viewer.rs +1015 -286
  96. package/src/rust/custom_events.rs +212 -61
  97. package/src/rust/js/mod.rs +2 -0
  98. package/src/rust/js/plugin.rs +297 -10
  99. package/src/rust/js/regular_layout.rs +258 -0
  100. package/src/rust/lib.rs +20 -1
  101. package/src/rust/presentation.rs +66 -33
  102. package/src/rust/queries/get_viewer_config.rs +29 -11
  103. package/src/rust/queries/plugin_column_styles.rs +2 -2
  104. package/src/rust/renderer/activate.rs +41 -14
  105. package/src/rust/renderer/dispatch.rs +436 -0
  106. package/src/rust/renderer/plugin_config.rs +495 -0
  107. package/src/rust/renderer/registry.rs +18 -6
  108. package/src/rust/renderer/render_timer.rs +29 -10
  109. package/src/rust/renderer.rs +350 -837
  110. package/src/rust/session/column_defaults_update.rs +26 -8
  111. package/src/rust/session/view_subscription.rs +30 -15
  112. package/src/rust/session.rs +753 -264
  113. package/src/rust/tasks/apply_global_filters.rs +89 -0
  114. package/src/rust/tasks/auto_pause.rs +171 -0
  115. package/src/rust/tasks/copy_export.rs +29 -19
  116. package/src/rust/tasks/create_panel.rs +243 -0
  117. package/src/rust/tasks/dismiss_render_warning.rs +3 -1
  118. package/src/rust/tasks/edit_expression.rs +4 -4
  119. package/src/rust/tasks/eject.rs +64 -23
  120. package/src/rust/tasks/mod.rs +28 -4
  121. package/src/rust/tasks/pipeline.rs +411 -0
  122. package/src/rust/tasks/presize_panels.rs +324 -0
  123. package/src/rust/tasks/reset_all.rs +22 -12
  124. package/src/rust/tasks/resize_observer.rs +124 -87
  125. package/src/rust/tasks/restore_and_render.rs +80 -86
  126. package/src/rust/tasks/restore_panel.rs +145 -0
  127. package/src/rust/tasks/send_column_config.rs +5 -2
  128. package/src/rust/tasks/send_plugin_config.rs +5 -2
  129. package/src/rust/tasks/set_edit_mode.rs +59 -0
  130. package/src/rust/tasks/sync_update_panels.rs +152 -0
  131. package/src/rust/tasks/table_lifecycle.rs +158 -0
  132. package/src/rust/tasks/update_theme.rs +56 -12
  133. package/src/rust/utils/completion.rs +56 -0
  134. package/src/rust/utils/custom_element.rs +5 -0
  135. package/src/rust/utils/debounce.rs +130 -7
  136. package/src/rust/utils/mod.rs +6 -0
  137. package/src/rust/utils/pending_effects.rs +62 -0
  138. package/src/rust/utils/spawn.rs +33 -0
  139. package/src/rust/utils/weak_scope.rs +0 -10
  140. package/src/rust/workspace.rs +846 -0
  141. package/src/svg/active-indicator.svg +3 -0
  142. package/src/themes/botanical.css +5 -2
  143. package/src/themes/defaults.css +54 -2
  144. package/src/themes/dracula.css +6 -3
  145. package/src/themes/gruvbox-dark.css +5 -2
  146. package/src/themes/gruvbox.css +5 -2
  147. package/src/themes/icons.css +4 -2
  148. package/src/themes/monokai.css +5 -2
  149. package/src/themes/phosphor.css +5 -2
  150. package/src/themes/pro-dark.css +5 -2
  151. package/src/themes/pro.css +2 -1
  152. package/src/themes/solarized-dark.css +5 -2
  153. package/src/themes/solarized.css +5 -2
  154. package/src/themes/vaporwave.css +5 -2
  155. package/src/ts/bootstrap.ts +8 -7
  156. package/src/ts/extensions.ts +30 -2
  157. package/src/ts/perspective-viewer.ts +10 -0
  158. package/src/ts/plugin.ts +72 -5
  159. package/src/ts/ts-rs/ClientOptions.ts +7 -0
  160. package/src/ts/ts-rs/ExportMethod.ts +3 -0
  161. package/src/ts/ts-rs/ExportOptions.ts +8 -0
  162. package/src/ts/ts-rs/GetClientOptions.ts +7 -0
  163. package/src/ts/ts-rs/GetTableOptions.ts +7 -0
  164. package/src/ts/ts-rs/Layout.ts +9 -0
  165. package/src/ts/ts-rs/Orientation.ts +6 -0
  166. package/src/ts/ts-rs/PanelOptions.ts +7 -0
  167. package/src/ts/ts-rs/PanelViewerConfig.ts +15 -0
  168. package/src/ts/ts-rs/SplitLayout.ts +5 -0
  169. package/src/ts/ts-rs/TabLayout.ts +11 -0
  170. package/src/ts/ts-rs/ViewerConfig.ts +6 -2
  171. package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
  172. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
  173. package/src/rust/tasks/intersection_observer.rs +0 -98
  174. package/src/rust/tasks/update_and_render.rs +0 -110
  175. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline0.js → perspective-viewer-daeef4ceb066e6c7/inline1.js} +0 -0
  176. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline1.js → perspective-viewer-daeef4ceb066e6c7/inline2.js} +0 -0
  177. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline2.js → perspective-viewer-daeef4ceb066e6c7/inline3.js} +0 -0
  178. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline4.js → perspective-viewer-daeef4ceb066e6c7/inline5.js} +0 -0
@@ -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`]. `wire_custom_events` is the only
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::update_view_config`].
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().metadata = SessionMetadata::default();
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().metadata = SessionMetadata::default();
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
- let table_unloaded = self.table_unloaded.callback();
265
- self.borrow_mut().is_clean = false;
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, which must exist on the
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().is_clean = false;
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().is_clean = false;
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::update_view_config`] ahead of the next
481
- /// draw and so may temporarily disagree with the bound `View`.
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
- /// Update the config, setting the `columns` property to the plugin defaults
500
- /// if provided.
501
- pub fn update_view_config(&self, config_update: ViewConfigUpdate) -> ApiResult<()> {
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
- if self.borrow_mut().config.apply_update(config_update) && self.0.borrow().is_clean {
510
- self.0.borrow_mut().is_clean = false;
511
- // View config changed → cached stats are stale.
512
- self.clear_column_stats();
513
- self.view_config_changed.emit(());
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
- /// In order to create a new view in this session, the session must first be
548
- /// validated to create a `ValidSession<'_>` guard.
549
- pub async fn validate(&self) -> Result<ValidSession<'_>, ApiError> {
550
- let old = self.borrow_mut().old_config.take();
551
- let is_diff = match old.as_ref() {
552
- Some(old) => !old.is_equivalent(&self.borrow().config),
553
- None => true,
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
- if let Err(err) = self.validate_view_config().await {
557
- let session = self.clone();
558
- let poll_loop = LocalPollLoop::new(move |()| {
559
- ApiFuture::spawn(session.reset(ResetOptions {
560
- config: true,
561
- expressions: true,
562
- ..ResetOptions::default()
563
- }));
564
- Ok(JsValue::UNDEFINED)
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
- self.borrow_mut().error = Some(TableErrorState(
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
- return Err(err);
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(ValidSession(self, is_diff))
1046
+ Ok(ValidatedSnapshot(snap))
596
1047
  }
597
1048
 
598
- fn update_stats(&self, stats: ViewStats) {
599
- self.borrow_mut().stats = Some(stats);
600
- if let Some(cb) = self.on_stats_changed.borrow().as_ref() {
601
- cb.emit(());
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
- fn all_columns(&self) -> Vec<String> {
606
- self.metadata()
607
- .get_table_columns()
608
- .into_iter()
609
- .flatten()
610
- .cloned()
611
- .collect()
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
- .as_ref()
623
- .ok_or_else(|| apierror!(NoTableError))?
624
- .clone();
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 expression_names = if self.metadata().get_features().unwrap().expressions {
627
- let valid_recs = table
628
- .validate_expressions(config.expressions.clone())
629
- .await?;
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
- self.metadata_mut().update_expressions(&valid_recs)?
632
- } else {
633
- HashSet::default()
1160
+ ViewSubscription::new(view, config, effective, on_stats, on_update).await?
634
1161
  };
635
1162
 
636
- if config.columns.is_empty() {
637
- config.columns = table_columns.into_iter().map(Some).collect();
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
- for column in config.columns.iter().flatten() {
641
- if all_columns.contains(column) || expression_names.contains(column) {
642
- let _existed = view_columns.insert(column);
643
- } else {
644
- return Err(apierror!(InvalidViewerConfigError(
645
- "columns",
646
- column.to_owned()
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
- for column in config.group_by.iter() {
652
- if all_columns.contains(column) || expression_names.contains(column) {
653
- let _existed = view_columns.insert(column);
654
- } else {
655
- return Err(apierror!(InvalidViewerConfigError(
656
- "group_by",
657
- column.to_owned(),
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
- for column in config.split_by.iter() {
663
- if all_columns.contains(column) || expression_names.contains(column) {
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
- for sort in config.sort.iter() {
674
- if all_columns.contains(&sort.0) || expression_names.contains(&sort.0) {
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
- for filter in config.filter.iter() {
685
- // TODO check filter op
686
- if all_columns.contains(filter.column()) || expression_names.contains(filter.column()) {
687
- let _existed = view_columns.insert(filter.column());
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 reset_clean(&self) -> bool {
705
- let mut is_clean = true;
706
- std::mem::swap(&mut is_clean, &mut self.0.borrow_mut().is_clean);
707
- is_clean
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,105 +1269,77 @@ impl Session {
752
1269
  }
753
1270
  }
754
1271
 
755
- /// A newtype wrapper which only provides `create_view()`
756
- pub struct ValidSession<'a>(&'a Session, bool);
757
-
758
- impl ValidSession<'_> {
759
- /// Set a new `View` (derived from this `Session`'s `Table`), and create the
760
- /// `update()` subscription, consuming this `ValidSession<'_>` and returning
761
- /// the original `&Session`.
762
- pub async fn create_view(&self) -> Result<Option<View>, ApiError> {
763
- // Always consume `is_clean` to preserve the "mark up-to-date"
764
- // contract of `reset_clean`. Force a full build if no view
765
- // schema has been built yet — `query_column_config_schema` (and
766
- // downstream `update_columns_configs`) needs a populated
767
- // `view_schema` to avoid destructively stripping every key from
768
- // incoming `columns_config` payloads. Without this fallback, a
769
- // first restore that lands with `is_clean = true` (e.g., when a
770
- // prior validate already consumed the flag) would skip the
771
- // view-schema-building inner block, leaving the schema empty
772
- // and causing strip-on-write to silently zero out
773
- // user-supplied column config.
774
- let was_clean = self.0.reset_clean();
775
- let needs_schema = !self.0.metadata().has_view_schema();
776
- if (!was_clean && !self.0.borrow().is_paused) || needs_schema {
777
- if !self.1 {
778
- let config = self.0.borrow().config.clone();
779
- if let Some(sub) = &mut self.0.borrow_mut().view_sub.as_mut() {
780
- sub.update_view_config(Rc::new(config));
781
- return Ok(Some(sub.get_view().clone()));
782
- }
783
- }
784
-
785
- let table = self
786
- .0
787
- .borrow()
788
- .table
789
- .clone()
790
- .ok_or("`restore()` called before `load()`")?;
791
-
792
- let mut view_config = self.0.borrow().config.clone();
793
-
794
- // Populate the aggreagtes with defaults as a courtesy to the
795
- // virtual server api.
796
- for col in view_config
797
- .columns
798
- .iter()
799
- .flatten()
800
- .chain(view_config.sort.iter().map(|x| &x.0))
801
- {
802
- if !view_config.aggregates.contains_key(col.as_str()) {
803
- let agg = self
804
- .0
805
- .metadata()
806
- .get_column_aggregates(col.as_str())
807
- .and_then(|mut aggs| aggs.next())
808
- .into_apierror();
809
-
810
- match agg {
811
- Err(_) => tracing::warn!(
812
- "No default aggregate for column '{}' found, skipping",
813
- col
814
- ),
815
- Ok(agg) => _ = view_config.aggregates.insert(col.to_string(), agg),
816
- };
817
- }
818
- }
819
-
820
- let view = table.view(Some(view_config.into())).await?;
821
- let view_schema = view.schema().await?;
822
- self.0.metadata_mut().update_view_schema(&view_schema)?;
823
- let on_stats = Callback::from({
824
- let this = self.0.clone();
825
- move |stats| this.update_stats(stats)
826
- });
827
-
828
- let sub = {
829
- let config = self.0.borrow().config.clone();
830
- let on_update = self
831
- .0
832
- .metadata()
833
- .get_features()
834
- .unwrap()
835
- .on_update
836
- .then(|| self.0.table_updated.callback());
837
-
838
- ViewSubscription::new(view, config, on_stats, on_update).await?
839
- };
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
+ }
840
1281
 
841
- let view = self.0.borrow_mut().view_sub.take();
842
- ApiFuture::spawn(view.delete());
843
- self.0.borrow_mut().view_sub = Some(sub);
844
- }
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
+ }
845
1304
 
846
- Ok(self.0.get_view())
1305
+ pub fn view(&self) -> &View {
1306
+ &self.0
847
1307
  }
848
1308
  }
849
1309
 
850
- impl Drop for ValidSession<'_> {
851
- /// `ValidSession` is a guard for listeners of the `view_created` pubsub
852
- /// event.
853
- fn drop(&mut self) {
854
- self.0.view_created.emit(());
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
+ }
855
1344
  }
856
1345
  }