@happyvertical/smrt-svelte 0.51.4 → 0.51.6

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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `mountListDataSurface` (#2906) — a one-liner to register an existing,
3
+ * hand-rolled list component as a mounted {@link DataSurfaceDescriptor}
4
+ * without adopting the `DataTable` component.
5
+ *
6
+ * `registerContentListDataSurface` in `@happyvertical/smrt-content/svelte`
7
+ * proves the pattern: mirror a headless `DataTableController` into the
8
+ * registry, translate visible commands back into controller dispatches, and
9
+ * bump a monotonic revision whenever the controller or app-owned context
10
+ * state changes. That logic is entangled with ContentList's view-mode
11
+ * concept and `packages/content` has no dependency on this package, so this
12
+ * module is a same-behavior PORT of that translation/registration logic —
13
+ * not a shared import — generalized off the view-mode concept so any custom
14
+ * list markup can register in one call instead of hand-mirroring the
15
+ * registry contract (the exact duplication that motivated this issue: an
16
+ * application page manually driving a headless `DataTableController` and
17
+ * calling `registry.register` itself). The two copies must be kept in sync
18
+ * by hand until they are unified behind a shared implementation in
19
+ * `@happyvertical/smrt-ui/data` (both packages already depend on it) —
20
+ * tracked as a follow-up (#2917). They are NOT currently identical: this
21
+ * copy denies a controlled controller's table command that fails to settle
22
+ * (see `applyControlledState` below), calls `registry.register` before
23
+ * subscribing to the controller to avoid leaking a subscription on a
24
+ * throwing register, and ignores `update()` once `destroy()` has run so a
25
+ * late-resolving page callback cannot resurrect this identity's entry in the
26
+ * registry-scoped revision map after a later mount has taken it over;
27
+ * `registerContentListDataSurface` predates all three fixes. #2917 should
28
+ * adopt them rather than treat this copy as the odd one out.
29
+ *
30
+ * Call during component initialization (top level of `<script>`, or inside
31
+ * an `$effect`); `destroy()` unregisters and unsubscribes, so tearing it down
32
+ * on unmount is the caller's responsibility, exactly like
33
+ * `registerContentListDataSurface`. Nothing here touches `window` or
34
+ * `document` at module scope, so it is safe under `ssr = false` SPAs and
35
+ * during SSR (the registry itself is a plain in-memory object).
36
+ */
37
+ import type { DataSurfaceDescriptor, DataSurfaceJsonValue, DataSurfaceRegistry, DataTableCommand, DataTableControlledStateApplier, DataTableController } from '@happyvertical/smrt-ui/data';
38
+ /**
39
+ * Arbitrary, JSON-safe application state folded into every snapshot.
40
+ *
41
+ * `table` is reserved: the mounted controller's own snapshot is always
42
+ * published under that key, so a context carrying it is rejected at mount
43
+ * and on every `update()` rather than silently discarded. The registry's own
44
+ * boundary-safe check further rejects transport-reserved keys such as
45
+ * `token`, `where`, or `tenantId` — see `boundarySafeObject` in
46
+ * `@happyvertical/smrt-ui/data`.
47
+ */
48
+ export type ListDataSurfaceContext = Readonly<Record<string, DataSurfaceJsonValue>>;
49
+ /**
50
+ * A {@link ListDataSurfaceContext} update: same shape, but a key may also be
51
+ * `undefined` to delete it from the published state (see
52
+ * {@link ListDataSurfaceHandle.update}). Kept distinct from
53
+ * `ListDataSurfaceContext` itself so the initial `context` option — which
54
+ * has no delete affordance — stays exactly JSON-safe.
55
+ */
56
+ export type ListDataSurfaceContextPatch = Readonly<Record<string, DataSurfaceJsonValue | undefined>>;
57
+ export interface ListDataSurfaceControlResult {
58
+ ok: boolean;
59
+ }
60
+ export interface MountListDataSurfaceOptions {
61
+ registry: DataSurfaceRegistry;
62
+ descriptor: DataSurfaceDescriptor;
63
+ /** The headless controller the page already mirrors search/filters/sort/page/selection from. */
64
+ controller: DataTableController;
65
+ /** App-owned state (freshness, fingerprints, …) folded into `state` alongside the table snapshot. */
66
+ context?: ListDataSurfaceContext;
67
+ /** Carries the mounted identity's monotonic revision across re-registration. */
68
+ initialRevision?: number;
69
+ onRevision?: (revision: number) => void;
70
+ /**
71
+ * App-owned constraints that must hold before a visible table command is
72
+ * acknowledged. Returning false denies the command rather than publishing
73
+ * a transient state a later effect would correct.
74
+ */
75
+ acceptsTableCommand?: (command: DataTableCommand) => boolean;
76
+ /**
77
+ * REQUIRED when `controller` is controlled (`controller.isControlled()`):
78
+ * a controlled controller's `dispatch()` only proposes state via its
79
+ * `onStateChange` callback — it never applies it or notifies subscribers,
80
+ * so without this the registry would acknowledge `ok: true` while nothing
81
+ * actually changed. Mirrors `DataTable`'s own controlled-table contract
82
+ * (`DataTableDataSurfaceOptions.applyControlledState`): settle the
83
+ * candidate state (typically by awaiting whatever the page's
84
+ * `onStateChange` triggered) and return the state that was actually
85
+ * applied, or `undefined` to deny. The command is denied whenever the
86
+ * controller's post-settle state does not match what was applied.
87
+ */
88
+ applyControlledState?: DataTableControlledStateApplier;
89
+ /** Non-table visible commands (`refresh`/`retry`/`focus`/`reveal`/`highlight`) the page implements. */
90
+ refresh?: () => boolean | Promise<boolean>;
91
+ retry?: () => boolean | Promise<boolean>;
92
+ focus?: () => void;
93
+ reveal?: () => void;
94
+ highlight?: () => void;
95
+ /**
96
+ * Escape hatch for custom controls beyond the fixed set above — a page can
97
+ * expose any additional `controlId` its markup understands, PROVIDED it is
98
+ * not one of `DATA_TABLE_SURFACE_CONTROL_IDS` (`@happyvertical/smrt-ui/data`,
99
+ * e.g. `set-filters`, `reset`, `set-page`): those ids are always
100
+ * intercepted first (translated and dispatched to `controller`, or denied
101
+ * outright if the payload fails to translate — see `TABLE_CONTROL_IDS`
102
+ * below) and never reach `onControl`, even if a descriptor declares one of
103
+ * them with a custom label. A descriptor control id must avoid that set to
104
+ * be reachable here. Only invoked when the command's `controlId` is
105
+ * neither a fixed control (`refresh`/`retry`/`focus`/`reveal`/`highlight`
106
+ * — a fixed control with no matching callback is denied directly, never
107
+ * forwarded here) nor a table-control id. Returning `false`/`{ ok: false }`
108
+ * denies the command; returning `true`, `{ ok: true }`, or nothing
109
+ * (`void`) is treated as success. A thrown error propagates out of
110
+ * `execute` and the registry reports it as `execution_failed` — it is
111
+ * never silently treated as success.
112
+ */
113
+ onControl?: (controlId: string, payload: DataSurfaceJsonValue | undefined) => boolean | void | ListDataSurfaceControlResult | Promise<boolean | void | ListDataSurfaceControlResult>;
114
+ }
115
+ export interface ListDataSurfaceHandle {
116
+ /**
117
+ * Merge new app-owned context state over what is already published,
118
+ * bumping the revision if the result changed. Keys `next` does not
119
+ * mention are retained; pass a key explicitly as `undefined` to drop it.
120
+ */
121
+ update(context: ListDataSurfaceContextPatch): void;
122
+ /** Unsubscribe from the controller and unregister from the registry. */
123
+ destroy(): void;
124
+ }
125
+ /**
126
+ * Register an existing, headless-controller-backed list as a mounted
127
+ * `DataSurfaceDescriptor`. Mirrors `registerContentListDataSurface`'s
128
+ * contract (stable identity independent of any view-mode churn, monotonic
129
+ * per-identity revision, visible-command translation) without requiring the
130
+ * ContentList view-mode concept, so any custom list can adopt it directly.
131
+ */
132
+ export declare function mountListDataSurface(options: MountListDataSurfaceOptions): ListDataSurfaceHandle;
133
+ //# sourceMappingURL=list-data-surface.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"list-data-surface.svelte.d.ts","sourceRoot":"","sources":["../../src/web/list-data-surface.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,KAAK,EACV,qBAAqB,EAErB,oBAAoB,EACpB,mBAAmB,EAGnB,gBAAgB,EAChB,+BAA+B,EAC/B,mBAAmB,EAEpB,MAAM,6BAA6B,CAAC;AAOrC;;;;;;;;;GASG;AACH,MAAM,MAAM,sBAAsB,GAAG,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,oBAAoB,CAAC,CACrC,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,MAAM,2BAA2B,GAAG,QAAQ,CAChD,MAAM,CAAC,MAAM,EAAE,oBAAoB,GAAG,SAAS,CAAC,CACjD,CAAC;AAEF,MAAM,WAAW,4BAA4B;IAC3C,EAAE,EAAE,OAAO,CAAC;CACb;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,UAAU,EAAE,qBAAqB,CAAC;IAClC,gGAAgG;IAChG,UAAU,EAAE,mBAAmB,CAAC;IAChC,qGAAqG;IACrG,OAAO,CAAC,EAAE,sBAAsB,CAAC;IACjC,gFAAgF;IAChF,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC;IAC7D;;;;;;;;;;;OAWG;IACH,oBAAoB,CAAC,EAAE,+BAA+B,CAAC;IACvD,uGAAuG;IACvG,OAAO,CAAC,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3C,KAAK,CAAC,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,EAAE,CACV,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,oBAAoB,GAAG,SAAS,KAEvC,OAAO,GACP,IAAI,GACJ,4BAA4B,GAC5B,OAAO,CAAC,OAAO,GAAG,IAAI,GAAG,4BAA4B,CAAC,CAAC;CAC5D;AAED,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,MAAM,CAAC,OAAO,EAAE,2BAA2B,GAAG,IAAI,CAAC;IACnD,wEAAwE;IACxE,OAAO,IAAI,IAAI,CAAC;CACjB;AAuID;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,2BAA2B,GACnC,qBAAqB,CAuKvB"}
@@ -0,0 +1,308 @@
1
+ /**
2
+ * `mountListDataSurface` (#2906) — a one-liner to register an existing,
3
+ * hand-rolled list component as a mounted {@link DataSurfaceDescriptor}
4
+ * without adopting the `DataTable` component.
5
+ *
6
+ * `registerContentListDataSurface` in `@happyvertical/smrt-content/svelte`
7
+ * proves the pattern: mirror a headless `DataTableController` into the
8
+ * registry, translate visible commands back into controller dispatches, and
9
+ * bump a monotonic revision whenever the controller or app-owned context
10
+ * state changes. That logic is entangled with ContentList's view-mode
11
+ * concept and `packages/content` has no dependency on this package, so this
12
+ * module is a same-behavior PORT of that translation/registration logic —
13
+ * not a shared import — generalized off the view-mode concept so any custom
14
+ * list markup can register in one call instead of hand-mirroring the
15
+ * registry contract (the exact duplication that motivated this issue: an
16
+ * application page manually driving a headless `DataTableController` and
17
+ * calling `registry.register` itself). The two copies must be kept in sync
18
+ * by hand until they are unified behind a shared implementation in
19
+ * `@happyvertical/smrt-ui/data` (both packages already depend on it) —
20
+ * tracked as a follow-up (#2917). They are NOT currently identical: this
21
+ * copy denies a controlled controller's table command that fails to settle
22
+ * (see `applyControlledState` below), calls `registry.register` before
23
+ * subscribing to the controller to avoid leaking a subscription on a
24
+ * throwing register, and ignores `update()` once `destroy()` has run so a
25
+ * late-resolving page callback cannot resurrect this identity's entry in the
26
+ * registry-scoped revision map after a later mount has taken it over;
27
+ * `registerContentListDataSurface` predates all three fixes. #2917 should
28
+ * adopt them rather than treat this copy as the odd one out.
29
+ *
30
+ * Call during component initialization (top level of `<script>`, or inside
31
+ * an `$effect`); `destroy()` unregisters and unsubscribes, so tearing it down
32
+ * on unmount is the caller's responsibility, exactly like
33
+ * `registerContentListDataSurface`. Nothing here touches `window` or
34
+ * `document` at module scope, so it is safe under `ssr = false` SPAs and
35
+ * during SSR (the registry itself is a plain in-memory object).
36
+ */
37
+ import { DATA_TABLE_SURFACE_CONTROL_IDS, dataTableCommandFromDataSurfaceCommand, dataTableRowIdKey, } from '@happyvertical/smrt-ui/data';
38
+ const revisionsByRegistry = new WeakMap();
39
+ function identityKey(descriptor) {
40
+ const { identity } = descriptor;
41
+ return JSON.stringify([
42
+ identity.kind,
43
+ identity.surfaceId,
44
+ identity.subject?.type,
45
+ identity.subject?.id,
46
+ ]);
47
+ }
48
+ function selectionReference(selection) {
49
+ if (selection.scope === 'page')
50
+ return { scope: 'current-page' };
51
+ if (selection.scope === 'allMatching') {
52
+ return {
53
+ scope: 'all-matching',
54
+ queryFingerprint: selection.queryFingerprint,
55
+ };
56
+ }
57
+ return selection.rowIds.length > 0
58
+ ? { scope: 'explicit-ids', rowIds: selection.rowIds }
59
+ : null;
60
+ }
61
+ const RESERVED_CONTEXT_KEYS = ['table'];
62
+ /**
63
+ * A command declaring a `controlId` from the canonical
64
+ * `DATA_TABLE_SURFACE_CONTROL_IDS` (`@happyvertical/smrt-ui/data`) that still
65
+ * fails to translate (an unparseable payload — e.g. `set-filters` with a
66
+ * non-array `filters`) must be denied directly, never forwarded to
67
+ * `onControl`: the descriptor and `acceptsTableCommand` gate never ran for
68
+ * it, so treating it as a generic custom control would let an `onControl`
69
+ * catch-all silently acknowledge a table mutation that was never applied.
70
+ * Sourced from the same upstream enumeration `dataTableCommandFromDataSurfaceCommand`
71
+ * is derived from — never a local copy of the id list — so an upstream
72
+ * addition to that switch is covered here automatically.
73
+ */
74
+ const TABLE_CONTROL_IDS = new Set(DATA_TABLE_SURFACE_CONTROL_IDS);
75
+ function assertNoReservedContextKeys(context) {
76
+ for (const key of RESERVED_CONTEXT_KEYS) {
77
+ if (key in context) {
78
+ throw new TypeError(`ListDataSurfaceContext must not use the reserved key "${key}" — the mounted controller's own snapshot is always published under it.`);
79
+ }
80
+ }
81
+ }
82
+ function allowsFilterOperator(column, operator) {
83
+ if (!column)
84
+ return false;
85
+ const canonical = column.operators?.filter;
86
+ const alias = column.filterOperators;
87
+ if (canonical && alias)
88
+ return canonical.includes(operator) && alias.includes(operator);
89
+ return canonical?.includes(operator) ?? alias?.includes(operator) ?? false;
90
+ }
91
+ function commandAllowed(command, descriptor, controller) {
92
+ const readable = new Set(descriptor.columns
93
+ .filter((column) => column.capabilities.includes('read'))
94
+ .map((column) => column.id));
95
+ const filterable = new Set(descriptor.query.filterableColumnIds ?? []);
96
+ const sortable = new Set(descriptor.query.sortableColumnIds ?? []);
97
+ switch (command.type) {
98
+ case 'setFilters':
99
+ return command.filters.every((filter) => {
100
+ const column = descriptor.columns.find((candidate) => candidate.id === filter.columnId);
101
+ return (filterable.has(filter.columnId) &&
102
+ allowsFilterOperator(column, filter.operator));
103
+ });
104
+ case 'setSorting':
105
+ return command.sorting.every((sort) => sortable.has(sort.columnId));
106
+ case 'toggleSorting':
107
+ return sortable.has(command.columnId);
108
+ case 'setSelectedRows':
109
+ return command.rowIds.length <= descriptor.limits.maxSelectionSize;
110
+ case 'toggleRowSelection': {
111
+ const selection = controller.snapshot().state.selection;
112
+ if (selection.scope === 'allMatching')
113
+ return false;
114
+ const selected = selection.rowIds.some((rowId) => dataTableRowIdKey(rowId) === dataTableRowIdKey(command.rowId));
115
+ return (selected || selection.rowIds.length < descriptor.limits.maxSelectionSize);
116
+ }
117
+ case 'setPageSize':
118
+ return (command.pageSize === null ||
119
+ (Number.isSafeInteger(command.pageSize) && command.pageSize > 0));
120
+ case 'setColumnOrder':
121
+ return command.columnIds.every((columnId) => readable.has(columnId));
122
+ case 'setColumnVisibility':
123
+ return command.columns.every((column) => readable.has(column.columnId));
124
+ default:
125
+ return true;
126
+ }
127
+ }
128
+ /** `undefined` (a `void` return) means the handler ran and succeeded. */
129
+ function normalizeControlResult(result) {
130
+ if (result === undefined)
131
+ return { ok: true };
132
+ if (typeof result === 'boolean')
133
+ return { ok: result };
134
+ return { ok: result.ok };
135
+ }
136
+ /**
137
+ * Register an existing, headless-controller-backed list as a mounted
138
+ * `DataSurfaceDescriptor`. Mirrors `registerContentListDataSurface`'s
139
+ * contract (stable identity independent of any view-mode churn, monotonic
140
+ * per-identity revision, visible-command translation) without requiring the
141
+ * ContentList view-mode concept, so any custom list can adopt it directly.
142
+ */
143
+ export function mountListDataSurface(options) {
144
+ let revisions = revisionsByRegistry.get(options.registry);
145
+ if (!revisions) {
146
+ revisions = new Map();
147
+ revisionsByRegistry.set(options.registry, revisions);
148
+ }
149
+ const key = identityKey(options.descriptor);
150
+ const previousRevision = revisions.get(key);
151
+ let revision = Math.max(options.initialRevision ?? 0, previousRevision === undefined ? 0 : previousRevision + 1);
152
+ // Do NOT commit `revision` to the shared per-identity map, or subscribe to
153
+ // the controller, until `registry.register` below succeeds. Both a bad
154
+ // descriptor (duplicate identity, unknown column/control ids, …) and a
155
+ // reserved context key throw synchronously; committing shared state first
156
+ // would leave an orphaned controller subscriber writing a phantom revision
157
+ // counter for an identity this call never actually owns.
158
+ let context = { ...(options.context ?? {}) };
159
+ assertNoReservedContextKeys(context);
160
+ let contextSignature = JSON.stringify(context);
161
+ const advanceRevision = () => {
162
+ revision += 1;
163
+ revisions.set(key, revision);
164
+ options.onRevision?.(revision);
165
+ };
166
+ let destroyed = false;
167
+ const updateContext = (next) => {
168
+ // A page callback captured before unmount (e.g. an async `refresh` that
169
+ // calls `handle.update()` after its promise resolves) must not resurrect
170
+ // this identity's entry in the registry-scoped, cross-mount `revisions`
171
+ // map after `destroy()` — doing so would corrupt the monotonic revision
172
+ // a LATER, unrelated mount of the same identity seeds from.
173
+ if (destroyed)
174
+ return;
175
+ // Genuinely "fold in": keys already published that `next` does not
176
+ // mention are retained, matching this function's own documented
177
+ // contract. A caller that wants a key gone passes it explicitly as
178
+ // `undefined`; the registry rejects a literal `undefined` value (it is
179
+ // not JSON-safe), so that case deletes the key outright instead.
180
+ const draft = {
181
+ ...context,
182
+ ...next,
183
+ };
184
+ for (const patchKey of Object.keys(next)) {
185
+ if (next[patchKey] === undefined)
186
+ delete draft[patchKey];
187
+ }
188
+ const merged = draft;
189
+ assertNoReservedContextKeys(merged);
190
+ // `merged` may still carry a transport-reserved key (`tenantId`, `token`,
191
+ // `where`, …) that only the registry's own boundary-safety check knows
192
+ // about (`FORBIDDEN_BOUNDARY_KEYS` in `@happyvertical/smrt-ui/data`,
193
+ // in-repo upstream — no local copy of that list here). Validate eagerly
194
+ // by forcing the same read `registry.register` already performed at
195
+ // mount, so a bad key throws synchronously at THIS call site instead of
196
+ // being committed and only failing later on an unrelated `inspect()`/
197
+ // `execute()` call against the now-poisoned surface. Roll back on
198
+ // failure so the surface is left exactly as it was.
199
+ const previousContext = context;
200
+ context = merged;
201
+ try {
202
+ options.registry.inspect(options.descriptor.identity);
203
+ }
204
+ catch (error) {
205
+ context = previousContext;
206
+ throw error;
207
+ }
208
+ const signature = JSON.stringify(merged);
209
+ if (signature !== contextSignature) {
210
+ advanceRevision();
211
+ contextSignature = signature;
212
+ }
213
+ };
214
+ const unregister = options.registry.register({
215
+ descriptor: options.descriptor,
216
+ getSnapshot: () => {
217
+ const table = options.controller.snapshot();
218
+ return {
219
+ revision,
220
+ state: {
221
+ ...context,
222
+ table: table,
223
+ },
224
+ selection: selectionReference(table.state.selection),
225
+ };
226
+ },
227
+ execute: async (command) => {
228
+ const tableCommand = dataTableCommandFromDataSurfaceCommand(command);
229
+ if (tableCommand) {
230
+ if (!commandAllowed(tableCommand, options.descriptor, options.controller) ||
231
+ options.acceptsTableCommand?.(tableCommand) === false)
232
+ return { ok: false };
233
+ const transition = options.controller.dispatch(tableCommand);
234
+ if (options.controller.isControlled() && transition.changed) {
235
+ // A controlled controller's dispatch() only proposed state via
236
+ // onStateChange — it never applied it. Settle it (or deny) exactly
237
+ // like DataTable's own controlled-table contract.
238
+ const settled = await options.applyControlledState?.(transition.next.state, tableCommand);
239
+ if (settled)
240
+ options.controller.replaceState(settled);
241
+ if (JSON.stringify(options.controller.getState()) !==
242
+ JSON.stringify(transition.next.state)) {
243
+ return { ok: false };
244
+ }
245
+ }
246
+ return;
247
+ }
248
+ switch (command.controlId) {
249
+ case 'refresh':
250
+ if (!options.refresh)
251
+ return { ok: false };
252
+ if ((await options.refresh()) === false)
253
+ return { ok: false };
254
+ return;
255
+ case 'retry':
256
+ if (!options.retry)
257
+ return { ok: false };
258
+ if ((await options.retry()) === false)
259
+ return { ok: false };
260
+ return;
261
+ case 'focus':
262
+ if (!options.focus)
263
+ return { ok: false };
264
+ options.focus();
265
+ return;
266
+ case 'reveal':
267
+ if (!options.reveal)
268
+ return { ok: false };
269
+ options.reveal();
270
+ return;
271
+ case 'highlight':
272
+ if (!options.highlight)
273
+ return { ok: false };
274
+ options.highlight();
275
+ return;
276
+ default: {
277
+ // A declared table-control id that failed to translate (e.g. an
278
+ // unparseable payload) must be denied directly — it never reached
279
+ // `commandAllowed`/`acceptsTableCommand`, so treating it as a
280
+ // generic custom control would let `onControl` silently
281
+ // acknowledge a table mutation that was never applied.
282
+ if (TABLE_CONTROL_IDS.has(command.controlId))
283
+ return { ok: false };
284
+ if (!options.onControl)
285
+ return { ok: false };
286
+ const result = normalizeControlResult(await options.onControl(command.controlId, command.payload));
287
+ return result.ok ? undefined : { ok: false };
288
+ }
289
+ }
290
+ },
291
+ });
292
+ // Registration succeeded — now, and only now, commit the shared per-identity
293
+ // revision and subscribe to the controller (see the note above).
294
+ revisions.set(key, revision);
295
+ const unsubscribe = options.controller.subscribe((transition) => {
296
+ if (transition.changed)
297
+ advanceRevision();
298
+ });
299
+ options.onRevision?.(revision);
300
+ return {
301
+ update: updateContext,
302
+ destroy() {
303
+ destroyed = true;
304
+ unsubscribe();
305
+ unregister();
306
+ },
307
+ };
308
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-svelte",
3
- "version": "0.51.4",
3
+ "version": "0.51.6",
4
4
  "smrtJsdoc": "strict",
5
5
  "description": "Svelte 5 components for SMRT user management - auth, users, tenants, roles, permissions, groups",
6
6
  "type": "module",
@@ -121,10 +121,10 @@
121
121
  },
122
122
  "dependencies": {
123
123
  "@happyvertical/logger": "^0.89.11",
124
- "@happyvertical/smrt-languages": "0.51.4",
125
- "@happyvertical/smrt-types": "0.51.4",
126
- "@happyvertical/smrt-ui": "0.51.4",
127
- "@happyvertical/smrt-web": "0.51.4",
124
+ "@happyvertical/smrt-languages": "0.51.6",
125
+ "@happyvertical/smrt-types": "0.51.6",
126
+ "@happyvertical/smrt-ui": "0.51.6",
127
+ "@happyvertical/smrt-web": "0.51.6",
128
128
  "@tanstack/db": "^0.6.14",
129
129
  "@tanstack/svelte-db": "^0.1.91",
130
130
  "esm-env": "^1.2.2"
@@ -155,14 +155,14 @@
155
155
  }
156
156
  },
157
157
  "devDependencies": {
158
- "@happyvertical/smrt-agents": "0.51.4",
159
- "@happyvertical/smrt-chat": "0.51.4",
160
- "@happyvertical/smrt-content": "0.51.4",
161
- "@happyvertical/smrt-core": "0.51.4",
162
- "@happyvertical/smrt-reports": "0.51.4",
163
- "@happyvertical/smrt-scanner": "0.51.4",
164
- "@happyvertical/smrt-tenancy": "0.51.4",
165
- "@happyvertical/smrt-users": "0.51.4",
158
+ "@happyvertical/smrt-agents": "0.51.6",
159
+ "@happyvertical/smrt-chat": "0.51.6",
160
+ "@happyvertical/smrt-content": "0.51.6",
161
+ "@happyvertical/smrt-core": "0.51.6",
162
+ "@happyvertical/smrt-reports": "0.51.6",
163
+ "@happyvertical/smrt-scanner": "0.51.6",
164
+ "@happyvertical/smrt-tenancy": "0.51.6",
165
+ "@happyvertical/smrt-users": "0.51.6",
166
166
  "@sveltejs/package": "^2.5.8",
167
167
  "@sveltejs/vite-plugin-svelte": "^7.1.2",
168
168
  "@testing-library/jest-dom": "^6.9.1",