@perspective-dev/viewer 5.5.0 → 5.6.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 (201) hide show
  1. package/README.md +82 -3
  2. package/dist/cdn/perspective-viewer.js +9 -9
  3. package/dist/cdn/perspective-viewer.js.map +3 -3
  4. package/dist/css/blueprint.css +1 -1
  5. package/dist/css/botanical.css +1 -1
  6. package/dist/css/dracula.css +1 -1
  7. package/dist/css/eggplant.css +1 -1
  8. package/dist/css/gruvbox-dark.css +1 -1
  9. package/dist/css/gruvbox.css +1 -1
  10. package/dist/css/icons.css +1 -1
  11. package/dist/css/intl/ar.css +1 -1
  12. package/dist/css/intl/de.css +1 -1
  13. package/dist/css/intl/es.css +1 -1
  14. package/dist/css/intl/fr.css +1 -1
  15. package/dist/css/intl/ja.css +1 -1
  16. package/dist/css/intl/pt.css +1 -1
  17. package/dist/css/intl/zh.css +1 -1
  18. package/dist/css/intl.css +1 -1
  19. package/dist/css/ledger.css +1 -1
  20. package/dist/css/monokai.css +1 -1
  21. package/dist/css/nord.css +1 -1
  22. package/dist/css/phosphor.css +1 -1
  23. package/dist/css/pro-dark.css +1 -1
  24. package/dist/css/pro.css +1 -1
  25. package/dist/css/solarized-dark.css +1 -1
  26. package/dist/css/solarized.css +1 -1
  27. package/dist/css/themes.css +1 -1
  28. package/dist/css/vaporwave.css +1 -1
  29. package/dist/css/velvet.css +1 -1
  30. package/dist/docs/perspective-docs.json +1 -1
  31. package/dist/esm/column-format.js +2 -0
  32. package/dist/esm/column-format.js.map +7 -0
  33. package/dist/esm/extensions.d.ts +3 -13
  34. package/dist/esm/perspective-viewer.d.ts +1 -1
  35. package/dist/esm/perspective-viewer.js +9 -9
  36. package/dist/esm/perspective-viewer.js.map +3 -3
  37. package/dist/esm/plugin.d.ts +4 -1
  38. package/dist/esm/select-detail.d.ts +15 -0
  39. package/dist/esm/select-detail.js +2 -0
  40. package/dist/esm/select-detail.js.map +7 -0
  41. package/dist/esm/ts-rs/DerivedTableConfig.d.ts +35 -0
  42. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -1
  43. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +7 -6
  44. package/dist/esm/ts-rs/RestoreOptions.d.ts +3 -7
  45. package/dist/esm/ts-rs/TableRef.d.ts +9 -0
  46. package/dist/esm/ts-rs/ViewerConfig.d.ts +7 -6
  47. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +7 -5
  48. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  49. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +9 -0
  50. package/dist/wasm/perspective-viewer.d.ts +126 -35
  51. package/dist/wasm/perspective-viewer.js +320 -185
  52. package/dist/wasm/perspective-viewer.wasm +0 -0
  53. package/dist/wasm/perspective-viewer.wasm.d.ts +21 -17
  54. package/package.json +33 -20
  55. package/src/css/column-selector.css +205 -50
  56. package/src/css/ui/containers/context-menu.css +12 -1
  57. package/src/css/ui/containers/dropdown-menu-items.css +29 -0
  58. package/src/css/ui/containers/scroll-panel.css +4 -1
  59. package/src/rust/agent/client/protocol.rs +0 -89
  60. package/src/rust/agent/markdown/mod.rs +0 -238
  61. package/src/rust/agent/preamble.md +1 -0
  62. package/src/rust/agent/tools.rs +2 -16
  63. package/src/rust/components/column_selector/active_column.rs +15 -5
  64. package/src/rust/components/column_selector/add_expression_button.rs +9 -26
  65. package/src/rust/components/column_selector/config_selector.rs +34 -6
  66. package/src/rust/components/column_selector/derived_control.rs +229 -0
  67. package/src/rust/components/column_selector/expr_edit_button.rs +3 -2
  68. package/src/rust/components/column_selector/filter_column.rs +0 -2
  69. package/src/rust/components/column_selector/inactive_column.rs +4 -5
  70. package/src/rust/components/column_selector/pivot_column.rs +21 -1
  71. package/src/rust/components/column_selector/sort_column.rs +4 -3
  72. package/src/rust/components/column_selector.rs +21 -1
  73. package/src/rust/components/column_settings_sidebar/save_settings.rs +5 -3
  74. package/src/rust/components/column_settings_sidebar/style_tab.rs +1 -0
  75. package/src/rust/components/column_settings_sidebar.rs +25 -19
  76. package/src/rust/components/debug_panel.rs +38 -29
  77. package/src/rust/components/dragdrop_list.rs +8 -9
  78. package/src/rust/components/export_dropdown.rs +6 -0
  79. package/src/rust/components/main_panel/msg.rs +6 -4
  80. package/src/rust/components/main_panel/reconcile.rs +6 -1
  81. package/src/rust/components/main_panel/render.rs +70 -51
  82. package/src/rust/components/main_panel/update.rs +39 -1
  83. package/src/rust/components/main_panel.rs +97 -59
  84. package/src/rust/components/new_panel_menu.rs +69 -1
  85. package/src/rust/components/panel_menu.rs +63 -12
  86. package/src/rust/components/plugin_tab.rs +3 -2
  87. package/src/rust/components/render_warning.rs +2 -1
  88. package/src/rust/components/settings_panel.rs +4 -4
  89. package/src/rust/components/status_bar.rs +80 -21
  90. package/src/rust/components/status_indicator.rs +8 -1
  91. package/src/rust/components/viewer/filters.rs +15 -3
  92. package/src/rust/components/viewer/msg.rs +8 -0
  93. package/src/rust/components/viewer/panels.rs +44 -4
  94. package/src/rust/components/viewer/render.rs +1 -0
  95. package/src/rust/components/viewer/settings.rs +0 -22
  96. package/src/rust/components/viewer/snapshots.rs +2 -2
  97. package/src/rust/components/viewer/wiring.rs +18 -1
  98. package/src/rust/components/viewer.rs +47 -27
  99. package/src/rust/components/window_editor.rs +5 -4
  100. package/src/rust/config/column_config_schema.rs +38 -377
  101. package/src/rust/config/export_method.rs +24 -0
  102. package/src/rust/config/number_string_format.rs +0 -94
  103. package/src/rust/config/options.rs +3 -7
  104. package/src/rust/config/plugin_static_config.rs +0 -42
  105. package/src/rust/config/viewer_config.rs +47 -17
  106. package/src/rust/config/workspace_config.rs +158 -128
  107. package/src/rust/custom_elements/viewer.rs +189 -129
  108. package/src/rust/custom_events.rs +33 -18
  109. package/src/rust/derived.rs +266 -0
  110. package/src/rust/exprtk/tokenize.rs +0 -54
  111. package/src/rust/js/plugin.rs +1 -1
  112. package/src/rust/lib.rs +3 -0
  113. package/src/rust/presentation.rs +23 -4
  114. package/src/rust/queries/column_locator.rs +55 -48
  115. package/src/rust/queries/get_viewer_config.rs +1 -1
  116. package/src/rust/queries/plugin_column_styles.rs +22 -23
  117. package/src/rust/renderer/dispatch.rs +2 -0
  118. package/src/rust/renderer/plugin_config.rs +652 -487
  119. package/src/rust/renderer/props.rs +4 -0
  120. package/src/rust/renderer/registry.rs +2 -6
  121. package/src/rust/renderer/state.rs +205 -0
  122. package/src/rust/renderer.rs +201 -59
  123. package/src/rust/session/metadata.rs +35 -19
  124. package/src/rust/session/op_queue.rs +430 -0
  125. package/src/rust/session/panel_state.rs +327 -0
  126. package/src/rust/session/view_subscription.rs +42 -3
  127. package/src/rust/session.rs +931 -419
  128. package/src/rust/tasks/apply_global_filters.rs +49 -35
  129. package/src/rust/tasks/auto_pause.rs +22 -12
  130. package/src/rust/tasks/copy_export.rs +72 -0
  131. package/src/rust/tasks/create_panel.rs +46 -21
  132. package/src/rust/tasks/derive.rs +312 -0
  133. package/src/rust/tasks/mod.rs +6 -2
  134. package/src/rust/tasks/pipeline.rs +152 -89
  135. package/src/rust/tasks/reset_all.rs +31 -20
  136. package/src/rust/tasks/restore_derived.rs +200 -0
  137. package/src/rust/tasks/restore_panel.rs +153 -141
  138. package/src/rust/tasks/send_column_config.rs +56 -29
  139. package/src/rust/tasks/send_plugin_config.rs +52 -23
  140. package/src/rust/tasks/set_edit_mode.rs +33 -25
  141. package/src/rust/tasks/sync_update_panels.rs +40 -13
  142. package/src/rust/tasks/table_lifecycle.rs +70 -34
  143. package/src/rust/tasks/transactional_restore.rs +387 -0
  144. package/src/rust/tasks/update_theme.rs +45 -6
  145. package/src/rust/ui/containers/scroll_panel.rs +17 -10
  146. package/src/rust/ui/containers/split_panel.rs +0 -41
  147. package/src/rust/ui/context_menu.rs +157 -18
  148. package/src/rust/ui/font_loader.rs +23 -35
  149. package/src/rust/ui/form/mirrored_textarea.rs +0 -12
  150. package/src/rust/ui/form/multi_stop_gradient_selector.rs +0 -89
  151. package/src/rust/ui/form/palette_selector.rs +0 -51
  152. package/src/rust/ui/portal.rs +4 -10
  153. package/src/rust/utils/browser/mod.rs +2 -3
  154. package/src/{ts/perspective-viewer.inline.ts → rust/utils/browser/mouse.rs} +11 -10
  155. package/src/rust/utils/browser/request_animation_frame.rs +27 -8
  156. package/src/rust/utils/css.rs +0 -360
  157. package/src/rust/utils/{tests/mod.rs → in_flight.rs} +62 -2
  158. package/src/rust/utils/local_fonts.rs +0 -36
  159. package/src/rust/utils/mod.rs +4 -3
  160. package/src/rust/utils/modal_position.rs +15 -21
  161. package/src/rust/utils/pending_effects.rs +6 -30
  162. package/src/rust/utils/{browser/tests/mod.rs → plugin_host_state.rs} +31 -1
  163. package/src/rust/utils/pubsub.rs +0 -1
  164. package/src/rust/workspace.rs +187 -140
  165. package/src/svg/derived.svg +6 -0
  166. package/src/themes/icons.css +1 -0
  167. package/src/themes/intl/ar.css +10 -0
  168. package/src/themes/intl/de.css +10 -0
  169. package/src/themes/intl/es.css +10 -0
  170. package/src/themes/intl/fr.css +10 -0
  171. package/src/themes/intl/ja.css +10 -0
  172. package/src/themes/intl/pt.css +10 -0
  173. package/src/themes/intl/zh.css +10 -0
  174. package/src/themes/intl.css +10 -0
  175. package/src/themes/monokai.css +1 -1
  176. package/src/themes/vaporwave.css +1 -1
  177. package/src/ts/extensions.ts +3 -36
  178. package/src/ts/perspective-viewer.ts +1 -1
  179. package/src/ts/plugin.ts +4 -1
  180. package/src/{rust/utils/tests/request_animation_frame.rs → ts/select-detail.ts} +32 -26
  181. package/src/ts/ts-rs/DerivedTableConfig.ts +22 -0
  182. package/src/ts/ts-rs/ExportMethod.ts +1 -1
  183. package/src/ts/ts-rs/PanelViewerConfig.ts +7 -6
  184. package/src/ts/ts-rs/RestoreOptions.ts +3 -7
  185. package/src/ts/ts-rs/TableRef.ts +9 -0
  186. package/src/ts/ts-rs/ViewerConfig.ts +7 -6
  187. package/src/ts/ts-rs/ViewerConfigUpdate.ts +7 -5
  188. package/src/ts/ts-rs/WorkspaceConfig.ts +7 -1
  189. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -0
  190. package/dist/esm/perspective-viewer.inline.d.ts +0 -3
  191. package/dist/esm/perspective-viewer.inline.js +0 -21
  192. package/dist/esm/perspective-viewer.inline.js.map +0 -7
  193. package/src/rust/tasks/restore_and_render.rs +0 -107
  194. package/src/rust/utils/browser/tests/debounce.rs +0 -163
  195. package/src/rust/utils/tests/pubsub.rs +0 -95
  196. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline0.js +0 -0
  197. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline1.js +0 -0
  198. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline2.js +0 -0
  199. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline3.js +0 -0
  200. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline4.js +0 -0
  201. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline5.js +0 -0
