@fuzdev/fuz_app 0.60.0 → 0.62.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 (63) hide show
  1. package/dist/actions/CLAUDE.md +28 -22
  2. package/dist/auth/CLAUDE.md +4 -4
  3. package/dist/server/app_server.d.ts +54 -6
  4. package/dist/server/app_server.d.ts.map +1 -1
  5. package/dist/server/app_server.js +32 -4
  6. package/dist/testing/CLAUDE.md +8 -8
  7. package/dist/ui/AccountSessions.svelte +21 -6
  8. package/dist/ui/AccountSessions.svelte.d.ts.map +1 -1
  9. package/dist/ui/AdminAccounts.svelte +32 -25
  10. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  11. package/dist/ui/AdminAuditLog.svelte +3 -3
  12. package/dist/ui/AdminInvites.svelte +20 -15
  13. package/dist/ui/AdminOverview.svelte +19 -21
  14. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  15. package/dist/ui/AdminRoleGrantHistory.svelte +3 -3
  16. package/dist/ui/AdminSessions.svelte +19 -21
  17. package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
  18. package/dist/ui/AdminSettings.svelte +1 -3
  19. package/dist/ui/AdminSettings.svelte.d.ts.map +1 -1
  20. package/dist/ui/CLAUDE.md +123 -69
  21. package/dist/ui/ConfirmButton.svelte +82 -24
  22. package/dist/ui/ConfirmButton.svelte.d.ts +8 -34
  23. package/dist/ui/ConfirmButton.svelte.d.ts.map +1 -1
  24. package/dist/ui/OpenSignupToggle.svelte +6 -4
  25. package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
  26. package/dist/ui/RoleGrantOfferForm.svelte +4 -4
  27. package/dist/ui/RoleGrantOfferHistory.svelte +3 -3
  28. package/dist/ui/RoleGrantOfferInbox.svelte +10 -6
  29. package/dist/ui/RoleGrantOfferInbox.svelte.d.ts.map +1 -1
  30. package/dist/ui/account_sessions_state.svelte.d.ts +17 -7
  31. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  32. package/dist/ui/account_sessions_state.svelte.js +32 -33
  33. package/dist/ui/admin_accounts_state.svelte.d.ts +48 -17
  34. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  35. package/dist/ui/admin_accounts_state.svelte.js +58 -76
  36. package/dist/ui/admin_invites_state.svelte.d.ts +14 -7
  37. package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
  38. package/dist/ui/admin_invites_state.svelte.js +32 -48
  39. package/dist/ui/admin_sessions_state.svelte.d.ts +15 -8
  40. package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
  41. package/dist/ui/admin_sessions_state.svelte.js +30 -47
  42. package/dist/ui/app_settings_state.svelte.d.ts +8 -3
  43. package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
  44. package/dist/ui/app_settings_state.svelte.js +19 -27
  45. package/dist/ui/async_slot.svelte.d.ts +173 -0
  46. package/dist/ui/async_slot.svelte.d.ts.map +1 -0
  47. package/dist/ui/async_slot.svelte.js +241 -0
  48. package/dist/ui/audit_log_state.svelte.d.ts +8 -2
  49. package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
  50. package/dist/ui/audit_log_state.svelte.js +19 -18
  51. package/dist/ui/keyed_async_slot.svelte.d.ts +139 -0
  52. package/dist/ui/keyed_async_slot.svelte.d.ts.map +1 -0
  53. package/dist/ui/keyed_async_slot.svelte.js +177 -0
  54. package/dist/ui/role_grant_offers_state.svelte.d.ts +39 -7
  55. package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -1
  56. package/dist/ui/role_grant_offers_state.svelte.js +34 -15
  57. package/dist/ui/table_state.svelte.d.ts +10 -7
  58. package/dist/ui/table_state.svelte.d.ts.map +1 -1
  59. package/dist/ui/table_state.svelte.js +11 -8
  60. package/package.json +1 -1
  61. package/dist/ui/loadable.svelte.d.ts +0 -60
  62. package/dist/ui/loadable.svelte.d.ts.map +0 -1
  63. package/dist/ui/loadable.svelte.js +0 -80
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Composable async-operation slot for Svelte 5 reactive state classes.
3
+ *
4
+ * A state class HOLDS one or more `AsyncSlot`s via composition — one slot
5
+ * per distinct async operation (e.g. `list` + `create` + `revoke`). Each
6
+ * slot tracks the status, payload, and error of its operation
7
+ * independently, so state classes with multiple write paths don't accumulate
8
+ * ad-hoc `creating` / `updating` fields beside a single shared
9
+ * `loading` / `error` pair.
10
+ *
11
+ * Core surface:
12
+ *
13
+ * - **Explicit four-value `status`** — `AsyncStatus` from
14
+ * `@fuzdev/fuz_util/async.js`: `'initial' | 'pending' | 'success' |
15
+ * 'failure'`. `loading: false, error: null` would be ambiguous
16
+ * between "never tried" and "succeeded once and now resting"; the
17
+ * four-value status removes the need for a per-class `submitted` /
18
+ * `hydrated` flag.
19
+ * - **Owns `data: T | undefined`** — the success payload persists
20
+ * across retries (stale-while-revalidate). The sentinel is
21
+ * `undefined` (not `null`) so `null` stays available as a legitimate
22
+ * success value for nullable `T`s. Pass `T = void` for write-only
23
+ * actions whose response isn't worth keeping.
24
+ * - **Supersession via internal `AbortController`** — a second `run()`
25
+ * aborts the first, and superseded results are silently discarded
26
+ * without writing to state. Removes the "in-flight call resolves
27
+ * after the locator advanced" race that locator-style state classes
28
+ * would otherwise need to compensate for.
29
+ * - **`AbortSignal` threaded to the callback** — RPC clients that accept
30
+ * a signal (or `fetch`) get cancellation for free; callers can also
31
+ * pass an external `signal` via {@link RunOptions} to bind the slot's
32
+ * lifetime to a component / page.
33
+ * - **`preserve_error_on_retry`** — opt-in to keeping the previous error
34
+ * visible while a retry is pending (default clears at the start of
35
+ * each `run()`).
36
+ * - **Per-slot `map_error`** — set once in the constructor
37
+ * (`{map_error: to_rpc_error_message}`); every `run()` gets the right
38
+ * normalization without re-passing per call.
39
+ * - **Public `run()`** — slots are composed, not subclassed, so call
40
+ * sites can invoke `state.list.run(...)` directly.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * class CellsState {
45
+ * readonly list = new AsyncSlot<{cells: ReadonlyArray<CellJson>}>();
46
+ * readonly create = new AsyncSlot<{cell: CellJson}>({map_error: to_rpc_error_message});
47
+ *
48
+ * async fetch() {
49
+ * await this.list.run((signal) => this.#api.cell_list({}, {signal}));
50
+ * }
51
+ *
52
+ * async submit_new(input: CellCreateInput) {
53
+ * const result = await this.create.run(() => this.#api.cell_create(input));
54
+ * if (result) await this.fetch();
55
+ * }
56
+ * }
57
+ * ```
58
+ *
59
+ * @module
60
+ */
61
+ import type { AsyncStatus } from '@fuzdev/fuz_util/async.js';
62
+ export interface AsyncSlotOptions<T, E = string> {
63
+ /**
64
+ * Seed `data` and put the slot in `'success'` before any `run()`. Useful
65
+ * when the page already has the resource in hand (SSR hydration, a
66
+ * mutation response, hand-off from a parent slot).
67
+ */
68
+ initial?: T;
69
+ /**
70
+ * Convert a caught throw into the error value stored in
71
+ * {@link AsyncSlot.error}. Default extracts `Error.message` (falling
72
+ * back to `'Request failed'` for non-Error throws). Pass
73
+ * `to_rpc_error_message` to unwrap JSON-RPC `data.reason` codes.
74
+ */
75
+ map_error?: (e: unknown) => E;
76
+ /**
77
+ * When `true`, the previous `error` / `error_data` survive the start
78
+ * of a new `run()` until the next success (or another failure
79
+ * overwrites them). Useful for retry UX that wants to keep the
80
+ * failure message visible alongside an inline spinner. Default `false`
81
+ * — `run()` clears the error at the start so the pending state reads
82
+ * "no current error."
83
+ */
84
+ preserve_error_on_retry?: boolean;
85
+ }
86
+ export interface RunOptions {
87
+ /**
88
+ * External signal chained into the slot's internal controller. Aborts
89
+ * the in-flight run when fired (alongside automatic supersession by
90
+ * the next `run()` and manual {@link AsyncSlot.abort} calls).
91
+ */
92
+ signal?: AbortSignal;
93
+ }
94
+ /**
95
+ * Reactive container for a single async operation.
96
+ *
97
+ * @typeParam T - The success payload type. Use `void` for write-only
98
+ * actions whose response isn't worth retaining.
99
+ * @typeParam E - The shape of {@link AsyncSlot.error}. Defaults to
100
+ * `string` (set by the default `map_error`). Narrow to a structured
101
+ * type by providing a `map_error` that returns it.
102
+ */
103
+ export declare class AsyncSlot<T = void, E = string> {
104
+ #private;
105
+ status: AsyncStatus;
106
+ data: T | undefined;
107
+ error: E | null;
108
+ /** The raw caught value from the last failed `run()`, for programmatic inspection. */
109
+ error_data: unknown;
110
+ /** Convenience derived: `status === 'initial'`. */
111
+ readonly initial: boolean;
112
+ /** Convenience derived: `status === 'pending'`. */
113
+ readonly loading: boolean;
114
+ /** Convenience derived: `status === 'success'`. */
115
+ readonly succeeded: boolean;
116
+ /** Convenience derived: `status === 'failure'`. */
117
+ readonly failed: boolean;
118
+ constructor(options?: AsyncSlotOptions<T, E>);
119
+ /**
120
+ * Run an async operation. The callback receives an `AbortSignal` it
121
+ * can forward to fetch / RPC clients that support cancellation; the
122
+ * slot also discards superseded results internally even if the
123
+ * callback ignores the signal.
124
+ *
125
+ * Supersession rule: a second `run()` aborts the first's signal AND
126
+ * silently drops its commit if it resolves anyway. So
127
+ * back-to-back-to-back `run()` calls leave only the last call's
128
+ * result in `data`.
129
+ *
130
+ * Abort rule: a `run()` that throws because of its own signal (manual
131
+ * `abort()`, external `options.signal`, OR supersession by another
132
+ * `run()`) does NOT promote to `'failure'`. Manual / external aborts
133
+ * revert status to the previous resolved state (`'initial'` if no
134
+ * `run()` has ever succeeded, `'success'` otherwise). Supersession is
135
+ * handled by the bail-on-mismatch check, leaving the second run's
136
+ * `'pending'` standing.
137
+ *
138
+ * @returns the resolved value on success; `undefined` on failure,
139
+ * abort, or supersession
140
+ */
141
+ run(fn: (signal: AbortSignal) => Promise<T>, options?: RunOptions): Promise<T | undefined>;
142
+ /**
143
+ * Manually abort the in-flight run, if any. Reverts `status`
144
+ * synchronously to the prior resolved state — `'initial'` if no
145
+ * `run()` (or `set()`) has ever succeeded on this slot, `'success'`
146
+ * otherwise. The aborted run's eventual resolution / rejection is
147
+ * dropped without writing to state (the run's `Promise` resolves to
148
+ * `undefined`).
149
+ */
150
+ abort(reason?: unknown): void;
151
+ /**
152
+ * Replace `data` directly and mark the slot `'success'`. For
153
+ * post-mutation hydration where the calling RPC already returned the
154
+ * canonical row (parallels `CellState.set_cell`).
155
+ *
156
+ * Aborts any in-flight `run()` first — without this, the in-flight
157
+ * callback could resolve after `set()` and overwrite the explicit
158
+ * value (the bail-on-mismatch check only fires when `#controller`
159
+ * was rotated).
160
+ *
161
+ * @mutates `this`
162
+ */
163
+ set(data: T): void;
164
+ /**
165
+ * Reset to `'initial'`, clear `data` / `error` / `error_data`, and
166
+ * abort any in-flight run. After `reset()` the slot looks like a
167
+ * fresh instance with no `initial` option.
168
+ *
169
+ * @mutates `this`
170
+ */
171
+ reset(): void;
172
+ }
173
+ //# sourceMappingURL=async_slot.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"async_slot.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/async_slot.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AAEH,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,2BAA2B,CAAC;AAE3D,MAAM,WAAW,gBAAgB,CAAC,CAAC,EAAE,CAAC,GAAG,MAAM;IAC9C;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,CAAC,CAAC;IAC9B;;;;;;;OAOG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,WAAW,UAAU;IAC1B;;;;OAIG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED;;;;;;;;GAQG;AACH,qBAAa,SAAS,CAAC,CAAC,GAAG,IAAI,EAAE,CAAC,GAAG,MAAM;;IAC1C,MAAM,EAAE,WAAW,CAAyB;IAC5C,IAAI,EAAE,CAAC,GAAG,SAAS,CAAwC;IAC3D,KAAK,EAAE,CAAC,GAAG,IAAI,CAAoB;IACnC,sFAAsF;IACtF,UAAU,EAAE,OAAO,CAAoB;IAEvC,mDAAmD;IACnD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAuC;IAChE,mDAAmD;IACnD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAuC;IAChE,mDAAmD;IACnD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAuC;IAClE,mDAAmD;IACnD,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAuC;gBAcnD,OAAO,GAAE,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAM;IAUhD;;;;;;;;;;;;;;;;;;;;;OAqBG;IACG,GAAG,CACR,EAAE,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,EACvC,OAAO,GAAE,UAAe,GACtB,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;IA8DzB;;;;;;;OAOG;IACH,KAAK,CAAC,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI;IAM7B;;;;;;;;;;;OAWG;IACH,GAAG,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI;IASlB;;;;;;OAMG;IACH,KAAK,IAAI,IAAI;CAQb"}
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Composable async-operation slot for Svelte 5 reactive state classes.
3
+ *
4
+ * A state class HOLDS one or more `AsyncSlot`s via composition — one slot
5
+ * per distinct async operation (e.g. `list` + `create` + `revoke`). Each
6
+ * slot tracks the status, payload, and error of its operation
7
+ * independently, so state classes with multiple write paths don't accumulate
8
+ * ad-hoc `creating` / `updating` fields beside a single shared
9
+ * `loading` / `error` pair.
10
+ *
11
+ * Core surface:
12
+ *
13
+ * - **Explicit four-value `status`** — `AsyncStatus` from
14
+ * `@fuzdev/fuz_util/async.js`: `'initial' | 'pending' | 'success' |
15
+ * 'failure'`. `loading: false, error: null` would be ambiguous
16
+ * between "never tried" and "succeeded once and now resting"; the
17
+ * four-value status removes the need for a per-class `submitted` /
18
+ * `hydrated` flag.
19
+ * - **Owns `data: T | undefined`** — the success payload persists
20
+ * across retries (stale-while-revalidate). The sentinel is
21
+ * `undefined` (not `null`) so `null` stays available as a legitimate
22
+ * success value for nullable `T`s. Pass `T = void` for write-only
23
+ * actions whose response isn't worth keeping.
24
+ * - **Supersession via internal `AbortController`** — a second `run()`
25
+ * aborts the first, and superseded results are silently discarded
26
+ * without writing to state. Removes the "in-flight call resolves
27
+ * after the locator advanced" race that locator-style state classes
28
+ * would otherwise need to compensate for.
29
+ * - **`AbortSignal` threaded to the callback** — RPC clients that accept
30
+ * a signal (or `fetch`) get cancellation for free; callers can also
31
+ * pass an external `signal` via {@link RunOptions} to bind the slot's
32
+ * lifetime to a component / page.
33
+ * - **`preserve_error_on_retry`** — opt-in to keeping the previous error
34
+ * visible while a retry is pending (default clears at the start of
35
+ * each `run()`).
36
+ * - **Per-slot `map_error`** — set once in the constructor
37
+ * (`{map_error: to_rpc_error_message}`); every `run()` gets the right
38
+ * normalization without re-passing per call.
39
+ * - **Public `run()`** — slots are composed, not subclassed, so call
40
+ * sites can invoke `state.list.run(...)` directly.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * class CellsState {
45
+ * readonly list = new AsyncSlot<{cells: ReadonlyArray<CellJson>}>();
46
+ * readonly create = new AsyncSlot<{cell: CellJson}>({map_error: to_rpc_error_message});
47
+ *
48
+ * async fetch() {
49
+ * await this.list.run((signal) => this.#api.cell_list({}, {signal}));
50
+ * }
51
+ *
52
+ * async submit_new(input: CellCreateInput) {
53
+ * const result = await this.create.run(() => this.#api.cell_create(input));
54
+ * if (result) await this.fetch();
55
+ * }
56
+ * }
57
+ * ```
58
+ *
59
+ * @module
60
+ */
61
+ /**
62
+ * Reactive container for a single async operation.
63
+ *
64
+ * @typeParam T - The success payload type. Use `void` for write-only
65
+ * actions whose response isn't worth retaining.
66
+ * @typeParam E - The shape of {@link AsyncSlot.error}. Defaults to
67
+ * `string` (set by the default `map_error`). Narrow to a structured
68
+ * type by providing a `map_error` that returns it.
69
+ */
70
+ export class AsyncSlot {
71
+ status = $state.raw('initial');
72
+ data = $state.raw(undefined);
73
+ error = $state.raw(null);
74
+ /** The raw caught value from the last failed `run()`, for programmatic inspection. */
75
+ error_data = $state.raw(null);
76
+ /** Convenience derived: `status === 'initial'`. */
77
+ initial = $derived(this.status === 'initial');
78
+ /** Convenience derived: `status === 'pending'`. */
79
+ loading = $derived(this.status === 'pending');
80
+ /** Convenience derived: `status === 'success'`. */
81
+ succeeded = $derived(this.status === 'success');
82
+ /** Convenience derived: `status === 'failure'`. */
83
+ failed = $derived(this.status === 'failure');
84
+ #controller = null;
85
+ /**
86
+ * Tracks whether any `run()` or `set()` has ever produced a success
87
+ * result. Used by {@link abort} to revert to `'success'` (vs `'initial'`)
88
+ * — explicit flag instead of inspecting `data` so the discriminator
89
+ * stays correct for `T = void` (where success-`data` is `undefined`)
90
+ * and for nullable `T`s where `null` is a legitimate success value.
91
+ */
92
+ #has_succeeded = false;
93
+ #map_error;
94
+ #preserve_error;
95
+ constructor(options = {}) {
96
+ if (options.initial !== undefined) {
97
+ this.data = options.initial;
98
+ this.status = 'success';
99
+ this.#has_succeeded = true;
100
+ }
101
+ this.#map_error = options.map_error ?? default_map_error;
102
+ this.#preserve_error = options.preserve_error_on_retry ?? false;
103
+ }
104
+ /**
105
+ * Run an async operation. The callback receives an `AbortSignal` it
106
+ * can forward to fetch / RPC clients that support cancellation; the
107
+ * slot also discards superseded results internally even if the
108
+ * callback ignores the signal.
109
+ *
110
+ * Supersession rule: a second `run()` aborts the first's signal AND
111
+ * silently drops its commit if it resolves anyway. So
112
+ * back-to-back-to-back `run()` calls leave only the last call's
113
+ * result in `data`.
114
+ *
115
+ * Abort rule: a `run()` that throws because of its own signal (manual
116
+ * `abort()`, external `options.signal`, OR supersession by another
117
+ * `run()`) does NOT promote to `'failure'`. Manual / external aborts
118
+ * revert status to the previous resolved state (`'initial'` if no
119
+ * `run()` has ever succeeded, `'success'` otherwise). Supersession is
120
+ * handled by the bail-on-mismatch check, leaving the second run's
121
+ * `'pending'` standing.
122
+ *
123
+ * @returns the resolved value on success; `undefined` on failure,
124
+ * abort, or supersession
125
+ */
126
+ async run(fn, options = {}) {
127
+ this.#controller?.abort();
128
+ const controller = new AbortController();
129
+ this.#controller = controller;
130
+ const external = options.signal;
131
+ let external_handler;
132
+ if (external) {
133
+ if (external.aborted) {
134
+ this.abort(external.reason);
135
+ return undefined;
136
+ }
137
+ // Route external abort through the slot's own abort() so the
138
+ // controller-null + status-revert + signal-fire happens
139
+ // atomically (same path as manual abort). Listener is
140
+ // removed in the finally so successful / failing runs don't
141
+ // leak listeners on long-lived external signals.
142
+ external_handler = () => {
143
+ if (this.#controller === controller)
144
+ this.abort(external.reason);
145
+ };
146
+ external.addEventListener('abort', external_handler, { once: true });
147
+ }
148
+ this.status = 'pending';
149
+ if (!this.#preserve_error) {
150
+ this.error = null;
151
+ this.error_data = null;
152
+ }
153
+ try {
154
+ const result = await fn(controller.signal);
155
+ // Bail if this run was superseded or manually aborted —
156
+ // `abort()` nulls `#controller`, so the mismatch fires in
157
+ // both cases. A callback that ignored its signal and
158
+ // resolved anyway has its result dropped silently.
159
+ if (this.#controller !== controller)
160
+ return undefined;
161
+ this.data = result;
162
+ this.error = null;
163
+ this.error_data = null;
164
+ this.status = 'success';
165
+ this.#has_succeeded = true;
166
+ return result;
167
+ }
168
+ catch (e) {
169
+ if (this.#controller !== controller)
170
+ return undefined;
171
+ this.error = this.#map_error(e);
172
+ this.error_data = e;
173
+ this.status = 'failure';
174
+ return undefined;
175
+ }
176
+ finally {
177
+ if (external_handler)
178
+ external?.removeEventListener('abort', external_handler);
179
+ if (this.#controller === controller)
180
+ this.#controller = null;
181
+ }
182
+ }
183
+ /**
184
+ * Abort the in-flight run (if any) and null out the controller field.
185
+ * Shared by {@link abort}, {@link set}, and {@link reset}.
186
+ */
187
+ #clear_controller(reason) {
188
+ this.#controller?.abort(reason);
189
+ this.#controller = null;
190
+ }
191
+ /**
192
+ * Manually abort the in-flight run, if any. Reverts `status`
193
+ * synchronously to the prior resolved state — `'initial'` if no
194
+ * `run()` (or `set()`) has ever succeeded on this slot, `'success'`
195
+ * otherwise. The aborted run's eventual resolution / rejection is
196
+ * dropped without writing to state (the run's `Promise` resolves to
197
+ * `undefined`).
198
+ */
199
+ abort(reason) {
200
+ if (!this.#controller)
201
+ return;
202
+ this.#clear_controller(reason);
203
+ this.status = this.#has_succeeded ? 'success' : 'initial';
204
+ }
205
+ /**
206
+ * Replace `data` directly and mark the slot `'success'`. For
207
+ * post-mutation hydration where the calling RPC already returned the
208
+ * canonical row (parallels `CellState.set_cell`).
209
+ *
210
+ * Aborts any in-flight `run()` first — without this, the in-flight
211
+ * callback could resolve after `set()` and overwrite the explicit
212
+ * value (the bail-on-mismatch check only fires when `#controller`
213
+ * was rotated).
214
+ *
215
+ * @mutates `this`
216
+ */
217
+ set(data) {
218
+ this.#clear_controller();
219
+ this.data = data;
220
+ this.status = 'success';
221
+ this.#has_succeeded = true;
222
+ this.error = null;
223
+ this.error_data = null;
224
+ }
225
+ /**
226
+ * Reset to `'initial'`, clear `data` / `error` / `error_data`, and
227
+ * abort any in-flight run. After `reset()` the slot looks like a
228
+ * fresh instance with no `initial` option.
229
+ *
230
+ * @mutates `this`
231
+ */
232
+ reset() {
233
+ this.#clear_controller();
234
+ this.status = 'initial';
235
+ this.data = undefined;
236
+ this.error = null;
237
+ this.error_data = null;
238
+ this.#has_succeeded = false;
239
+ }
240
+ }
241
+ const default_map_error = (e) => e instanceof Error ? e.message : 'Request failed';
@@ -6,9 +6,13 @@
6
6
  * stream continues to use `EventSource` directly — streams aren't an RPC
7
7
  * concern.
8
8
  *
9
+ * Holds two `AsyncSlot`s — `list` (the main event stream) and
10
+ * `role_grant_history` (the dedicated role-grant history endpoint). Data
11
+ * lives on the class so SSE pushes and gap-fill calls update it directly.
12
+ *
9
13
  * @module
10
14
  */
11
- import { Loadable } from './loadable.svelte.js';
15
+ import { AsyncSlot } from './async_slot.svelte.js';
12
16
  import type { AuditLogEventWithUsernamesJson, RoleGrantHistoryEventJson } from '../auth/audit_log_schema.js';
13
17
  import type { AuditLogListInput, AuditLogListOutput, AuditLogRoleGrantHistoryInput, AuditLogRoleGrantHistoryOutput } from '../auth/admin_action_specs.js';
14
18
  /**
@@ -38,8 +42,10 @@ export interface AuditLogStateOptions {
38
42
  /** SSE stream URL. Defaults to the shipped admin audit-log stream route. */
39
43
  stream_url?: string;
40
44
  }
41
- export declare class AuditLogState extends Loadable {
45
+ export declare class AuditLogState {
42
46
  #private;
47
+ readonly list: AsyncSlot<void, string>;
48
+ readonly role_grant_history: AsyncSlot<void, string>;
43
49
  events: Array<AuditLogEventWithUsernamesJson>;
44
50
  role_grant_history_events: Array<RoleGrantHistoryEventJson>;
45
51
  readonly count: number;
@@ -1 +1 @@
1
- {"version":3,"file":"audit_log_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/audit_log_state.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,OAAO,EAAC,QAAQ,EAAC,MAAM,sBAAsB,CAAC;AAC9C,OAAO,KAAK,EAEX,8BAA8B,EAC9B,yBAAyB,EACzB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EACX,iBAAiB,EACjB,kBAAkB,EAClB,6BAA6B,EAC7B,8BAA8B,EAC9B,MAAM,+BAA+B,CAAC;AAGvC;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,CAAC,KAAK,CAAC,EAAE,iBAAiB,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACjE,kBAAkB,EAAE,CACnB,KAAK,CAAC,EAAE,6BAA6B,KACjC,OAAO,CAAC,8BAA8B,CAAC,CAAC;CAC7C;AAED;;;GAGG;AACH,eAAO,MAAM,qBAAqB;qBAAwB,WAAW,GAAG,IAAI;yBAAlB,WAAW,GAAG,IAAI,wBAAlB,WAAW,GAAG,IAAI;CAAmB,CAAC;AAEhG,MAAM,WAAW,oBAAoB;IACpC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,WAAW,GAAG,IAAI,CAAC;IACnC,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,aAAc,SAAQ,QAAQ;;IAG1C,MAAM,EAAE,KAAK,CAAC,8BAA8B,CAAC,CAAkB;IAC/D,yBAAyB,EAAE,KAAK,CAAC,yBAAyB,CAAC,CAAkB;IAE7E,QAAQ,CAAC,KAAK,SAAgC;IAE9C,qDAAqD;IACrD,SAAS,UAAqB;gBAWlB,OAAO,CAAC,EAAE,oBAAoB;IAM1C,8FAA8F;IAC9F,IAAI,OAAO,IAAI,OAAO,CAErB;IAEK,KAAK,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAajD,wBAAwB,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAY9E;;;;;;;;OAQG;IACH,SAAS,IAAI,MAAM,IAAI;IA0CvB;;;;OAIG;IACH,UAAU,IAAI,IAAI;CAiClB"}
1
+ {"version":3,"file":"audit_log_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/audit_log_state.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH,OAAO,EAAC,SAAS,EAAC,MAAM,wBAAwB,CAAC;AACjD,OAAO,KAAK,EAEX,8BAA8B,EAC9B,yBAAyB,EACzB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EACX,iBAAiB,EACjB,kBAAkB,EAClB,6BAA6B,EAC7B,8BAA8B,EAC9B,MAAM,+BAA+B,CAAC;AAGvC;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,CAAC,KAAK,CAAC,EAAE,iBAAiB,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACjE,kBAAkB,EAAE,CACnB,KAAK,CAAC,EAAE,6BAA6B,KACjC,OAAO,CAAC,8BAA8B,CAAC,CAAC;CAC7C;AAED;;;GAGG;AACH,eAAO,MAAM,qBAAqB;qBAAwB,WAAW,GAAG,IAAI;yBAAlB,WAAW,GAAG,IAAI,wBAAlB,WAAW,GAAG,IAAI;CAAmB,CAAC;AAEhG,MAAM,WAAW,oBAAoB;IACpC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,WAAW,GAAG,IAAI,CAAC;IACnC,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,aAAa;;IAGzB,QAAQ,CAAC,IAAI,0BAAyB;IACtC,QAAQ,CAAC,kBAAkB,0BAAyB;IAEpD,MAAM,EAAE,KAAK,CAAC,8BAA8B,CAAC,CAAkB;IAC/D,yBAAyB,EAAE,KAAK,CAAC,yBAAyB,CAAC,CAAkB;IAE7E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAgC;IAEtD,qDAAqD;IACrD,SAAS,UAAqB;gBAWlB,OAAO,CAAC,EAAE,oBAAoB;IAK1C,8FAA8F;IAC9F,IAAI,OAAO,IAAI,OAAO,CAErB;IAQK,KAAK,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAQjD,wBAAwB,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO9E;;;;;;;;OAQG;IACH,SAAS,IAAI,MAAM,IAAI;IA0CvB;;;;OAIG;IACH,UAAU,IAAI,IAAI;CAiClB"}
@@ -6,18 +6,24 @@
6
6
  * stream continues to use `EventSource` directly — streams aren't an RPC
7
7
  * concern.
8
8
  *
9
+ * Holds two `AsyncSlot`s — `list` (the main event stream) and
10
+ * `role_grant_history` (the dedicated role-grant history endpoint). Data
11
+ * lives on the class so SSE pushes and gap-fill calls update it directly.
12
+ *
9
13
  * @module
10
14
  */
11
15
  import { DEV } from 'esm-env';
12
16
  import { create_context } from '@fuzdev/fuz_ui/context_helpers.js';
13
- import { Loadable } from './loadable.svelte.js';
17
+ import { AsyncSlot } from './async_slot.svelte.js';
14
18
  /**
15
19
  * Svelte context carrying the reactive `AuditLogRpc` accessor. Mirrors
16
20
  * `admin_accounts_rpc_context`. Unset context falls back to `() => null`.
17
21
  */
18
22
  export const audit_log_rpc_context = create_context(() => () => null);
19
- export class AuditLogState extends Loadable {
23
+ export class AuditLogState {
20
24
  #get_rpc;
25
+ list = new AsyncSlot();
26
+ role_grant_history = new AsyncSlot();
21
27
  events = $state.raw([]);
22
28
  role_grant_history_events = $state.raw([]);
23
29
  count = $derived(this.events.length);
@@ -30,7 +36,6 @@ export class AuditLogState extends Loadable {
30
36
  /** Path to the SSE stream endpoint. */
31
37
  #stream_url;
32
38
  constructor(options) {
33
- super();
34
39
  this.#get_rpc = options?.get_rpc ?? (() => null);
35
40
  this.#stream_url = options?.stream_url ?? '/api/admin/audit/stream';
36
41
  }
@@ -38,26 +43,22 @@ export class AuditLogState extends Loadable {
38
43
  get has_rpc() {
39
44
  return this.#get_rpc() !== null;
40
45
  }
41
- async fetch(options) {
46
+ #require_rpc() {
42
47
  const rpc = this.#get_rpc();
43
- if (!rpc) {
44
- this.error = 'rpc adapter not wired';
45
- return;
46
- }
47
- await this.run(async () => {
48
- const { events } = await rpc.list(options);
48
+ if (!rpc)
49
+ throw new Error('rpc adapter not wired');
50
+ return rpc;
51
+ }
52
+ async fetch(options) {
53
+ await this.list.run(async () => {
54
+ const { events } = await this.#require_rpc().list(options);
49
55
  this.events = events;
50
- this.#update_last_seq(this.events);
56
+ this.#update_last_seq(events);
51
57
  });
52
58
  }
53
59
  async fetch_role_grant_history(limit, offset) {
54
- const rpc = this.#get_rpc();
55
- if (!rpc) {
56
- this.error = 'rpc adapter not wired';
57
- return;
58
- }
59
- await this.run(async () => {
60
- const { events } = await rpc.role_grant_history({ limit, offset });
60
+ await this.role_grant_history.run(async () => {
61
+ const { events } = await this.#require_rpc().role_grant_history({ limit, offset });
61
62
  this.role_grant_history_events = events;
62
63
  });
63
64
  }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Keyed sibling of `AsyncSlot` — fans the per-instance supersession
3
+ * machinery out across an open set of keys.
4
+ *
5
+ * Each key gets its own lazily-created `AsyncSlot`, so concurrent
6
+ * `run(key_a, ...)` and `run(key_b, ...)` calls are independent: a
7
+ * second `run()` on `key_a` aborts only `key_a`'s in-flight call,
8
+ * leaving `key_b` running. The keyed shape replaces the
9
+ * `AsyncSlot` + `SvelteSet<id>` pair that state classes previously
10
+ * carried for per-row in-flight tracking, with two genuine wins:
11
+ *
12
+ * - **Cross-key supersession is correct** — clicking row B while row
13
+ * A is in flight no longer aborts A; each row has its own
14
+ * AbortController.
15
+ * - **Per-key error surfacing** — `error(key)` carries the failure
16
+ * for that key only, instead of the last-error-wins shape of a
17
+ * shared slot.
18
+ *
19
+ * The backing `SvelteMap` keeps entries even after a `run()` resolves
20
+ * — components can read `error(key)` to render an inline per-row
21
+ * failure indicator. Call `delete(key)` to dismiss an entry, or
22
+ * `reset()` to clear everything (e.g. on page leave).
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * class AdminInvitesState {
27
+ * readonly remove = new KeyedAsyncSlot<Uuid>();
28
+ *
29
+ * async submit_delete(id: Uuid): Promise<void> {
30
+ * const ok = await this.remove.run(id, () => this.#rpc().delete({invite_id: id}));
31
+ * if (ok !== undefined) await this.fetch();
32
+ * }
33
+ * }
34
+ *
35
+ * // In a template:
36
+ * // <button disabled={state.remove.loading(row.id)}>
37
+ * // {state.remove.loading(row.id) ? 'deleting…' : 'delete'}
38
+ * // </button>
39
+ * // {#if state.remove.error(row.id)}<p>{state.remove.error(row.id)}</p>{/if}
40
+ * ```
41
+ *
42
+ * @module
43
+ */
44
+ import { AsyncSlot, type AsyncSlotOptions, type RunOptions } from './async_slot.svelte.js';
45
+ /**
46
+ * Constructor options for `KeyedAsyncSlot`. Propagated to every child
47
+ * `AsyncSlot` at lazy creation time.
48
+ *
49
+ * `initial` from {@link AsyncSlotOptions} is deliberately omitted —
50
+ * keyed slots have no per-key seed concept (the entries don't exist
51
+ * until `run()` creates them).
52
+ */
53
+ export type KeyedAsyncSlotOptions<T, E = string> = Omit<AsyncSlotOptions<T, E>, 'initial'>;
54
+ /**
55
+ * Reactive container for many concurrent async operations keyed by `K`.
56
+ *
57
+ * @typeParam K - The key type. Map identity is SameValueZero — branded
58
+ * strings (`Uuid`) work directly. For composite keys, stringify at
59
+ * the call site (e.g. `` `${account_id}:${role}` ``).
60
+ * @typeParam T - The success payload type. Use `void` for write-only
61
+ * actions whose response isn't worth retaining.
62
+ * @typeParam E - The shape of per-key `error(key)`. Defaults to
63
+ * `string` (set by the default `map_error`).
64
+ */
65
+ export declare class KeyedAsyncSlot<K, T = void, E = string> {
66
+ #private;
67
+ constructor(options?: KeyedAsyncSlotOptions<T, E>);
68
+ /** Total number of keys with state (pending OR resolved). Reactive. */
69
+ get size(): number;
70
+ /** Reactive — true once `run(key, ...)` has been called and the entry hasn't been deleted. */
71
+ has(key: K): boolean;
72
+ /**
73
+ * Direct access to the underlying `AsyncSlot` for `key`, or
74
+ * `undefined` if no `run()` has been issued for it yet. Reactive on
75
+ * map population and on the slot's `$state.raw` fields.
76
+ *
77
+ * Prefer the sugar getters ({@link loading}, {@link error}) for
78
+ * templates; reach for `get(key)` when you need `error_data`, `data`,
79
+ * or to call `abort()` / `set()` / `reset()` on the underlying slot.
80
+ */
81
+ get(key: K): AsyncSlot<T, E> | undefined;
82
+ /** Reactive — `false` for keys that have never been used. */
83
+ loading(key: K): boolean;
84
+ /** Reactive — `null` when the key has no entry or hasn't failed. */
85
+ error(key: K): E | null;
86
+ /** Reactive — `false` for keys that have never been used. */
87
+ failed(key: K): boolean;
88
+ /** Reactive — `false` for keys that have never been used. */
89
+ succeeded(key: K): boolean;
90
+ /** Reactive iterator over every key with state. */
91
+ keys(): IterableIterator<K>;
92
+ /** Reactive iterator over every slot. */
93
+ values(): IterableIterator<AsyncSlot<T, E>>;
94
+ /** Reactive iterator over `[key, slot]` pairs. */
95
+ entries(): IterableIterator<[K, AsyncSlot<T, E>]>;
96
+ /**
97
+ * Run an async operation for `key`. Lazily creates an `AsyncSlot`
98
+ * for the key on first use, inheriting the constructor's
99
+ * `map_error` / `preserve_error_on_retry` options.
100
+ *
101
+ * Supersession is scoped to `key`: a second `run(key, ...)` aborts
102
+ * the first's signal AND drops its commit. Calls on different keys
103
+ * are fully independent (each has its own `AbortController`).
104
+ *
105
+ * @returns the resolved value on success; `undefined` on failure,
106
+ * abort, or supersession.
107
+ */
108
+ run(key: K, fn: (signal: AbortSignal) => Promise<T>, options?: RunOptions): Promise<T | undefined>;
109
+ /**
110
+ * Abort the in-flight run for `key`, if any. No-op when the key has
111
+ * no entry. The slot stays in the map at its prior resolved status —
112
+ * call {@link delete} to remove the entry entirely.
113
+ */
114
+ abort(key: K, reason?: unknown): void;
115
+ /**
116
+ * Abort every in-flight run. Resolved entries stay in the map —
117
+ * call {@link reset} to clear them too.
118
+ */
119
+ abort_all(reason?: unknown): void;
120
+ /**
121
+ * Abort the in-flight run for `key` (if any) and remove the entry
122
+ * from the map. After `delete(key)`, `has(key)` returns `false` and
123
+ * the sugar getters report the no-entry defaults — typically how a
124
+ * UI dismisses a per-row error indicator.
125
+ *
126
+ * @returns `true` if the key had an entry.
127
+ *
128
+ * @mutates `this`
129
+ */
130
+ delete(key: K): boolean;
131
+ /**
132
+ * Abort every in-flight run and clear the map. The keyed slot looks
133
+ * like a fresh instance afterwards.
134
+ *
135
+ * @mutates `this`
136
+ */
137
+ reset(): void;
138
+ }
139
+ //# sourceMappingURL=keyed_async_slot.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyed_async_slot.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/keyed_async_slot.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAIH,OAAO,EAAC,SAAS,EAAE,KAAK,gBAAgB,EAAE,KAAK,UAAU,EAAC,MAAM,wBAAwB,CAAC;AAEzF;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,CAAC,CAAC,EAAE,CAAC,GAAG,MAAM,IAAI,IAAI,CAAC,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC;AAE3F;;;;;;;;;;GAUG;AACH,qBAAa,cAAc,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,GAAG,MAAM;;gBAItC,OAAO,GAAE,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAM;IAIrD,uEAAuE;IACvE,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,8FAA8F;IAC9F,GAAG,CAAC,GAAG,EAAE,CAAC,GAAG,OAAO;IAIpB;;;;;;;;OAQG;IACH,GAAG,CAAC,GAAG,EAAE,CAAC,GAAG,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,SAAS;IAIxC,6DAA6D;IAC7D,OAAO,CAAC,GAAG,EAAE,CAAC,GAAG,OAAO;IAIxB,oEAAoE;IACpE,KAAK,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,GAAG,IAAI;IAIvB,6DAA6D;IAC7D,MAAM,CAAC,GAAG,EAAE,CAAC,GAAG,OAAO;IAIvB,6DAA6D;IAC7D,SAAS,CAAC,GAAG,EAAE,CAAC,GAAG,OAAO;IAI1B,mDAAmD;IACnD,IAAI,IAAI,gBAAgB,CAAC,CAAC,CAAC;IAI3B,yCAAyC;IACzC,MAAM,IAAI,gBAAgB,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAI3C,kDAAkD;IAClD,OAAO,IAAI,gBAAgB,CAAC,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAIjD;;;;;;;;;;;OAWG;IACG,GAAG,CACR,GAAG,EAAE,CAAC,EACN,EAAE,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,EACvC,OAAO,CAAC,EAAE,UAAU,GAClB,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;IASzB;;;;OAIG;IACH,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI;IAIrC;;;OAGG;IACH,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI;IAMjC;;;;;;;;;OASG;IACH,MAAM,CAAC,GAAG,EAAE,CAAC,GAAG,OAAO;IAOvB;;;;;OAKG;IACH,KAAK,IAAI,IAAI;CAMb"}