@@ -13,11 +13,13 @@
13
13
  pub(crate) mod column_defaults_update;
14
14
  pub(crate) mod drag_drop_update;
15
15
  mod metadata;
16
+ mod op_queue;
17
+ mod panel_state;
16
18
  mod props;
17
19
  pub(crate) mod replace_expression_update;
18
20
  mod view_subscription;
19
21
 
20
- use std::cell::{Cell, Ref, RefCell};
22
+ use std::cell::{Cell, RefCell};
21
23
  use std::collections::{HashMap, HashSet};
22
24
  use std::future::Future;
23
25
  use std::ops::Deref;
@@ -25,7 +27,9 @@ use std::rc::Rc;
25
27
 
26
28
  use perspective_client::config::*;
27
29
  use perspective_client::proto::ViewDimensionsResp;
28
- use perspective_client::{Client, ClientError, ReconnectCallback, View};
30
+ use perspective_client::{
31
+ Client, ClientError, DescribeError, Description, ExprValidationResult, ReconnectCallback, View,
32
+ };
29
33
  use perspective_js::apierror;
30
34
  use perspective_js::utils::*;
31
35
  use wasm_bindgen::prelude::*;
@@ -34,9 +38,13 @@ use yew::prelude::*;
34
38
 
35
39
  use self::metadata::*;
36
40
  pub use self::metadata::{MetadataRef, SessionMetadata, SessionMetadataRc};
41
+ use self::op_queue::OpQueue;
42
+ pub use self::op_queue::{EditDelta, OpCtx, OpKind, StepFuture, Ticket};
43
+ use self::panel_state::effective;
44
+ pub use self::panel_state::{Binding, OverlayClause, PanelState};
37
45
  pub use self::props::{SessionProps, TableLoadState};
38
- pub use self::view_subscription::ViewStats;
39
46
  use self::view_subscription::*;
47
+ pub use self::view_subscription::{ViewStats, ViewSubscription};
40
48
  use crate::config::PluginStaticConfig;
41
49
  use crate::utils::*;
42
50
 
@@ -71,12 +79,8 @@ pub struct SessionHandle {
71
79
  pub view_config_changed: PubSub<()>,
72
80
  pub title_changed: PubSub<Option<String>>,
73
81
 
74
- /// Count of in-flight CONFIG-DRIVEN pipeline runs.
75
- in_flight_config_runs: Cell<u32>,
76
-
77
- /// Fires with the ABSOLUTE [`Self::in_flight_config_runs`] count after
78
- /// every change.
79
- pub run_state_changed: PubSub<u32>,
82
+ /// Account of in-flight CONFIG-DRIVEN pipeline runs.
83
+ pub config_runs: InFlight,
80
84
 
81
85
  /// Fires when the user clicks the status indicator while in
82
86
  /// [`StatusIconState::Normal`].
@@ -100,31 +104,21 @@ pub struct SessionHandle {
100
104
  /// copying the config.
101
105
  pub last_dispatched_config: RefCell<Option<std::rc::Rc<crate::config::ViewerConfig>>>,
102
106
 
103
- /// Monotone load counter, bumped once per `load()` call
104
- /// ([`Session::begin_pending_load`]). Identifies the LATEST load so a
105
- /// stale async classification — React binds `load` to a prop and fires it
106
- /// repeatedly, unawaited and out of order — no-ops instead of clobbering a
107
- /// newer load ([`Session::is_current_load`]).
108
- load_generation: Cell<u32>,
109
-
110
- /// Open between a `load()` call and its payload's classification as
111
- /// `Table`/`Client` ([`PendingLoad`]).
112
- pending_load: RefCell<Option<PendingLoad>>,
107
+ /// Every writer of this panel's committed state, run one at a time in
108
+ /// submit order (see [`op_queue`]).
109
+ queue: Rc<OpQueue>,
113
110
 
114
111
  /// Coalesces `view_config_changed`: multiple synchronous commits in one
115
112
  /// task emit ONE event on the next microtask — the cadence the deleted
116
113
  /// `is_clean` flag provided by accident, now deliberate.
117
114
  config_event_scheduled: Cell<bool>,
118
115
 
119
- /// Count of in-flight `perspective-config-update` dispatch tasks for this
120
- /// panel (see [`Session::track_dispatch`]). `flush()` joins these via
116
+ /// Account of in-flight `perspective-config-update` dispatch tasks for
117
+ /// this panel (see [`Session::track_dispatch`]). `flush()` joins these via
121
118
  /// [`Session::settle_dispatches`], so the "config-update fires before
122
119
  /// `flush()` resolves" contract holds by construction instead of by
123
120
  /// microtask luck.
124
- pending_dispatches: Cell<u32>,
125
-
126
- /// Fires when [`SessionHandle::pending_dispatches`] returns to zero.
127
- dispatches_settled: PubSub<()>,
121
+ dispatches: InFlight,
128
122
 
129
123
  /// Fires when [`SessionHandle::column_stats`] is updated (insert or
130
124
  /// clear). Subscribers re-render and re-query the schema with the
@@ -200,24 +194,144 @@ pub(crate) async fn probe_table(
200
194
  /// Mutable state for `Session`.
201
195
  #[derive(Default)]
202
196
  pub struct SessionData {
203
- client: Option<perspective_client::Client>,
204
- table: Option<perspective_client::Table>,
205
- pending_table: Option<String>,
206
- metadata: SessionMetadata,
207
- config: ViewConfig,
208
- global_filter: Vec<Filter>,
197
+ /// This panel's committed state: ONE immutable value, replaced whole by
198
+ /// [`SessionData::swap`].
199
+ state: Lifecycle,
200
+
201
+ /// What [`Session::metadata`] answers while nothing is bound, and what
202
+ /// [`Session::metadata_mut`] scribbles on (every write is a no-op then).
203
+ unbound_metadata: Rc<SessionMetadata>,
209
204
  view_sub: Option<ViewSubscription>,
210
205
  stats: Option<ViewStats>,
211
- is_loading: bool,
212
206
  is_paused: bool,
207
+ }
208
+
209
+ /// A panel's state and whether it can still be written.
210
+ enum Lifecycle {
211
+ Live(Rc<PanelState>),
212
+
213
+ /// Terminal: the owning panel was ejected ([`Session::dispose`]).
214
+ Disposed {
215
+ last: Rc<PanelState>,
216
+ disposal: Disposal,
217
+ },
218
+ }
219
+
220
+ impl Default for Lifecycle {
221
+ fn default() -> Self {
222
+ Self::Live(Rc::default())
223
+ }
224
+ }
213
225
 
214
- /// Memo for [`Session::validate_snapshot`]: the expression set validated
215
- /// by the last successful server round trip; an equal snapshot skips the
216
- /// round trip. Written only under the draw lock; cleared on table
217
- /// (re)bind and expression reset.
218
- last_validated_expressions: Option<Expressions>,
219
- error: Option<TableErrorState>,
220
- title: Option<String>,
226
+ impl Deref for Lifecycle {
227
+ type Target = Rc<PanelState>;
228
+
229
+ fn deref(&self) -> &Self::Target {
230
+ match self {
231
+ Lifecycle::Live(state) | Lifecycle::Disposed { last: state, .. } => state,
232
+ }
233
+ }
234
+ }
235
+
236
+ impl Lifecycle {
237
+ fn live_mut(&mut self) -> Option<&mut Rc<PanelState>> {
238
+ match self {
239
+ Lifecycle::Live(state) => Some(state),
240
+ Lifecycle::Disposed { .. } => None,
241
+ }
242
+ }
243
+
244
+ fn disposal(&self) -> Option<Disposal> {
245
+ match self {
246
+ Lifecycle::Live(_) => None,
247
+ Lifecycle::Disposed { disposal, .. } => Some(*disposal),
248
+ }
249
+ }
250
+ }
251
+
252
+ /// An OWNED read guard over the committed [`ViewConfig`]: a snapshot that
253
+ /// borrows nothing.
254
+ pub struct ViewConfigRef(Rc<ViewConfig>);
255
+
256
+ impl Deref for ViewConfigRef {
257
+ type Target = ViewConfig;
258
+
259
+ fn deref(&self) -> &Self::Target {
260
+ &self.0
261
+ }
262
+ }
263
+
264
+ impl SessionData {
265
+ fn error(&self) -> Option<&TableErrorState> {
266
+ self.state.lost()
267
+ }
268
+
269
+ fn clear_errors(&mut self) {
270
+ if self.state.lost().is_some() {
271
+ self.swap(self.state.recovered());
272
+ }
273
+ }
274
+
275
+ fn swap(&mut self, next: PanelState) {
276
+ if let Some(state) = self.state.live_mut() {
277
+ *state = Rc::new(next);
278
+ }
279
+ }
280
+
281
+ fn table(&self) -> Option<&perspective_client::Table> {
282
+ self.state.bound().map(|bound| &bound.table)
283
+ }
284
+
285
+ fn pending_table(&self) -> Option<&str> {
286
+ self.state.awaiting()
287
+ }
288
+
289
+ fn metadata(&self) -> &Rc<SessionMetadata> {
290
+ match self.state.bound() {
291
+ Some(bound) => &bound.metadata,
292
+ None => &self.unbound_metadata,
293
+ }
294
+ }
295
+
296
+ /// Copy-on-write: a snapshot held elsewhere never observes the mutation.
297
+ fn metadata_mut(&mut self) -> &mut SessionMetadata {
298
+ let Some(state) = self.state.live_mut() else {
299
+ return Rc::make_mut(&mut self.unbound_metadata);
300
+ };
301
+
302
+ let binding = match &mut Rc::make_mut(state).binding {
303
+ Binding::Lost { prior, .. } => Rc::make_mut(prior),
304
+ binding => binding,
305
+ };
306
+
307
+ match binding {
308
+ Binding::Bound(bound) => Rc::make_mut(&mut Rc::make_mut(bound).metadata),
309
+ _ => Rc::make_mut(&mut self.unbound_metadata),
310
+ }
311
+ }
312
+
313
+ fn config(&self) -> &ViewConfig {
314
+ self.state.config()
315
+ }
316
+
317
+ fn set_config(&mut self, config: ViewConfig) {
318
+ self.swap(self.state.with_config(Rc::new(config)));
319
+ }
320
+
321
+ /// Memo for [`Session::validate_snapshot`]: the [`Description`] the server
322
+ /// gave for the last validated effective config; an equal snapshot skips
323
+ /// the round trip.
324
+ fn description(&self) -> Option<&(Rc<ViewConfig>, Rc<Description>)> {
325
+ self.state.bound()?.description.as_ref()
326
+ }
327
+
328
+ fn set_description(&mut self, description: Option<(Rc<ViewConfig>, Rc<Description>)>) {
329
+ self.swap(self.state.with_description(description));
330
+ }
331
+
332
+ fn unbind(&mut self) {
333
+ self.swap(self.state.unbound());
334
+ }
221
335
  }
222
336
 
223
337
  #[derive(Clone)]
@@ -255,20 +369,32 @@ impl TableErrorState {
255
369
  }
256
370
  }
257
371
 
372
+ /// How a terminal disposal reports itself to a public run still in flight on
373
+ /// the ejected panel.
374
+ #[derive(Clone, Copy, PartialEq, Eq)]
375
+ pub enum Disposal {
376
+ /// A separate call destroyed the panel, so a racing public run rejects
377
+ /// rather than reporting a success it never achieved.
378
+ Reject,
379
+
380
+ /// The ejecting call reports the eviction on its own promise, so a racing
381
+ /// public run resolves rather than surfacing it twice.
382
+ Resolve,
383
+ }
384
+
385
+ impl From<Disposal> for ApiResult<()> {
386
+ fn from(disposal: Disposal) -> Self {
387
+ match disposal {
388
+ Disposal::Reject => Err(ApiError::new("Panel disposed")),
389
+ Disposal::Resolve => Ok(()),
390
+ }
391
+ }
392
+ }
393
+
258
394
  #[derive(Debug, Default)]
259
395
  pub enum TableIntermediateState {
260
396
  #[default]
261
397
  Ejected,
262
- Reloaded,
263
- }
264
-
265
- /// The open-load window state (see [`SessionHandle::pending_load`]): the
266
- /// generation that opened it (for supersession detection) and the raw config
267
- /// deltas committed while it was open, in commit order, awaiting replay over a
268
- /// reset base if the payload classifies as a `Table`.
269
- pub struct PendingLoad {
270
- generation: u32,
271
- journal: Vec<ViewConfigUpdate>,
272
398
  }
273
399
 
274
400
  /// Options for [`Session::reset`]
@@ -293,19 +419,6 @@ pub struct ResetOptions {
293
419
  #[derive(Clone)]
294
420
  pub struct Session(Rc<SessionHandle>);
295
421
 
296
- /// RAII spinner accounting for one config-driven pipeline run.
297
- pub struct ConfigRunToken(Session);
298
-
299
- impl Drop for ConfigRunToken {
300
- fn drop(&mut self) {
301
- let count = self.0.0.in_flight_config_runs.get();
302
- debug_assert!(count > 0, "ConfigRunToken underflow");
303
- let count = count.saturating_sub(1);
304
- self.0.0.in_flight_config_runs.set(count);
305
- self.0.run_state_changed.emit(count);
306
- }
307
- }
308
-
309
422
  impl ImplicitClone for Session {}
310
423
 
311
424
  impl Deref for Session {
@@ -330,21 +443,56 @@ impl Session {
330
443
  Self(Rc::default())
331
444
  }
332
445
 
333
- pub(crate) fn metadata(&self) -> MetadataRef<'_> {
334
- std::cell::Ref::map(self.borrow(), |x| &x.metadata)
446
+ pub(crate) fn metadata(&self) -> MetadataRef {
447
+ MetadataRef(self.borrow().metadata().clone())
335
448
  }
336
449
 
337
450
  pub(crate) fn metadata_mut(&self) -> MetadataMutRef<'_> {
338
- std::cell::RefMut::map(self.borrow_mut(), |x| &mut x.metadata)
451
+ std::cell::RefMut::map(self.borrow_mut(), |x| x.metadata_mut())
339
452
  }
340
453
 
454
+ /// The title as the UI and `save()` see it: the committed title, or the
455
+ /// latest pending rename.
341
456
  pub(crate) fn get_title(&self) -> Option<String> {
342
- self.borrow().title.clone()
457
+ let pending = self
458
+ .0
459
+ .queue
460
+ .pending_edits()
461
+ .into_iter()
462
+ .filter_map(|delta| match delta {
463
+ EditDelta::Title(title) => Some(title),
464
+ _ => None,
465
+ })
466
+ .next_back();
467
+
468
+ pending.unwrap_or_else(|| self.borrow().state.chrome.title.clone())
343
469
  }
344
470
 
471
+ /// Rename this panel — a UI edit, committed by the drain in its turn.
345
472
  pub fn set_title(&self, title: Option<String>) {
473
+ let title = title.filter(|x| !x.is_empty());
474
+ let session = self.clone();
475
+ let _ticket = self.submit(
476
+ OpKind::Edit {
477
+ delta: EditDelta::Title(title.clone()),
478
+ swaps_plugin: false,
479
+ },
480
+ move |_ctx| {
481
+ Box::pin(async move {
482
+ session.commit_title(title);
483
+ Ok(None)
484
+ })
485
+ },
486
+ );
487
+
488
+ self.title_changed.emit(self.get_title());
489
+ }
490
+
491
+ /// Write the title NOW.
492
+ pub(crate) fn commit_title(&self, title: Option<String>) {
346
493
  let new_title = title.filter(|x| !x.is_empty());
347
- self.borrow_mut().title.clone_from(&new_title);
494
+ let next = self.borrow().state.with_title(new_title.clone());
495
+ self.borrow_mut().swap(next);
348
496
  self.title_changed.emit(new_title);
349
497
  }
350
498
 
@@ -354,37 +502,26 @@ impl Session {
354
502
  /// `Session`.
355
503
  pub fn reset(&self, options: ResetOptions) -> impl Future<Output = ApiResult<()>> + use<> {
356
504
  let view = self.0.borrow_mut().view_sub.take();
357
- let had_table = self.borrow().table.is_some();
505
+ let had_table = self.borrow().table().is_some();
358
506
  let err = self.get_error();
359
- self.borrow_mut().error = None;
507
+ self.borrow_mut().clear_errors();
360
508
  if options.stats {
361
509
  self.update_stats(ViewStats::default());
362
510
  }
363
511
 
364
512
  if options.config {
365
- self.borrow_mut().config.reset(options.expressions);
513
+ let mut config = self.borrow().config().clone();
514
+ config.reset(options.expressions);
515
+ self.borrow_mut().set_config(config);
366
516
  }
367
517
 
368
518
  if options.expressions {
369
- self.borrow_mut().last_validated_expressions = None;
519
+ self.borrow_mut().set_description(None);
370
520
  }
371
521
 
372
522
  match options.table {
373
- Some(TableIntermediateState::Ejected) => {
374
- self.borrow_mut().is_loading = false;
375
- self.borrow_mut().table = None;
376
- self.borrow_mut().pending_table = None;
377
- self.borrow_mut().metadata = SessionMetadata::default();
378
- },
379
- Some(TableIntermediateState::Reloaded) => {
380
- self.borrow_mut().is_loading = true;
381
- self.borrow_mut().table = None;
382
- self.borrow_mut().pending_table = None;
383
- self.borrow_mut().metadata = SessionMetadata::default();
384
- },
385
- _ => {
386
- self.borrow_mut().is_loading = false;
387
- },
523
+ Some(TableIntermediateState::Ejected) => self.borrow_mut().unbind(),
524
+ None => {},
388
525
  };
389
526
 
390
527
  // A config reset that KEEPS its `Table` is itself a commit, and every
@@ -409,56 +546,94 @@ impl Session {
409
546
  }
410
547
  }
411
548
 
412
- /// Open a pending-load window (see [`SessionHandle::pending_load`]) at the
413
- /// `load()` call site. Bumps the load generation and returns it — the
414
- /// caller carries it into the async classification to detect supersession
415
- /// by a later `load()`. Sets `is_loading` so a FRESH panel shows the
416
- /// spinner while a slow payload resolves (a RELOAD keeps its bound table,
417
- /// hence [`TableLoadState::Loaded`], throughout — no spinner flicker).
418
- pub fn begin_pending_load(&self) -> u32 {
419
- let generation = self.0.load_generation.get() + 1;
420
- self.0.load_generation.set(generation);
421
- *self.0.pending_load.borrow_mut() = Some(PendingLoad {
422
- generation,
423
- journal: Vec::new(),
549
+ /// Submit a writer of this panel's state.
550
+ pub fn submit(&self, kind: OpKind, step: impl FnOnce(OpCtx) -> StepFuture + 'static) -> Ticket {
551
+ if let Some(disposal) = self.disposal() {
552
+ return Ticket::settled(disposal.into());
553
+ }
554
+
555
+ let is_edit = matches!(kind, OpKind::Edit {
556
+ delta: EditDelta::View(_),
557
+ ..
424
558
  });
425
- self.borrow_mut().is_loading = true;
426
- generation
559
+
560
+ let guard = self.begin_config_run();
561
+ let (ticket, start) = self.0.queue.push(kind, guard, step);
562
+ if is_edit {
563
+ self.notify_view_config_changed();
564
+ }
565
+
566
+ if start {
567
+ let queue = self.0.queue.clone();
568
+ let session = self.clone();
569
+ ApiFuture::spawn_named("op-queue-drain", async move {
570
+ queue
571
+ .drain(move |_exit| session.notify_view_config_changed())
572
+ .await;
573
+
574
+ Ok(())
575
+ });
576
+ }
577
+
578
+ ticket
427
579
  }
428
580
 
429
- /// Whether a `load()` payload is still awaiting classification. A `true`
430
- /// value makes every config-driven bind DEFER
431
- /// ([`crate::tasks::bind_snapshot`]) — the incoming config must never draw
432
- /// against the outgoing table.
581
+ /// Resolves once every op submitted to this panel has settled — its write
582
+ /// made (or rejected) AND its render landed.
583
+ pub async fn settle_ops(&self) {
584
+ self.0.queue.idle().await
585
+ }
586
+
587
+ /// Whether a `load()` is queued or running.
433
588
  pub fn has_pending_load(&self) -> bool {
434
- self.0.pending_load.borrow().is_some()
435
- }
436
-
437
- /// Close the pending-load window for `generation`, returning its journal
438
- /// (the raw deltas committed while it was open, in order) for replay.
439
- /// Returns `None` when a later `load()` already superseded this one (or it
440
- /// was already closed), which the caller treats as "abandon this
441
- /// classification, hold the frame". Clears `is_loading` — the
442
- /// classification that follows sets the real table state.
443
- pub fn take_pending_load(&self, generation: u32) -> Option<Vec<ViewConfigUpdate>> {
444
- let mut slot = self.0.pending_load.borrow_mut();
445
- if slot.as_ref().map(|p| p.generation) != Some(generation) {
446
- return None;
589
+ self.0.queue.has_load()
590
+ }
591
+
592
+ /// The ONE `Live → Disposed` transition (this session's panel was ejected):
593
+ /// the state is frozen as its last value, everything queued settles per
594
+ /// `disposal`, and every later `submit` settles at once.
595
+ pub(crate) fn dispose(&self, disposal: Disposal) {
596
+ {
597
+ let mut data = self.borrow_mut();
598
+ if let Lifecycle::Live(last) = &data.state {
599
+ data.state = Lifecycle::Disposed {
600
+ last: last.clone(),
601
+ disposal,
602
+ };
603
+ }
447
604
  }
448
605
 
449
- let journal = slot.take().map(|p| p.journal);
450
- drop(slot);
451
- self.borrow_mut().is_loading = false;
452
- journal
606
+ self.0.queue.reject_all(disposal.into());
607
+ }
608
+
609
+ /// Take the bound `View` out WITHOUT deleting it, so it can feed a derived
610
+ /// `Table`, leaving the next bind nothing to dispose of.
611
+ pub fn take_view_sub(&self) -> Option<ViewSubscription> {
612
+ self.borrow_mut().view_sub.take()
613
+ }
614
+
615
+ /// Put a subscription taken by [`Self::take_view_sub`] back, for a
616
+ /// derivation which could not complete.
617
+ pub fn put_view_sub(&self, sub: ViewSubscription) {
618
+ self.borrow_mut().view_sub = Some(sub);
619
+ }
620
+
621
+ pub(crate) fn is_disposed(&self) -> bool {
622
+ self.disposal().is_some()
623
+ }
624
+
625
+ /// This session's disposal, if its panel was ejected.
626
+ pub(crate) fn disposal(&self) -> Option<Disposal> {
627
+ self.borrow().state.disposal()
453
628
  }
454
629
 
455
630
  pub(crate) fn has_table(&self) -> Option<TableLoadState> {
456
631
  let data = self.borrow();
457
- if data.table.is_some() {
632
+ if data.table().is_some() {
458
633
  Some(TableLoadState::Loaded)
459
- } else if data.is_loading {
634
+ } else if self.0.queue.has_load() {
460
635
  Some(TableLoadState::Loading)
461
- } else if data.pending_table.is_some() {
636
+ } else if data.pending_table().is_some() {
462
637
  Some(TableLoadState::Pending)
463
638
  } else {
464
639
  None
@@ -466,13 +641,15 @@ impl Session {
466
641
  }
467
642
 
468
643
  pub fn get_table(&self) -> Option<perspective_client::Table> {
469
- self.borrow().table.clone()
644
+ self.borrow().table().cloned()
470
645
  }
471
646
 
647
+ /// Look this panel's tables up on `client` — unbinding it from a table of
648
+ /// any other client's.
472
649
  pub fn set_client(&self, client: Client) -> bool {
473
- if Some(&client) != self.borrow().client.as_ref() {
474
- self.borrow_mut().client = Some(client);
475
- self.borrow_mut().table = None;
650
+ if Some(&client) != self.get_client().as_ref() {
651
+ let next = self.borrow().state.unbound_on(Some(client));
652
+ self.borrow_mut().swap(next);
476
653
  true
477
654
  } else {
478
655
  false
@@ -480,13 +657,13 @@ impl Session {
480
657
  }
481
658
 
482
659
  pub fn get_client(&self) -> Option<Client> {
483
- self.borrow().client.clone()
660
+ self.borrow().state.client()
484
661
  }
485
662
 
486
663
  /// The configured table name awaiting a host, if any (see
487
664
  /// [`SessionData::pending_table`]).
488
665
  pub fn pending_table(&self) -> Option<String> {
489
- self.borrow().pending_table.clone()
666
+ self.borrow().pending_table().map(str::to_owned)
490
667
  }
491
668
 
492
669
  /// Suspend a BOUND table to PENDING: delete the `View`, drop the `Table`
@@ -496,60 +673,35 @@ impl Session {
496
673
  /// as it was when the name is hosted again. `None` when no table is
497
674
  /// bound.
498
675
  pub fn suspend_table(&self) -> Option<impl Future<Output = ApiResult<()>> + use<>> {
499
- let name = self.borrow().table.as_ref()?.get_name().to_owned();
676
+ let name = self.borrow().table()?.get_name().to_owned();
677
+ let client = self.get_client()?;
500
678
  let fut = self.reset(ResetOptions {
501
679
  table: Some(TableIntermediateState::Ejected),
502
680
  stats: true,
503
681
  ..ResetOptions::default()
504
682
  });
505
683
 
506
- self.borrow_mut().pending_table = Some(name);
684
+ let next = self.borrow().state.awaiting_table(client, name);
685
+ self.borrow_mut().swap(next);
507
686
  Some(fut)
508
687
  }
509
688
 
510
- /// Reset this `Session`'s state with a new `Table`. Implicitly clears the
511
- /// `ViewSubscription`, which will need to be re-initialized later via
512
- /// `create_view()`.
513
- ///
514
- /// # Arguments
515
- ///
516
- /// - `table_name` The name of the `Table` to load.
517
- ///
518
- /// # Returns
519
- ///
520
- /// `table_name` is unique per `Client`, so if this value has not changed,
521
- /// `Session::set_table` does nothing and returns `Ok(false)`.
522
- ///
523
- /// A name no loaded client hosts pends ([`MissingTable::Pend`]) and
524
- /// returns `Ok(false)` until the table lifecycle re-runs the bind.
525
- pub async fn set_table(&self, table_name: String) -> ApiResult<bool> {
526
- if Some(table_name.as_str()) == self.0.borrow().table.as_ref().map(|x| x.get_name()) {
527
- self.0.borrow_mut().pending_table = None;
528
- return Ok(false);
529
- }
530
-
531
- let client = self.0.borrow().client.clone().into_apierror()?;
532
- match probe_table(&client, &table_name, MissingTable::Pend).await? {
533
- Some(table) => match self.bind_table(table).await {
534
- Ok(()) => Ok(true),
535
- Err(err) => self.set_error(false, err).await.map(|_| false),
536
- },
537
- None => {
538
- self.pend_table(table_name).await?;
539
- Ok(false)
540
- },
541
- }
542
- }
543
-
544
- /// Bind an opened `Table`, replacing any previous binding and announcing
545
- /// `table_loaded`.
546
- pub(crate) async fn bind_table(&self, table: perspective_client::Table) -> ApiResult<()> {
547
- let metadata = SessionMetadata::from_table(&table).await?;
548
- let client = table.get_client();
689
+ /// Record a connection error on `client` as this panel's lost binding — for
690
+ /// as long as `client` is the one the panel is bound through.
691
+ async fn watch_client(&self, client: &Client) -> ApiResult<()> {
549
692
  let on_error = self.on_table_errored.borrow().clone();
550
693
  let session = self.clone();
694
+ let watched = client.clone();
551
695
  let poll_loop = LocalPollLoop::new(move |(message, reconnect): (ApiError, _)| {
552
- session.borrow_mut().error = Some(TableErrorState(message, reconnect));
696
+ if session.get_client().as_ref() != Some(&watched) {
697
+ return Ok(JsValue::UNDEFINED);
698
+ }
699
+
700
+ let next = session
701
+ .borrow()
702
+ .state
703
+ .with_lost(TableErrorState(message, reconnect));
704
+ session.borrow_mut().swap(next);
553
705
  if let Some(cb) = &on_error {
554
706
  cb.emit(());
555
707
  }
@@ -570,29 +722,9 @@ impl Session {
570
722
  }))
571
723
  .await?;
572
724
 
573
- let sub = self.borrow_mut().view_sub.take();
574
- self.borrow_mut().metadata = metadata;
575
- self.borrow_mut().table = Some(table);
576
- self.borrow_mut().pending_table = None;
577
- self.borrow_mut().is_loading = false;
578
- self.borrow_mut().last_validated_expressions = None;
579
- sub.delete().await?;
580
- self.table_loaded.emit(());
581
725
  Ok(())
582
726
  }
583
727
 
584
- /// Record `name` as pending, dropping any bound `Table` so nothing binds
585
- /// against the outgoing table while the name waits for a host.
586
- pub(crate) async fn pend_table(&self, name: String) -> ApiResult<()> {
587
- let sub = self.borrow_mut().view_sub.take();
588
- self.borrow_mut().table = None;
589
- self.borrow_mut().metadata = SessionMetadata::default();
590
- self.borrow_mut().pending_table = Some(name);
591
- self.borrow_mut().is_loading = false;
592
- self.borrow_mut().last_validated_expressions = None;
593
- sub.delete().await
594
- }
595
-
596
728
  pub async fn set_error(&self, reset_table: bool, err: ApiError) -> ApiResult<()> {
597
729
  let session = self.clone();
598
730
  let poll_loop = LocalPollLoop::new(move |()| {
@@ -604,7 +736,7 @@ impl Session {
604
736
  Ok(JsValue::UNDEFINED)
605
737
  });
606
738
 
607
- self.borrow_mut().error = Some(TableErrorState(
739
+ let error = TableErrorState(
608
740
  err.clone(),
609
741
  Some(ReconnectCallback::new(move || {
610
742
  clone!(poll_loop);
@@ -613,7 +745,10 @@ impl Session {
613
745
  Ok(())
614
746
  })
615
747
  })),
616
- ));
748
+ );
749
+
750
+ let next = self.borrow().state.with_lost(error);
751
+ self.borrow_mut().swap(next);
617
752
 
618
753
  if let Some(cb) = self.on_table_errored.borrow().as_ref() {
619
754
  cb.emit(());
@@ -621,7 +756,7 @@ impl Session {
621
756
 
622
757
  let sub = self.borrow_mut().view_sub.take();
623
758
  if reset_table {
624
- self.borrow_mut().table = None;
759
+ self.borrow_mut().unbind();
625
760
  }
626
761
 
627
762
  sub.delete().await?;
@@ -651,24 +786,38 @@ impl Session {
651
786
  }
652
787
 
653
788
  pub fn js_get_table(&self) -> Option<JsValue> {
654
- Some(perspective_js::Table::from(self.borrow().table.clone()?).into())
789
+ Some(perspective_js::Table::from(self.borrow().table().cloned()?).into())
655
790
  }
656
791
 
792
+ /// Whether the binding is lost.
657
793
  pub(crate) fn is_errored(&self) -> bool {
658
- self.borrow().error.is_some()
794
+ self.borrow().error().is_some()
659
795
  }
660
796
 
661
797
  pub(crate) fn get_error(&self) -> Option<ApiError> {
662
- self.borrow().error.as_ref().map(|x| x.0.clone())
798
+ self.borrow().error().map(|x| x.0.clone())
663
799
  }
664
800
 
801
+ /// Recover from an error state (the overlay's button) — a writer, so an op
802
+ /// on the queue like any other.
665
803
  pub async fn reconnect(&self) -> ApiResult<()> {
666
- let err = self.borrow().error.clone();
804
+ let session = self.clone();
805
+ self.submit(OpKind::Restore { update: None }, move |_ctx| {
806
+ Box::pin(async move {
807
+ session.reconnect_step().await?;
808
+ Ok(None)
809
+ })
810
+ })
811
+ .settle()
812
+ .await
813
+ }
814
+
815
+ async fn reconnect_step(&self) -> ApiResult<()> {
816
+ let err = self.borrow().error().cloned();
667
817
  if let Some(TableErrorState(_, Some(reconnect))) = err {
668
818
  reconnect().await?;
669
- self.borrow_mut().is_loading = false;
670
- self.borrow_mut().error = None;
671
- self.borrow_mut().last_validated_expressions = None;
819
+ self.borrow_mut().clear_errors();
820
+ self.borrow_mut().set_description(None);
672
821
  self.borrow_mut().view_sub = None;
673
822
  self.table_loaded.emit(());
674
823
  }
@@ -704,36 +853,93 @@ impl Session {
704
853
  .is_some_and(|s| s.num_table_cells.is_some())
705
854
  }
706
855
 
707
- pub fn get_view_config(&'_ self) -> Ref<'_, ViewConfig> {
708
- Ref::map(self.borrow(), |x| &x.config)
856
+ /// The view config as the UI and `save()` see it: the committed config with
857
+ /// every pending UI edit applied, in submit order.
858
+ pub fn get_view_config(&self) -> ViewConfigRef {
859
+ ViewConfigRef(self.projected())
709
860
  }
710
861
 
711
- /// The effective [`ViewConfig`] the `View` is built from: the stored config
712
- /// with the transient element-level [`SessionData::global_filter`] clauses
713
- /// appended. Used at view-creation only; the stored `config` (hence
714
- /// `savePanel`/the settings UI) is unaffected.
715
- fn effective_view_config(&self) -> ViewConfig {
716
- let data = self.borrow();
717
- if data.global_filter.is_empty() {
718
- return data.config.clone();
862
+ /// The COMMITTED view config — what a running op must read, since the edits
863
+ /// still queued behind it have not happened yet.
864
+ pub fn committed_view_config(&self) -> ViewConfigRef {
865
+ ViewConfigRef(self.borrow().state.config().clone())
866
+ }
867
+
868
+ fn projected(&self) -> Rc<ViewConfig> {
869
+ let committed = self.borrow().state.config().clone();
870
+ let edits = self
871
+ .0
872
+ .queue
873
+ .pending_edits()
874
+ .into_iter()
875
+ .filter_map(|delta| match delta {
876
+ EditDelta::View(delta) => Some(*delta),
877
+ _ => None,
878
+ })
879
+ .collect::<Vec<_>>();
880
+
881
+ if edits.is_empty() {
882
+ return committed;
719
883
  }
720
884
 
721
- let mut config = data.config.clone();
722
- config.filter.extend(data.global_filter.iter().cloned());
723
- config
885
+ let mut config = (*committed).clone();
886
+ for delta in edits {
887
+ let mut candidate = config.clone();
888
+ if candidate.apply_update(delta) && self.validate_names(&candidate).is_ok() {
889
+ self.normalize_view_config(&mut candidate);
890
+ config = candidate;
891
+ }
892
+ }
893
+
894
+ Rc::new(config)
724
895
  }
725
896
 
726
- /// Replace the transient element-level global filters and re-render the
727
- /// view (no-op if unchanged). These are applied on top of the panel's
728
- /// own config at view-creation but never persisted into it.
729
- pub fn set_global_filter(&self, filter: Vec<Filter>) -> bool {
730
- if self.borrow().global_filter != filter {
731
- self.borrow_mut().global_filter = filter;
732
- self.notify_view_config_changed();
733
- true
734
- } else {
735
- false
897
+ /// Whether `delta` is an edit the UI may submit: SYNCHRONOUS validation
898
+ /// against the projection, so an invalid edit is refused at the control
899
+ /// that made it.
900
+ pub fn check_edit(&self, delta: &ViewConfigUpdate) -> ApiResult<()> {
901
+ if let Some(x) = self.borrow().state.lost() {
902
+ return Err(ApiError::new(x.0.clone()));
903
+ }
904
+
905
+ let mut candidate = (*self.projected()).clone();
906
+ if candidate.apply_update(delta.clone()) {
907
+ self.validate_names(&candidate)?;
736
908
  }
909
+
910
+ Ok(())
911
+ }
912
+
913
+ /// The effective [`ViewConfig`] the `View` is built from — the one the
914
+ /// committed description describes.
915
+ fn effective_view_config(&self) -> Rc<ViewConfig> {
916
+ let data = self.borrow();
917
+ if let Some((effective, _)) = data.description() {
918
+ return effective.clone();
919
+ }
920
+
921
+ let metadata = data.metadata().clone();
922
+ let (config, _) = effective(data.config(), &data.state.overlay, &|name| {
923
+ metadata.get_column_table_type(name)
924
+ });
925
+
926
+ Rc::new(config)
927
+ }
928
+
929
+ /// The overlay clauses this panel's table cannot honor, by index — a column
930
+ /// it lacks, or has with another type.
931
+ pub fn skipped_overlay(&self) -> Vec<usize> {
932
+ let data = self.borrow();
933
+ let metadata = data.metadata().clone();
934
+ effective(data.config(), &data.state.overlay, &|name| {
935
+ metadata.get_column_table_type(name)
936
+ })
937
+ .1
938
+ }
939
+
940
+ /// The element's global filter as last broadcast to this panel.
941
+ pub(crate) fn committed_overlay(&self) -> Rc<Vec<OverlayClause>> {
942
+ self.borrow().state.overlay.clone()
737
943
  }
738
944
 
739
945
  /// Snapshot of the [`ViewConfig`] the currently-bound `View` was
@@ -742,9 +948,8 @@ impl Session {
742
948
  ///
743
949
  /// Prefer this over [`Self::get_view_config`] when you need a
744
950
  /// value consistent with what the active plugin is rendering.
745
- /// `get_view_config` returns the live config, which is mutated
746
- /// synchronously by [`Self::commit_view_config`] ahead of the next
747
- /// queued run and so may temporarily disagree with the bound `View`.
951
+ /// `get_view_config` returns the PROJECTED config — committed state plus
952
+ /// pending edits — which runs ahead of the bound `View`.
748
953
  pub fn get_rendered_view_config(&self) -> Option<Rc<ViewConfig>> {
749
954
  self.borrow().view_sub.as_ref().map(|s| s.get_view_config())
750
955
  }
@@ -755,7 +960,7 @@ impl Session {
755
960
  config_static: &PluginStaticConfig,
756
961
  ) {
757
962
  use self::column_defaults_update::*;
758
- let config = self.get_view_config();
963
+ let config = self.committed_view_config();
759
964
  config_update.set_update_column_defaults(
760
965
  &self.metadata(),
761
966
  &config,
@@ -777,72 +982,18 @@ impl Session {
777
982
  use self::column_defaults_update::*;
778
983
  config_update.set_update_rollup_defaults(
779
984
  &self.metadata(),
780
- &self.get_view_config(),
985
+ &self.committed_view_config(),
781
986
  config_static,
782
987
  )
783
988
  }
784
989
 
785
- /// Apply a `ViewConfigUpdate` to the live config — the ONLY view-config
786
- /// mutator (invariant I1: synchronous and total; no `await` separates any
787
- /// read of the config from this write, so a lost update is
788
- /// unrepresentable).
789
- ///
790
- /// Validation is SYNCHRONOUS and happens before anything is applied: an
791
- /// update naming an unknown column is rejected with `Err` and the config
792
- /// is untouched (I4 — invalid state is never entered, so no rollback
793
- /// path exists). Server-side expression compilation is deliberately NOT
794
- /// checked here; it is a property of a pipeline run
795
- /// ([`Self::validate_snapshot`]) and fails that run, never the commit.
796
- pub fn commit_view_config(&self, config_update: ViewConfigUpdate) -> ApiResult<()> {
797
- if let Some(x) = self.borrow().error.as_ref() {
798
- tracing::warn!("Errored state");
799
-
800
- // Load bearing return
801
- return Err(ApiError::new(x.0.clone()));
802
- }
803
-
804
- // A `load()` is classifying its payload (see
805
- // [`SessionHandle::pending_load`]): record the RAW delta so a `Table`
806
- // classification can replay it over the reset base, validated against
807
- // the INCOMING table's schema. The live apply below is a best-effort
808
- // preview (kept for `save()` coherence, I1) that SKIPS name validation
809
- // — the delta may legitimately name the incoming table's columns,
810
- // absent from the outgoing one — mirroring the "no table bound yet"
811
- // leniency already in `validate_names`. Authoritative validation is
812
- // deferred to the replay.
813
- let pending = self.has_pending_load();
814
- let mut candidate = self.borrow().config.clone();
815
- let journal_entry = pending.then(|| config_update.clone());
816
- if !candidate.apply_update(config_update) {
817
- return Ok(());
818
- }
819
-
820
- if let Some(entry) = journal_entry
821
- && let Some(p) = self.0.pending_load.borrow_mut().as_mut()
822
- {
823
- p.journal.push(entry);
824
- }
825
-
826
- if !pending {
827
- self.validate_names(&candidate)?;
828
- }
829
-
830
- self.normalize_view_config(&mut candidate);
831
- self.borrow_mut().config = candidate;
832
- self.notify_view_config_changed();
833
- Ok(())
834
- }
835
-
836
- /// Table-bind commit: normalize the (possibly empty) config against the
837
- /// newly-bound table's metadata — the default-view materialization that
838
- /// previously happened inside the async validate write-back. SYNC;
839
- /// called immediately after `set_table().await` inside the binding run,
840
- /// so it is ordered like any other commit.
841
- pub fn commit_table_defaults(&self) {
842
- let mut candidate = self.borrow().config.clone();
990
+ /// Re-normalize the config against the bound table's metadata — the
991
+ /// default-view materialization after a config reset that keeps its table.
992
+ fn commit_table_defaults(&self) {
993
+ let mut candidate = self.borrow().config().clone();
843
994
  self.normalize_view_config(&mut candidate);
844
- if candidate != self.borrow().config {
845
- self.borrow_mut().config = candidate;
995
+ if candidate != *self.borrow().config() {
996
+ self.borrow_mut().set_config(candidate);
846
997
  self.notify_view_config_changed();
847
998
  }
848
999
  }
@@ -854,7 +1005,13 @@ impl Session {
854
1005
  /// when no table is bound yet: the config rides along until `load()`
855
1006
  /// binds one, and the engine surfaces any residual error on that run.
856
1007
  fn validate_names(&self, config: &ViewConfig) -> ApiResult<()> {
857
- let table_columns = self.all_columns();
1008
+ Self::validate_names_with(&self.metadata(), config)
1009
+ }
1010
+
1011
+ /// [`Self::validate_names`] against an explicit table's metadata — the
1012
+ /// INCOMING table's, for a restore that binds one.
1013
+ fn validate_names_with(metadata: &SessionMetadata, config: &ViewConfig) -> ApiResult<()> {
1014
+ let table_columns = Self::columns_of(metadata);
858
1015
  if table_columns.is_empty() {
859
1016
  return Ok(());
860
1017
  }
@@ -897,7 +1054,11 @@ impl Session {
897
1054
  /// write-back): fill empty `columns` from the table, prune `aggregates`
898
1055
  /// to referenced columns.
899
1056
  fn normalize_view_config(&self, config: &mut ViewConfig) {
900
- let table_columns = self.all_columns();
1057
+ Self::normalize_with(&self.metadata(), config)
1058
+ }
1059
+
1060
+ fn normalize_with(metadata: &SessionMetadata, config: &mut ViewConfig) {
1061
+ let table_columns = Self::columns_of(metadata);
901
1062
  if table_columns.is_empty() {
902
1063
  return;
903
1064
  }
@@ -927,19 +1088,10 @@ impl Session {
927
1088
  /// [`Self::settle_dispatches`]. Used by the `perspective-config-update`
928
1089
  /// dispatcher, whose landing `flush()` must be able to await.
929
1090
  pub fn track_dispatch(&self, fut: impl std::future::Future<Output = ApiResult<()>> + 'static) {
930
- self.0
931
- .pending_dispatches
932
- .set(self.0.pending_dispatches.get() + 1);
933
- let session = self.clone();
1091
+ let guard = self.0.dispatches.guard();
934
1092
  ApiFuture::spawn_named("config-update-dispatch", async move {
935
- let result = fut.await;
936
- let remaining = session.0.pending_dispatches.get() - 1;
937
- session.0.pending_dispatches.set(remaining);
938
- if remaining == 0 {
939
- session.0.dispatches_settled.emit(());
940
- }
941
-
942
- if let Err(e) = result {
1093
+ let _guard = guard;
1094
+ if let Err(e) = fut.await {
943
1095
  tracing::error!("[config-update dispatch] {}", e);
944
1096
  }
945
1097
 
@@ -950,10 +1102,7 @@ impl Session {
950
1102
  /// Resolve once every in-flight tracked dispatch for this panel has
951
1103
  /// landed. Immediate when none are pending.
952
1104
  pub async fn settle_dispatches(&self) -> ApiResult<()> {
953
- while self.0.pending_dispatches.get() > 0 {
954
- self.0.dispatches_settled.read_next().await?;
955
- }
956
-
1105
+ self.0.dispatches.settle().await;
957
1106
  Ok(())
958
1107
  }
959
1108
 
@@ -973,26 +1122,10 @@ impl Session {
973
1122
  }
974
1123
 
975
1124
  /// Begin spinner accounting for ONE config-driven pipeline run: call
976
- /// immediately after the run's commit, and move the returned token INTO
977
- /// the run future. `Drop` settles it on every exit — completion, error,
978
- /// cancellation, and runs that never reach `bind_view` (the
979
- /// deferred-draw restore) — so, unlike edge-counting PubSub pairs, the
980
- /// count cannot strand by construction.
981
- /// [`SessionHandle::run_state_changed`] broadcasts the ABSOLUTE count
982
- /// on both edges.
983
- pub fn begin_config_run(&self) -> ConfigRunToken {
984
- let count = self.0.in_flight_config_runs.get() + 1;
985
- self.0.in_flight_config_runs.set(count);
986
- self.run_state_changed.emit(count);
987
- ConfigRunToken(self.clone())
988
- }
989
-
990
- /// The number of config-driven pipeline runs currently in flight (see
991
- /// [`Self::begin_config_run`]). Read directly when (re)targeting a
992
- /// panel — level-triggered consumers initialize from this and then
993
- /// track [`SessionHandle::run_state_changed`].
994
- pub fn in_flight_config_runs(&self) -> u32 {
995
- self.0.in_flight_config_runs.get()
1125
+ /// immediately after the run's commit, and move the returned guard INTO
1126
+ /// the run future.
1127
+ pub fn begin_config_run(&self) -> InFlightGuard {
1128
+ self.0.config_runs.guard()
996
1129
  }
997
1130
 
998
1131
  /// Read the cached `ColumnStats` for a column. Returns `None` if no
@@ -1031,53 +1164,336 @@ impl Session {
1031
1164
  /// that preceded run *N*'s completion (invariant I3).
1032
1165
  pub fn snapshot(&self, _guard: &RenderGuard) -> ConfigSnapshot {
1033
1166
  ConfigSnapshot {
1034
- config: Rc::new(self.borrow().config.clone()),
1035
- effective: Rc::new(self.effective_view_config()),
1167
+ config: self.borrow().state.config().clone(),
1168
+ effective: self.effective_view_config(),
1036
1169
  }
1037
1170
  }
1038
1171
 
1039
- /// Validate a snapshot's expressions against the server (the only
1040
- /// inherently-async validation), updating the metadata expression
1041
- /// schema. Reads nothing from — and writes nothing to — the live
1042
- /// config; a failure fails this RUN, never the committed config (I4).
1043
- ///
1044
- /// Memoized: when the snapshot's expressions equal the last successfully
1045
- /// validated set, the round trip is skipped entirely — safe because the
1046
- /// memo key is an immutable snapshot field and the recorded set is
1047
- /// written only under the draw lock.
1172
+ /// Validate a snapshot against the server with `Table::describe`, failing
1173
+ /// this run and never the committed config.
1048
1174
  pub async fn validate_snapshot(
1049
1175
  &self,
1050
1176
  _guard: &RenderGuard,
1051
1177
  snap: ConfigSnapshot,
1052
1178
  ) -> ApiResult<ValidatedSnapshot> {
1053
- let memo_hit =
1054
- self.borrow().last_validated_expressions.as_ref() == Some(&snap.effective.expressions);
1055
- if !memo_hit {
1056
- let supports_expressions = self
1057
- .metadata()
1058
- .get_features()
1059
- .map(|x| x.expressions)
1060
- .unwrap_or_default();
1061
-
1062
- if supports_expressions {
1063
- let table = self
1064
- .borrow()
1065
- .table
1066
- .as_ref()
1067
- .ok_or_else(|| apierror!(NoTableError))?
1068
- .clone();
1069
-
1070
- let valid_recs = table
1071
- .validate_expressions(snap.effective.expressions.clone())
1072
- .await?;
1179
+ let (description, fresh) = self.describe_effective(&snap.effective).await?;
1180
+ if fresh {
1181
+ tracing::warn!("Rendering a commit that was not described");
1182
+ Self::record_description(&mut self.metadata_mut(), &snap.effective, &description)?;
1183
+ self.borrow_mut()
1184
+ .set_description(Some((snap.effective.clone(), description.clone())));
1185
+ }
1186
+
1187
+ Ok(ValidatedSnapshot { snap })
1188
+ }
1189
+
1190
+ /// The server's [`Description`] of `effective`, and whether it took a round
1191
+ /// trip (`false` when the memo already held it).
1192
+ async fn describe_effective(
1193
+ &self,
1194
+ effective: &Rc<ViewConfig>,
1195
+ ) -> ApiResult<(Rc<Description>, bool)> {
1196
+ let memo = self
1197
+ .borrow()
1198
+ .description()
1199
+ .cloned()
1200
+ .filter(|(key, _)| **key == **effective)
1201
+ .map(|(_, description)| description);
1202
+
1203
+ if let Some(description) = memo {
1204
+ return Ok((description, false));
1205
+ }
1206
+
1207
+ let table = self
1208
+ .borrow()
1209
+ .table()
1210
+ .cloned()
1211
+ .ok_or_else(|| apierror!(NoTableError))?;
1212
+
1213
+ Self::describe_with(&table, &self.metadata(), effective)
1214
+ .await
1215
+ .map(|description| (description, true))
1216
+ }
1217
+
1218
+ /// `table`'s [`Description`] of `effective`, with validation failures as
1219
+ /// the errors a `restore()` rejects with.
1220
+ async fn describe_with(
1221
+ table: &perspective_client::Table,
1222
+ metadata: &SessionMetadata,
1223
+ effective: &Rc<ViewConfig>,
1224
+ ) -> ApiResult<Rc<Description>> {
1225
+ let engine_config = Self::with_default_aggregates_of(metadata, effective);
1226
+ match table.describe(engine_config.into()).await? {
1227
+ Ok(description) => Ok(Rc::new(description)),
1228
+ Err(DescribeError::Expressions {
1229
+ expression_schema,
1230
+ errors,
1231
+ }) => Err(apierror!(InvalidViewerConfigExpressionsError(Rc::new(
1232
+ ExprValidationResult {
1233
+ expression_schema,
1234
+ errors,
1235
+ expression_alias: effective.expressions.0.clone(),
1236
+ }
1237
+ )))),
1238
+ Err(DescribeError::Config(msg)) => Err(ApiError::new(msg)),
1239
+ }
1240
+ }
1241
+
1242
+ /// The PREPARE half of a transactional restore's view of the panel: the
1243
+ /// binding `plan` resolved, and the config it leaves — the committed one (a
1244
+ /// default one, for a plan that resets) with `update` applied —
1245
+ /// name-checked, normalized and DESCRIBED by the table it will be bound to.
1246
+ pub(crate) async fn prepare_view(
1247
+ &self,
1248
+ plan: BindPlan,
1249
+ mut update: ViewConfigUpdate,
1250
+ defaults: ViewDefaults<'_>,
1251
+ overlay: Option<Rc<Vec<OverlayClause>>>,
1252
+ ) -> ApiResult<PreparedView> {
1253
+ if self.is_disposed() {
1254
+ return Err(ApiError::new("Panel disposed"));
1255
+ }
1256
+
1257
+ if matches!(plan, BindPlan::Keep)
1258
+ && let Some(error) = self.borrow().state.lost()
1259
+ {
1260
+ return Err(error.0.clone());
1261
+ }
1262
+
1263
+ let committed = self.borrow().state.config().clone();
1264
+ let base = match &plan {
1265
+ BindPlan::Bind { reset: true, .. } | BindPlan::Pend { reset: true, .. } => {
1266
+ Rc::new(ViewConfig::default())
1267
+ },
1268
+ _ => committed.clone(),
1269
+ };
1270
+
1271
+ let (binding, checked) = match plan {
1272
+ BindPlan::Keep => {
1273
+ let bound = self.borrow().state.bound().cloned();
1274
+ (
1275
+ PreparedBinding::Keep,
1276
+ bound.map(|bound| (bound.table.clone(), bound.metadata.clone())),
1277
+ )
1278
+ },
1279
+ BindPlan::Bind { table, client, .. } => {
1280
+ let metadata = Rc::new(SessionMetadata::from_table(&table).await?);
1281
+ let binding = PreparedBinding::Bind {
1282
+ client: client.clone(),
1283
+ table: table.clone(),
1284
+ metadata: metadata.clone(),
1285
+ };
1286
+
1287
+ (binding, Some((*table, metadata)))
1288
+ },
1289
+ BindPlan::Pend { client, name, .. } => (PreparedBinding::Pend { client, name }, None),
1290
+ };
1291
+
1292
+ {
1293
+ use self::column_defaults_update::*;
1294
+ let metadata = match &checked {
1295
+ Some((_, metadata)) => metadata.clone(),
1296
+ None => self.borrow().metadata().clone(),
1297
+ };
1298
+
1299
+ match defaults {
1300
+ ViewDefaults::Swap(plugin) => {
1301
+ update.set_update_column_defaults(&metadata, &base, &base.columns, plugin)
1302
+ },
1303
+ ViewDefaults::Rollup(plugin) => {
1304
+ update.set_update_rollup_defaults(&metadata, &base, plugin)
1305
+ },
1306
+ ViewDefaults::AsGiven => {},
1307
+ }
1308
+ }
1309
+
1310
+ let mut candidate = (*base).clone();
1311
+ candidate.apply_update(update);
1312
+ if let Some((_, metadata)) = &checked {
1313
+ Self::validate_names_with(metadata, &candidate)?;
1314
+ Self::normalize_with(metadata, &mut candidate);
1315
+ }
1316
+
1317
+ let config = if candidate == *committed {
1318
+ committed.clone()
1319
+ } else {
1320
+ Rc::new(candidate)
1321
+ };
1322
+
1323
+ let changed = !Rc::ptr_eq(&config, &committed);
1324
+ let clauses = overlay
1325
+ .clone()
1326
+ .unwrap_or_else(|| self.borrow().state.overlay.clone());
1327
+
1328
+ let (effective, description) = match &checked {
1329
+ None => (config.clone(), None),
1330
+ Some((table, metadata)) => {
1331
+ let on_expression = clauses
1332
+ .iter()
1333
+ .any(|x| config.expressions.0.contains_key(x.filter.column()));
1334
+
1335
+ let bare = if on_expression {
1336
+ Some(Self::describe_with(table, metadata, &config).await?)
1337
+ } else {
1338
+ None
1339
+ };
1340
+
1341
+ let (effective, _) = effective(&config, &clauses, &|name| {
1342
+ metadata.get_table_schema_type(name).or_else(|| {
1343
+ bare.as_ref()
1344
+ .and_then(|x| x.expression_schema.get(name).copied())
1345
+ })
1346
+ });
1347
+
1348
+ let effective = if effective == *config {
1349
+ config.clone()
1350
+ } else {
1351
+ Rc::new(effective)
1352
+ };
1353
+
1354
+ let description = match (&binding, bare) {
1355
+ (_, Some(bare)) if Rc::ptr_eq(&effective, &config) => bare,
1356
+ (PreparedBinding::Keep, _) => self.describe_effective(&effective).await?.0,
1357
+ _ => Self::describe_with(table, metadata, &effective).await?,
1358
+ };
1359
+
1360
+ (effective, Some(description))
1361
+ },
1362
+ };
1363
+
1364
+ Ok(PreparedView {
1365
+ binding,
1366
+ overlay,
1367
+ config,
1368
+ effective,
1369
+ description,
1370
+ changed,
1371
+ announce: true,
1372
+ })
1373
+ }
1374
+
1375
+ /// The COMMIT half of [`Self::prepare_view`], as one swap of this session's
1376
+ /// state.
1377
+ pub(crate) fn commit_view(
1378
+ &self,
1379
+ view: PreparedView,
1380
+ title: Option<Option<String>>,
1381
+ ) -> BindingEffects {
1382
+ let state = self.borrow().state.clone();
1383
+ let title_before = state.chrome.title.clone();
1384
+ let had_table = state.bound().is_some();
1385
+ let (rebound, watch) = match view.binding {
1386
+ PreparedBinding::Keep => (None, None),
1387
+ PreparedBinding::Bind {
1388
+ client,
1389
+ table,
1390
+ metadata,
1391
+ } => (
1392
+ Some(state.bound_to(*table, (*metadata).clone())),
1393
+ Some(client),
1394
+ ),
1395
+ PreparedBinding::Pend { client, name } => {
1396
+ (Some(state.awaiting_table(client, name)), None)
1397
+ },
1398
+ };
1399
+
1400
+ let rebinds = rebound.is_some();
1401
+ let mut next = rebound.unwrap_or_else(|| (*state).clone());
1402
+ next = next.with_config(view.config.clone());
1403
+ if let Some(overlay) = view.overlay {
1404
+ next = next.with_overlay(overlay);
1405
+ }
1073
1406
 
1074
- self.metadata_mut().update_expressions(&valid_recs)?;
1407
+ if let Some(description) = view.description {
1408
+ if let Some(bound) = next.bound() {
1409
+ let mut metadata = (*bound.metadata).clone();
1410
+ if Self::record_description(&mut metadata, &view.effective, &description).is_ok() {
1411
+ next = next.with_metadata(Rc::new(metadata));
1412
+ }
1075
1413
  }
1076
1414
 
1077
- self.borrow_mut().last_validated_expressions = Some(snap.effective.expressions.clone());
1415
+ next = next.with_description(Some((view.effective, description)));
1416
+ }
1417
+
1418
+ let next = match title {
1419
+ Some(title) => next.with_title(title),
1420
+ None => next,
1421
+ };
1422
+
1423
+ let title_after = next.chrome.title.clone();
1424
+ let bound = next.bound().is_some();
1425
+ self.borrow_mut().swap(next);
1426
+ let outgoing = if rebinds {
1427
+ self.borrow_mut().view_sub.take()
1428
+ } else {
1429
+ None
1430
+ };
1431
+
1432
+ if rebinds {
1433
+ self.update_stats(ViewStats::default());
1434
+ }
1435
+
1436
+ if (view.changed && view.announce) || rebinds {
1437
+ self.notify_view_config_changed();
1078
1438
  }
1079
1439
 
1080
- Ok(ValidatedSnapshot(snap))
1440
+ if title_before != title_after {
1441
+ self.title_changed.emit(title_after);
1442
+ }
1443
+
1444
+ BindingEffects {
1445
+ rebinds,
1446
+ outgoing,
1447
+ unloaded: rebinds && had_table,
1448
+ loaded: rebinds && bound,
1449
+ watch,
1450
+ }
1451
+ }
1452
+
1453
+ /// Everything the metadata derives from a [`Description`] of `effective`:
1454
+ /// the expression types, the types the `View` will have, and the window
1455
+ /// columns.
1456
+ fn record_description(
1457
+ metadata: &mut SessionMetadata,
1458
+ effective: &ViewConfig,
1459
+ description: &Description,
1460
+ ) -> ApiResult<()> {
1461
+ metadata.update_expressions(&ExprValidationResult {
1462
+ expression_schema: description.expression_schema.clone(),
1463
+ errors: HashMap::new(),
1464
+ expression_alias: effective.expressions.0.clone(),
1465
+ })?;
1466
+
1467
+ metadata.update_view_schema(&description.view_schema)?;
1468
+ metadata.update_windows(&effective.windows)?;
1469
+ Ok(())
1470
+ }
1471
+
1472
+ /// Finish a committed rebind: dispose of the outgoing `View`, announce the
1473
+ /// table change, and watch the incoming client for errors.
1474
+ pub(crate) async fn finish_binding(&self, effects: BindingEffects) -> ApiResult<()> {
1475
+ let BindingEffects {
1476
+ outgoing,
1477
+ unloaded,
1478
+ loaded,
1479
+ watch,
1480
+ ..
1481
+ } = effects;
1482
+
1483
+ let deleted = outgoing.delete().await;
1484
+ if unloaded {
1485
+ self.table_unloaded.emit(true);
1486
+ }
1487
+
1488
+ if let Some(client) = watch {
1489
+ self.watch_client(&client).await?;
1490
+ }
1491
+
1492
+ if loaded {
1493
+ self.table_loaded.emit(());
1494
+ }
1495
+
1496
+ deleted
1081
1497
  }
1082
1498
 
1083
1499
  /// Bind the engine `View` for a validated snapshot: SKIP
@@ -1105,7 +1521,9 @@ impl Session {
1105
1521
  }
1106
1522
  }
1107
1523
 
1108
- let ConfigSnapshot { config, effective } = validated.0;
1524
+ let ValidatedSnapshot {
1525
+ snap: ConfigSnapshot { config, effective },
1526
+ } = validated;
1109
1527
  if self.borrow().is_paused {
1110
1528
  // A paused bind still RECONCILES the committed config (no `View`
1111
1529
  // is constructed — `view_created` stays silent). Without this
@@ -1121,8 +1539,7 @@ impl Session {
1121
1539
  return Ok(unchanged_or_deferred(self));
1122
1540
  }
1123
1541
 
1124
- let needs_schema = !self.metadata().has_view_schema();
1125
- if !needs_schema {
1542
+ {
1126
1543
  let bound = self.borrow().view_sub.as_ref().map(|x| x.build_config());
1127
1544
  if let Some(bound) = bound {
1128
1545
  if *bound == *effective {
@@ -1146,15 +1563,12 @@ impl Session {
1146
1563
 
1147
1564
  let table = self
1148
1565
  .borrow()
1149
- .table
1150
- .clone()
1566
+ .table()
1567
+ .cloned()
1151
1568
  .ok_or("`restore()` called before `load()`")?;
1152
1569
 
1153
1570
  let view_config = self.with_default_aggregates(&effective);
1154
1571
  let view = table.view(Some(view_config.into())).await?;
1155
- let view_schema = view.schema().await?;
1156
- self.metadata_mut().update_view_schema(&view_schema)?;
1157
- self.metadata_mut().update_windows(&effective.windows)?;
1158
1572
  let on_stats = Callback::from({
1159
1573
  let this = self.clone();
1160
1574
  move |stats| this.update_stats(stats)
@@ -1185,6 +1599,13 @@ impl Session {
1185
1599
  /// The engine config for a `View` built from `effective`, per-column
1186
1600
  /// default aggregates filled in as a courtesy to the virtual server API.
1187
1601
  fn with_default_aggregates(&self, effective: &ViewConfig) -> ViewConfig {
1602
+ Self::with_default_aggregates_of(&self.metadata(), effective)
1603
+ }
1604
+
1605
+ fn with_default_aggregates_of(
1606
+ metadata: &SessionMetadata,
1607
+ effective: &ViewConfig,
1608
+ ) -> ViewConfig {
1188
1609
  let mut view_config = effective.clone();
1189
1610
  for col in view_config
1190
1611
  .columns
@@ -1193,15 +1614,14 @@ impl Session {
1193
1614
  .chain(view_config.sort.iter().map(|x| &x.0))
1194
1615
  {
1195
1616
  if !view_config.aggregates.contains_key(col.as_str()) {
1196
- let agg = self
1197
- .metadata()
1617
+ let agg = metadata
1198
1618
  .get_column_aggregates(col.as_str())
1199
1619
  .and_then(|mut aggs| aggs.next())
1200
1620
  .into_apierror();
1201
1621
 
1202
1622
  match agg {
1203
1623
  Err(_) => {
1204
- tracing::warn!("No default aggregate for column '{}' found, skipping", col)
1624
+ tracing::debug!("No default aggregate for column '{}' found, skipping", col)
1205
1625
  },
1206
1626
  Ok(agg) => _ = view_config.aggregates.insert(col.to_string(), agg),
1207
1627
  };
@@ -1214,43 +1634,29 @@ impl Session {
1214
1634
  /// Build a caller-owned `View` from the effective config outside the
1215
1635
  /// render pipeline, with no subscription and no interaction with pause.
1216
1636
  pub async fn create_detached_view(&self) -> ApiResult<View> {
1217
- let table = self.borrow().table.clone().ok_or("No `Table` set")?;
1637
+ let table = self.borrow().table().cloned().ok_or("No `Table` set")?;
1218
1638
  let view_config = self.with_default_aggregates(&self.effective_view_config());
1219
1639
  Ok(table.view(Some(view_config.into())).await?)
1220
1640
  }
1221
1641
 
1222
- /// Record a failed pipeline run: error state plus a reconnect affordance
1223
- /// that resets the config (the error screen's reset button). Replaces
1224
- /// the old `validate()` error path — the committed config is NOT rolled
1225
- /// back (I4: it holds exactly what the caller committed; the failure
1226
- /// belongs to the run).
1227
- pub async fn set_run_error(&self, err: ApiError) -> ApiResult<()> {
1642
+ /// Reset the config a failed draw could not render, as one queued op.
1643
+ pub async fn reset_failed_config(&self) -> ApiResult<()> {
1228
1644
  let session = self.clone();
1229
- let poll_loop = LocalPollLoop::new(move |()| {
1230
- ApiFuture::spawn(session.reset(ResetOptions {
1231
- config: true,
1232
- expressions: true,
1233
- ..ResetOptions::default()
1234
- }));
1235
- Ok(JsValue::UNDEFINED)
1236
- });
1237
-
1238
- self.borrow_mut().error = Some(TableErrorState(
1239
- err.clone(),
1240
- Some(ReconnectCallback::new(move || {
1241
- clone!(poll_loop);
1242
- Box::pin(async move {
1243
- poll_loop.poll(()).await;
1244
- Ok(())
1245
- })
1246
- })),
1247
- ));
1248
-
1249
- if let Some(cb) = self.on_table_errored.borrow().as_ref() {
1250
- cb.emit(());
1251
- }
1645
+ self.submit(OpKind::Restore { update: None }, move |_ctx| {
1646
+ Box::pin(async move {
1647
+ session
1648
+ .reset(ResetOptions {
1649
+ config: true,
1650
+ expressions: true,
1651
+ ..ResetOptions::default()
1652
+ })
1653
+ .await?;
1252
1654
 
1253
- Err(err)
1655
+ Ok(None)
1656
+ })
1657
+ })
1658
+ .settle()
1659
+ .await
1254
1660
  }
1255
1661
 
1256
1662
  fn update_stats(&self, stats: ViewStats) {
@@ -1262,8 +1668,8 @@ impl Session {
1262
1668
  self.stats_changed.emit(());
1263
1669
  }
1264
1670
 
1265
- fn all_columns(&self) -> Vec<String> {
1266
- self.metadata()
1671
+ fn columns_of(metadata: &SessionMetadata) -> Vec<String> {
1672
+ metadata
1267
1673
  .get_table_columns()
1268
1674
  .into_iter()
1269
1675
  .flatten()
@@ -1276,6 +1682,8 @@ impl Session {
1276
1682
  /// session-related PubSub event fires.
1277
1683
  pub fn to_props(&self) -> SessionProps {
1278
1684
  let column_stats = PtrEqRc::new(self.column_stats.borrow().clone());
1685
+ let projected = self.projected();
1686
+ let title = self.get_title();
1279
1687
  let data = self.borrow();
1280
1688
 
1281
1689
  // Reuse memoized snapshots when the underlying value hasn't
@@ -1285,15 +1693,15 @@ impl Session {
1285
1693
  // these `PtrEqRc`s will spuriously refire.
1286
1694
  let config = {
1287
1695
  let mut cached = self.cached_config.borrow_mut();
1288
- if !matches!(&*cached, Some(c) if **c == data.config) {
1289
- *cached = Some(PtrEqRc::new(data.config.clone()));
1696
+ if !matches!(&*cached, Some(c) if **c == *projected) {
1697
+ *cached = Some(PtrEqRc::new((*projected).clone()));
1290
1698
  }
1291
1699
  cached.clone().unwrap()
1292
1700
  };
1293
1701
  let metadata = {
1294
1702
  let mut cached = self.cached_metadata.borrow_mut();
1295
- if !matches!(&*cached, Some(m) if **m == data.metadata) {
1296
- *cached = Some(PtrEqRc::new(data.metadata.clone()));
1703
+ if !matches!(&*cached, Some(m) if **m == **data.metadata()) {
1704
+ *cached = Some(PtrEqRc::new((**data.metadata()).clone()));
1297
1705
  }
1298
1706
  cached.clone().unwrap()
1299
1707
  };
@@ -1304,23 +1712,126 @@ impl Session {
1304
1712
  .stats
1305
1713
  .as_ref()
1306
1714
  .is_some_and(|s| s.num_table_cells.is_some()),
1307
- has_table: if data.table.is_some() {
1715
+ has_table: if data.table().is_some() {
1308
1716
  Some(TableLoadState::Loaded)
1309
- } else if data.is_loading {
1717
+ } else if self.0.queue.has_load() {
1310
1718
  Some(TableLoadState::Loading)
1311
- } else if data.pending_table.is_some() {
1719
+ } else if data.pending_table().is_some() {
1312
1720
  Some(TableLoadState::Pending)
1313
1721
  } else {
1314
1722
  None
1315
1723
  },
1316
- error: data.error.clone(),
1317
- title: data.title.clone(),
1724
+ error: data.error().cloned(),
1725
+ title,
1318
1726
  metadata,
1319
1727
  column_stats,
1320
1728
  }
1321
1729
  }
1322
1730
  }
1323
1731
 
1732
+ /// A view-config change that has passed every check — names, normalization and
1733
+ /// the server's `describe` — and only awaits [`Session::commit_view`].
1734
+ pub(crate) struct PreparedView {
1735
+ binding: PreparedBinding,
1736
+
1737
+ /// A new overlay to commit, when the op broadcasts one.
1738
+ overlay: Option<Rc<Vec<OverlayClause>>>,
1739
+ config: Rc<ViewConfig>,
1740
+ effective: Rc<ViewConfig>,
1741
+ description: Option<Rc<Description>>,
1742
+ changed: bool,
1743
+
1744
+ /// `false` for a projected UI edit, whose SUBMIT already announced it.
1745
+ announce: bool,
1746
+ }
1747
+
1748
+ /// The plugin whose advice fills in what a restore's view config leaves unsaid
1749
+ /// — computed against the table and config the restore LANDS on.
1750
+ pub(crate) enum ViewDefaults<'a> {
1751
+ /// The restore swaps to this plugin: default its columns and rollup mode.
1752
+ Swap(&'a PluginStaticConfig),
1753
+
1754
+ /// The restore stays on this plugin: re-enforce its rollup mode only.
1755
+ Rollup(&'a PluginStaticConfig),
1756
+
1757
+ /// The update is complete as given (a UI edit, whose control applied the
1758
+ /// plugin's advice when it made it).
1759
+ AsGiven,
1760
+ }
1761
+
1762
+ /// What a restore does to the panel's table binding — decided by its caller
1763
+ /// from the restore's `table` and the panel it lands on, with the incoming
1764
+ /// table already probed.
1765
+ pub(crate) enum BindPlan {
1766
+ /// The binding stands: bound, awaiting or unbound, as it is.
1767
+ Keep,
1768
+
1769
+ /// Bind `table`.
1770
+ Bind {
1771
+ client: Client,
1772
+ table: Box<perspective_client::Table>,
1773
+ reset: bool,
1774
+ },
1775
+
1776
+ /// Await a host for `name`.
1777
+ Pend {
1778
+ client: Client,
1779
+ name: String,
1780
+ reset: bool,
1781
+ },
1782
+ }
1783
+
1784
+ enum PreparedBinding {
1785
+ Keep,
1786
+ Bind {
1787
+ client: Client,
1788
+ table: Box<perspective_client::Table>,
1789
+ metadata: Rc<SessionMetadata>,
1790
+ },
1791
+ Pend {
1792
+ client: Client,
1793
+ name: String,
1794
+ },
1795
+ }
1796
+
1797
+ /// What [`Session::commit_view`] leaves for [`Session::finish_binding`].
1798
+ #[must_use]
1799
+ pub(crate) struct BindingEffects {
1800
+ rebinds: bool,
1801
+ outgoing: Option<ViewSubscription>,
1802
+ unloaded: bool,
1803
+ loaded: bool,
1804
+ watch: Option<Client>,
1805
+ }
1806
+
1807
+ impl BindingEffects {
1808
+ /// Whether the commit replaced the panel's table binding.
1809
+ pub fn rebound(&self) -> bool {
1810
+ self.rebinds
1811
+ }
1812
+
1813
+ /// Discard the effects of a commit that kept its binding (there are none).
1814
+ pub fn forget(self) {}
1815
+ }
1816
+
1817
+ impl PreparedView {
1818
+ /// Mark this as a projected UI edit: the UI has shown it since it was
1819
+ /// submitted, so committing it announces nothing.
1820
+ pub fn projected(mut self) -> Self {
1821
+ self.announce = false;
1822
+ self
1823
+ }
1824
+
1825
+ pub fn config(&self) -> &ViewConfig {
1826
+ &self.config
1827
+ }
1828
+
1829
+ /// The type each column will have in the `View` this config builds.
1830
+ pub fn view_schema(&self) -> Option<&HashMap<String, perspective_client::proto::ColumnType>> {
1831
+ self.description.as_ref().map(|x| &x.view_schema)
1832
+ }
1833
+ }
1834
+
1324
1835
  /// One pipeline run's frozen input (invariant I2): the persisted
1325
1836
  /// [`ViewConfig`] and its EFFECTIVE companion (global-filter overlay
1326
1837
  /// appended), captured at the same synchronous instant inside the draw
@@ -1331,10 +1842,11 @@ pub struct ConfigSnapshot {
1331
1842
  pub effective: Rc<ViewConfig>,
1332
1843
  }
1333
1844
 
1334
- /// Type-state token: proof this snapshot's expressions were validated by
1335
- /// [`Session::validate_snapshot`]. [`Session::bind_view`] accepts only this
1336
- /// token, so a `View` can never be built from an unvalidated snapshot.
1337
- pub struct ValidatedSnapshot(ConfigSnapshot);
1845
+ /// Type-state token: proof this snapshot was validated by
1846
+ /// [`Session::validate_snapshot`], carrying the server's [`Description`] of it.
1847
+ pub struct ValidatedSnapshot {
1848
+ snap: ConfigSnapshot,
1849
+ }
1338
1850
 
1339
1851
  /// Type-state witness that a `View` is NEW for the plugin about to render
1340
1852
  /// it.