@happyvertical/smrt-svelte 0.38.1 → 0.38.2

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,327 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { systemFeed } from '../live/system-feed.svelte.js';
3
+ /**
4
+ * Lifecycle tests for the `systemFeed` polling helper (#1774). Real Svelte
5
+ * `$state` reactivity + vitest fake timers; only the external `fetch` is
6
+ * mocked. Each `dispose()`s its feed in `afterEach` to avoid leaked intervals.
7
+ */
8
+ const disposers = [];
9
+ function track(controller) {
10
+ disposers.push(() => controller.dispose());
11
+ return controller;
12
+ }
13
+ /** Flush the interval + the async tick's microtasks. */
14
+ async function advance(ms) {
15
+ await vi.advanceTimersByTimeAsync(ms);
16
+ }
17
+ function panel(id, itemCount) {
18
+ return {
19
+ id,
20
+ label: id,
21
+ items: Array.from({ length: itemCount }, (_, index) => ({
22
+ id: `${id}-${index}`,
23
+ label: `${id} ${index}`,
24
+ status: 'running',
25
+ })),
26
+ };
27
+ }
28
+ beforeEach(() => {
29
+ vi.useFakeTimers();
30
+ });
31
+ afterEach(() => {
32
+ while (disposers.length > 0)
33
+ disposers.pop()?.();
34
+ vi.useRealTimers();
35
+ // Restore any spies (e.g. document.hidden / addEventListener) so they cannot
36
+ // leak into later tests or files.
37
+ vi.restoreAllMocks();
38
+ });
39
+ describe('systemFeed', () => {
40
+ it('fetches immediately and maps the response into panels and chips', async () => {
41
+ const fetch = vi.fn().mockResolvedValue({ jobs: 2 });
42
+ const feed = track(systemFeed({
43
+ fetch,
44
+ intervalMs: 1000,
45
+ pauseWhenHidden: false,
46
+ map: (data) => ({
47
+ panels: [panel('jobs', data.jobs)],
48
+ chips: [{ id: 'jobs', label: 'Jobs', value: data.jobs }],
49
+ }),
50
+ }));
51
+ // Immediate tick is queued synchronously; flush its microtasks.
52
+ await advance(0);
53
+ expect(fetch).toHaveBeenCalledTimes(1);
54
+ expect(feed.status).toBe('success');
55
+ expect(feed.panels).toHaveLength(1);
56
+ expect(feed.panels[0]?.items).toHaveLength(2);
57
+ expect(feed.chips[0]?.value).toBe(2);
58
+ expect(feed.running).toBe(true);
59
+ });
60
+ it('polls again on each interval tick', async () => {
61
+ const fetch = vi.fn().mockResolvedValue({ ok: true });
62
+ track(systemFeed({
63
+ fetch,
64
+ intervalMs: 1000,
65
+ pauseWhenHidden: false,
66
+ map: () => ({}),
67
+ }));
68
+ await advance(0); // immediate
69
+ expect(fetch).toHaveBeenCalledTimes(1);
70
+ await advance(1000);
71
+ expect(fetch).toHaveBeenCalledTimes(2);
72
+ await advance(3000);
73
+ expect(fetch).toHaveBeenCalledTimes(5);
74
+ });
75
+ it('stops polling after the disposer runs', async () => {
76
+ const fetch = vi.fn().mockResolvedValue({ ok: true });
77
+ const feed = systemFeed({
78
+ fetch,
79
+ intervalMs: 1000,
80
+ pauseWhenHidden: false,
81
+ map: () => ({}),
82
+ });
83
+ await advance(0);
84
+ expect(fetch).toHaveBeenCalledTimes(1);
85
+ feed.dispose();
86
+ expect(feed.running).toBe(false);
87
+ expect(feed.status).toBe('stopped');
88
+ await advance(5000);
89
+ // No further ticks after disposal.
90
+ expect(fetch).toHaveBeenCalledTimes(1);
91
+ });
92
+ it('stop() halts the timer while start() re-arms it', async () => {
93
+ const fetch = vi.fn().mockResolvedValue({ ok: true });
94
+ const feed = track(systemFeed({
95
+ fetch,
96
+ intervalMs: 1000,
97
+ pauseWhenHidden: false,
98
+ map: () => ({}),
99
+ }));
100
+ await advance(0);
101
+ expect(fetch).toHaveBeenCalledTimes(1);
102
+ feed.stop();
103
+ expect(feed.running).toBe(false);
104
+ await advance(3000);
105
+ expect(fetch).toHaveBeenCalledTimes(1);
106
+ feed.start();
107
+ expect(feed.running).toBe(true);
108
+ await advance(1000);
109
+ expect(fetch).toHaveBeenCalledTimes(2);
110
+ });
111
+ it('tolerates a rejected fetch without stopping the loop and keeps prior data', async () => {
112
+ const onError = vi.fn();
113
+ const fetch = vi
114
+ .fn()
115
+ .mockResolvedValueOnce({ n: 1 })
116
+ .mockRejectedValueOnce(new Error('boom'))
117
+ .mockResolvedValue({ n: 3 });
118
+ const feed = track(systemFeed({
119
+ fetch,
120
+ intervalMs: 1000,
121
+ pauseWhenHidden: false,
122
+ onError,
123
+ map: (data) => ({
124
+ chips: [{ id: 'n', label: 'N', value: data.n }],
125
+ }),
126
+ }));
127
+ await advance(0);
128
+ expect(feed.chips[0]?.value).toBe(1);
129
+ expect(feed.status).toBe('success');
130
+ // Second tick rejects.
131
+ await advance(1000);
132
+ expect(onError).toHaveBeenCalledTimes(1);
133
+ expect(feed.status).toBe('error');
134
+ expect(feed.error).toBeInstanceOf(Error);
135
+ // Last good data stays on screen.
136
+ expect(feed.chips[0]?.value).toBe(1);
137
+ // Third tick recovers.
138
+ await advance(1000);
139
+ expect(feed.status).toBe('success');
140
+ expect(feed.error).toBeNull();
141
+ expect(feed.chips[0]?.value).toBe(3);
142
+ });
143
+ it('tolerates a throwing mapper as a failed tick', async () => {
144
+ const fetch = vi.fn().mockResolvedValue({ bad: true });
145
+ const feed = track(systemFeed({
146
+ fetch,
147
+ intervalMs: 1000,
148
+ pauseWhenHidden: false,
149
+ map: () => {
150
+ throw new Error('map failed');
151
+ },
152
+ }));
153
+ await advance(0);
154
+ expect(feed.status).toBe('error');
155
+ expect(feed.error).toBeInstanceOf(Error);
156
+ // Loop survives.
157
+ await advance(1000);
158
+ expect(fetch).toHaveBeenCalledTimes(2);
159
+ });
160
+ it('does not fetch on creation when immediate is false', async () => {
161
+ const fetch = vi.fn().mockResolvedValue({ ok: true });
162
+ const feed = track(systemFeed({
163
+ fetch,
164
+ intervalMs: 1000,
165
+ immediate: false,
166
+ pauseWhenHidden: false,
167
+ map: () => ({}),
168
+ }));
169
+ await advance(0);
170
+ expect(fetch).not.toHaveBeenCalled();
171
+ expect(feed.status).toBe('idle');
172
+ // First real load is the timer tick.
173
+ await advance(1000);
174
+ expect(fetch).toHaveBeenCalledTimes(1);
175
+ });
176
+ it('runs a single fetch with no timer when intervalMs <= 0', async () => {
177
+ const fetch = vi.fn().mockResolvedValue({ ok: true });
178
+ const feed = track(systemFeed({
179
+ fetch,
180
+ intervalMs: 0,
181
+ pauseWhenHidden: false,
182
+ map: () => ({}),
183
+ }));
184
+ await advance(0);
185
+ expect(fetch).toHaveBeenCalledTimes(1);
186
+ // No timer is armed, so `running` stays false (it tracks an armed timer).
187
+ expect(feed.running).toBe(false);
188
+ await advance(10_000);
189
+ expect(fetch).toHaveBeenCalledTimes(1);
190
+ // Manual refresh still works.
191
+ await feed.refresh();
192
+ expect(fetch).toHaveBeenCalledTimes(2);
193
+ });
194
+ it('refresh() fetches once off-schedule', async () => {
195
+ const fetch = vi.fn().mockResolvedValue({ ok: true });
196
+ const feed = track(systemFeed({
197
+ fetch,
198
+ intervalMs: 0,
199
+ immediate: false,
200
+ pauseWhenHidden: false,
201
+ map: () => ({}),
202
+ }));
203
+ await advance(0);
204
+ expect(fetch).not.toHaveBeenCalled();
205
+ await feed.refresh();
206
+ expect(fetch).toHaveBeenCalledTimes(1);
207
+ });
208
+ it('skips ticks while the document is hidden and refreshes on becoming visible', async () => {
209
+ let hidden = false;
210
+ const listeners = [];
211
+ vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
212
+ const addSpy = vi
213
+ .spyOn(document, 'addEventListener')
214
+ .mockImplementation((type, cb) => {
215
+ if (type === 'visibilitychange')
216
+ listeners.push(cb);
217
+ });
218
+ const removeSpy = vi
219
+ .spyOn(document, 'removeEventListener')
220
+ .mockImplementation(() => { });
221
+ const fetch = vi.fn().mockResolvedValue({ ok: true });
222
+ const feed = track(systemFeed({
223
+ fetch,
224
+ intervalMs: 1000,
225
+ // pauseWhenHidden defaults to true.
226
+ map: () => ({}),
227
+ }));
228
+ // Immediate tick while visible.
229
+ await advance(0);
230
+ expect(fetch).toHaveBeenCalledTimes(1);
231
+ // Go hidden — timer ticks are skipped.
232
+ hidden = true;
233
+ await advance(3000);
234
+ expect(fetch).toHaveBeenCalledTimes(1);
235
+ // Back to visible — the visibilitychange listener refreshes once.
236
+ hidden = false;
237
+ for (const cb of listeners)
238
+ cb();
239
+ await advance(0);
240
+ expect(fetch).toHaveBeenCalledTimes(2);
241
+ feed.dispose();
242
+ expect(removeSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
243
+ addSpy.mockRestore();
244
+ removeSpy.mockRestore();
245
+ });
246
+ it('supersedes an in-flight fetch so a stale response cannot clobber newer data', async () => {
247
+ let resolveFirst;
248
+ const fetch = vi
249
+ .fn()
250
+ .mockImplementationOnce(() => new Promise((resolve) => {
251
+ resolveFirst = resolve;
252
+ }))
253
+ .mockResolvedValue({ n: 2 });
254
+ const feed = track(systemFeed({
255
+ fetch,
256
+ intervalMs: 0,
257
+ immediate: false,
258
+ pauseWhenHidden: false,
259
+ map: (data) => ({
260
+ chips: [{ id: 'n', label: 'N', value: data.n }],
261
+ }),
262
+ }));
263
+ // Start a slow first fetch (never resolved yet).
264
+ const first = feed.refresh();
265
+ // Start a second fetch that resolves immediately; it supersedes the first.
266
+ await feed.refresh();
267
+ expect(feed.chips[0]?.value).toBe(2);
268
+ // Now let the stale first fetch resolve — it must be ignored.
269
+ resolveFirst?.({ n: 1 });
270
+ await first;
271
+ expect(feed.chips[0]?.value).toBe(2);
272
+ });
273
+ it('contains a throwing onError callback (no unhandled rejection)', async () => {
274
+ const unhandled = vi.fn();
275
+ process.on('unhandledRejection', unhandled);
276
+ const onError = vi.fn(() => {
277
+ throw new Error('consumer onError blew up');
278
+ });
279
+ const fetch = vi.fn().mockRejectedValue(new Error('fetch failed'));
280
+ const feed = track(systemFeed({
281
+ fetch,
282
+ intervalMs: 0,
283
+ immediate: false,
284
+ pauseWhenHidden: false,
285
+ onError,
286
+ map: () => ({}),
287
+ }));
288
+ // The failing fetch invokes the throwing onError; the tick must still
289
+ // resolve rather than reject.
290
+ await expect(feed.refresh()).resolves.toBeUndefined();
291
+ expect(onError).toHaveBeenCalled();
292
+ expect(feed.status).toBe('error');
293
+ // Give any stray rejection a chance to surface, then assert none did.
294
+ await advance(0);
295
+ process.off('unhandledRejection', unhandled);
296
+ expect(unhandled).not.toHaveBeenCalled();
297
+ });
298
+ it('does not refresh on becoming visible after stop()', async () => {
299
+ let hidden = false;
300
+ const listeners = [];
301
+ vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
302
+ vi.spyOn(document, 'addEventListener').mockImplementation((type, cb) => {
303
+ if (type === 'visibilitychange')
304
+ listeners.push(cb);
305
+ });
306
+ vi.spyOn(document, 'removeEventListener').mockImplementation(() => { });
307
+ const fetch = vi.fn().mockResolvedValue({ ok: true });
308
+ const feed = track(systemFeed({
309
+ fetch,
310
+ intervalMs: 1000,
311
+ map: () => ({}),
312
+ }));
313
+ await advance(0);
314
+ expect(fetch).toHaveBeenCalledTimes(1);
315
+ // Pause polling (but the visibility listener is still attached — only
316
+ // dispose() detaches it).
317
+ feed.stop();
318
+ expect(feed.running).toBe(false);
319
+ // A hidden -> visible transition must NOT resurrect polling while stopped.
320
+ hidden = true;
321
+ hidden = false;
322
+ for (const cb of listeners)
323
+ cb();
324
+ await advance(0);
325
+ expect(fetch).toHaveBeenCalledTimes(1);
326
+ });
327
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@happyvertical/smrt-svelte/workspace/live`
3
+ *
4
+ * Opt-in, transport-light live-data helpers for the AdminShell system scope.
5
+ *
6
+ * This subpath is intentionally separate from the `./workspace` presentation
7
+ * barrel and the `./web` (smrt-web / TanStack) entry. The system scope shows
8
+ * jobs / schedules / dispatch, which live in `_smrt_*` system tables that the
9
+ * core change-feed skips and smrt-web does not expose as collections — so this
10
+ * layer polls an app-provided status endpoint rather than a live collection,
11
+ * and carries no smrt-web dependency.
12
+ *
13
+ * See `src/components/workspace/live/system-feed.recipe.md` for wiring an app
14
+ * jobs-status endpoint (server reads `_smrt_*` via `@happyvertical/smrt-jobs`).
15
+ */
16
+ export { type SystemFeedController, type SystemFeedOptions, type SystemFeedStatus, type SystemFeedView, systemFeed, } from './system-feed.svelte.js';
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/workspace/live/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,UAAU,GACX,MAAM,yBAAyB,CAAC"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `@happyvertical/smrt-svelte/workspace/live`
3
+ *
4
+ * Opt-in, transport-light live-data helpers for the AdminShell system scope.
5
+ *
6
+ * This subpath is intentionally separate from the `./workspace` presentation
7
+ * barrel and the `./web` (smrt-web / TanStack) entry. The system scope shows
8
+ * jobs / schedules / dispatch, which live in `_smrt_*` system tables that the
9
+ * core change-feed skips and smrt-web does not expose as collections — so this
10
+ * layer polls an app-provided status endpoint rather than a live collection,
11
+ * and carries no smrt-web dependency.
12
+ *
13
+ * See `src/components/workspace/live/system-feed.recipe.md` for wiring an app
14
+ * jobs-status endpoint (server reads `_smrt_*` via `@happyvertical/smrt-jobs`).
15
+ */
16
+ export { systemFeed, } from './system-feed.svelte.js';
@@ -0,0 +1,169 @@
1
+ # Recipe: live system-scope data with `systemFeed`
2
+
3
+ The AdminShell **system scope** (the bottom edge) shows operational data — jobs,
4
+ schedules, dispatch, worker liveness. That data lives in the `_smrt_*` **system
5
+ tables**, which are intentionally outside the reactive path used for tenant/app
6
+ data:
7
+
8
+ - The core change-feed **skips** every `_smrt_*` table
9
+ (`packages/core/src/change-feed.ts`).
10
+ - `smrt-web` does **not** expose these as collections.
11
+
12
+ So the system scope is not fed by `liveCollection()`. Instead the **app owns a
13
+ status endpoint** that reads `_smrt_*` on the server, and the browser **polls**
14
+ it with `systemFeed` from `@happyvertical/smrt-svelte/workspace/live`. This
15
+ subpath is transport-light — it pulls no `smrt-web` / TanStack dependency.
16
+
17
+ ## 1. Server: expose a jobs-status endpoint
18
+
19
+ Read the system tables through the `@happyvertical/smrt-jobs` query API
20
+ (`SmrtJobCollection`, `SmrtWorkerCollection`, …) — never hand-write SQL against
21
+ `_smrt_*`. A SvelteKit `+server.ts` is shown; any framework's request handler
22
+ works the same way.
23
+
24
+ ```ts
25
+ // src/routes/admin/system/status/+server.ts
26
+ import { json } from '@sveltejs/kit';
27
+ import { getDatabase } from '@happyvertical/sql';
28
+ import { SmrtJobCollection, SmrtWorkerCollection } from '@happyvertical/smrt-jobs';
29
+
30
+ export async function GET() {
31
+ const db = await getDatabase(/* your app's connection options */);
32
+ const jobs = await SmrtJobCollection.create({ db });
33
+ const workers = await SmrtWorkerCollection.create({ db });
34
+
35
+ // Collections read the `_smrt_*` system tables for you.
36
+ const recent = await jobs.list({ orderBy: 'created_at DESC', limit: 20 });
37
+ const live = await workers.list({ limit: 50 });
38
+
39
+ const counts = { queued: 0, running: 0, failed: 0 };
40
+ for (const job of recent) {
41
+ if (job.status === 'pending') counts.queued += 1;
42
+ else if (job.status === 'running') counts.running += 1;
43
+ else if (job.status === 'failed') counts.failed += 1;
44
+ }
45
+
46
+ return json({
47
+ generatedAt: new Date().toISOString(),
48
+ counts,
49
+ workers: live.length,
50
+ // SmrtJob is a method-dispatch row: `queue` / `objectType` / `method` /
51
+ // `status` / `lastError` — there is no free-text "name" column, so build a
52
+ // label from the fields that exist.
53
+ jobs: recent.map((job) => ({
54
+ id: job.id,
55
+ label: `${job.objectType}.${job.method}`,
56
+ queue: job.queue,
57
+ status: job.status,
58
+ detail: job.lastError ?? undefined,
59
+ updatedAt: job.updatedAt,
60
+ })),
61
+ });
62
+ }
63
+ ```
64
+
65
+ Guard the route with your own auth/permissions (e.g. the `smrt-users`
66
+ `PermissionResolver`) — it exposes operational internals.
67
+
68
+ ## 2. Client: poll it and map into the shell contracts
69
+
70
+ `systemFeed({ fetch, intervalMs, map })` calls your `fetch` on an interval and
71
+ maps the response into `ShellSystemPanel[]` (for `SystemScopePanel`) and
72
+ `ShellStatusChip[]` (for `SystemStatusChips`). It returns a controller whose
73
+ `panels` / `chips` are `$state`-reactive; bind them straight to the components
74
+ and call `dispose()` on teardown.
75
+
76
+ ```svelte
77
+ <script lang="ts">
78
+ import { onDestroy } from 'svelte';
79
+ import { systemFeed } from '@happyvertical/smrt-svelte/workspace/live';
80
+ import {
81
+ SystemScopePanel,
82
+ SystemStatusChips,
83
+ type ShellStatusChip,
84
+ type ShellSystemPanel,
85
+ } from '@happyvertical/smrt-svelte/workspace';
86
+
87
+ interface SystemStatus {
88
+ counts: { queued: number; running: number; failed: number };
89
+ workers: number;
90
+ jobs: Array<{
91
+ id: string;
92
+ label: string;
93
+ queue: string;
94
+ status: string;
95
+ detail?: string;
96
+ updatedAt?: string;
97
+ }>;
98
+ }
99
+
100
+ const feed = systemFeed<SystemStatus>({
101
+ // App-owned transport — the helper only calls it.
102
+ fetch: (signal) =>
103
+ fetch('/admin/system/status', { signal }).then((r) => r.json()),
104
+ intervalMs: 5000,
105
+ map: (status) => ({
106
+ chips: [
107
+ { id: 'workers', label: 'Workers', value: status.workers, tone: 'info' },
108
+ {
109
+ id: 'running',
110
+ label: 'Running',
111
+ value: status.counts.running,
112
+ tone: 'success',
113
+ },
114
+ {
115
+ id: 'failed',
116
+ label: 'Failed',
117
+ value: status.counts.failed,
118
+ tone: status.counts.failed > 0 ? 'error' : 'neutral',
119
+ },
120
+ ] satisfies ShellStatusChip[],
121
+ panels: [
122
+ {
123
+ id: 'jobs',
124
+ label: 'Jobs',
125
+ items: status.jobs.map((job) => ({
126
+ id: job.id,
127
+ label: job.label,
128
+ status: job.status,
129
+ detail: job.detail,
130
+ updatedAt: job.updatedAt,
131
+ })),
132
+ },
133
+ ] satisfies ShellSystemPanel[],
134
+ }),
135
+ });
136
+
137
+ onDestroy(feed.dispose);
138
+ </script>
139
+
140
+ <SystemStatusChips chips={feed.chips} />
141
+ <SystemScopePanel panels={feed.panels} />
142
+ ```
143
+
144
+ Wire those two snippets into the shell's `systemBar` / `systemPanel` slots as
145
+ usual (see `SystemScopePanel` / `SystemStatusChips`).
146
+
147
+ ## Behavior notes
148
+
149
+ - **Immediate first load** — fetches once on creation (disable with
150
+ `immediate: false`), then every `intervalMs`. `intervalMs <= 0` disables the
151
+ timer (single fetch; drive further loads with `refresh()`).
152
+ - **Errors are tolerated** — a rejected `fetch` or a throwing `map` sets
153
+ `status: 'error'` and `error`, calls `onError`, and keeps polling. The last
154
+ good `panels` / `chips` stay on screen.
155
+ - **Backgrounded tabs** — with the default `pauseWhenHidden: true`, ticks are
156
+ skipped while `document.hidden` and one refresh fires when the tab becomes
157
+ visible again. In SSR / non-DOM environments this is a no-op.
158
+ - **No overlap / no late writes** — each tick supersedes any in-flight fetch
159
+ (via `AbortSignal`), and a stale response can never clobber a newer one.
160
+ - **SSR-safe construction** — constructed under SSR (no `document`), the feed is
161
+ inert: no listener, no immediate fetch, no server-side timer. It comes to life
162
+ when the client re-runs the component script, so you can call `systemFeed`
163
+ directly in `<script>` (as above) without an `onMount` guard; the first fetch
164
+ and the poll timer start on the client. `running` is `false` until a timer is
165
+ actually armed (so it is also `false` for single-shot `intervalMs <= 0`).
166
+ - **`onError` is contained** — a throwing `onError` callback is caught and
167
+ swallowed; it never rejects the tick or surfaces as an unhandled rejection.
168
+ - **Teardown** — `dispose()` stops the timer, detaches the visibility listener,
169
+ and aborts any in-flight fetch. Always call it from `onDestroy`.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `systemFeed` — a transport-light polling helper for the AdminShell system
3
+ * scope (issue #1774, epic #1766).
4
+ *
5
+ * The System edge presents jobs / schedules / dispatch — data that lives in the
6
+ * `_smrt_*` SYSTEM tables. Those tables are intentionally **excluded** from the
7
+ * core change-feed (`packages/core/src/change-feed.ts`) and are **not** surfaced
8
+ * as `smrt-web` collections, so the live-collection path used by tenant/app
9
+ * data does not apply here. Instead, an app exposes its own status endpoint
10
+ * (a `+server.ts` that reads `_smrt_*` through the `smrt-jobs` query API) and
11
+ * this helper polls it on an interval, mapping the response into the
12
+ * presentation contracts the shell already renders:
13
+ * `ShellSystemPanel[]` (for `SystemScopePanel`) and `ShellStatusChip[]`
14
+ * (for `SystemStatusChips`).
15
+ *
16
+ * This module is deliberately kept OUT of the `./workspace` presentation barrel
17
+ * and the `./web` (smrt-web / TanStack) entry: it is opt-in via the
18
+ * `@happyvertical/smrt-svelte/workspace/live` subpath and pulls no runtime
19
+ * dependency beyond Svelte's reactivity. It only imports the shell's *types*.
20
+ *
21
+ * @example
22
+ * ```svelte
23
+ * <script lang="ts">
24
+ * import { systemFeed } from '@happyvertical/smrt-svelte/workspace/live';
25
+ * import { SystemScopePanel, SystemStatusChips } from
26
+ * '@happyvertical/smrt-svelte/workspace';
27
+ * import { onDestroy } from 'svelte';
28
+ *
29
+ * const feed = systemFeed({
30
+ * fetch: () => fetch('/admin/system/status').then((r) => r.json()),
31
+ * intervalMs: 5000,
32
+ * map: (status) => ({ panels: toPanels(status), chips: toChips(status) }),
33
+ * });
34
+ * onDestroy(feed.dispose);
35
+ * </script>
36
+ *
37
+ * <SystemStatusChips chips={feed.chips} />
38
+ * <SystemScopePanel panels={feed.panels} />
39
+ * ```
40
+ */
41
+ import type { ShellStatusChip, ShellSystemPanel } from '../admin-shell/types.js';
42
+ /** Lifecycle phase of a {@link SystemFeedController}. */
43
+ export type SystemFeedStatus = 'idle' | 'loading' | 'success' | 'error' | 'stopped';
44
+ /**
45
+ * The shell-facing shape produced by {@link SystemFeedOptions.map}. Either half
46
+ * is optional so a feed can drive only the panels, only the chips, or both.
47
+ */
48
+ export interface SystemFeedView {
49
+ panels?: ShellSystemPanel[];
50
+ chips?: ShellStatusChip[];
51
+ }
52
+ /** Options for {@link systemFeed}. */
53
+ export interface SystemFeedOptions<T> {
54
+ /**
55
+ * App-supplied async loader. Called on each tick (and once immediately unless
56
+ * `immediate` is `false`). Typically wraps `fetch('/…/status')`. Its resolved
57
+ * value is handed to {@link SystemFeedOptions.map}. Rejections are caught and
58
+ * surfaced via `error` / `onError` without stopping the poll loop.
59
+ */
60
+ fetch: (signal: AbortSignal) => T | Promise<T>;
61
+ /**
62
+ * Maps a raw `fetch` result into the shell presentation contracts. Runs
63
+ * inside the same try/catch as `fetch`, so a throwing mapper is treated as a
64
+ * failed tick (tolerated, loop continues).
65
+ */
66
+ map: (data: T) => SystemFeedView;
67
+ /**
68
+ * Poll interval in milliseconds. Values `<= 0` disable the timer (single
69
+ * fetch only, useful for tests / manual `refresh()`). Default `5000`.
70
+ */
71
+ intervalMs?: number;
72
+ /**
73
+ * Fetch once as soon as the feed is created. Default `true`. When `false`,
74
+ * nothing is fetched until the first timer tick or an explicit `refresh()`.
75
+ */
76
+ immediate?: boolean;
77
+ /**
78
+ * Skip ticks while `document.hidden` is true and refresh once on the next
79
+ * `visibilitychange` back to visible. Avoids polling a backgrounded tab.
80
+ * Default `true`. Ignored (treated as `false`) when there is no `document`
81
+ * (SSR / non-DOM environments).
82
+ */
83
+ pauseWhenHidden?: boolean;
84
+ /** Notified on every caught fetch/map error. Never throws into the loop. */
85
+ onError?: (error: unknown) => void;
86
+ }
87
+ /**
88
+ * Reactive controller returned by {@link systemFeed}. `panels` / `chips` are
89
+ * `$state`-backed getters safe to bind straight to `SystemScopePanel` /
90
+ * `SystemStatusChips`. Call {@link SystemFeedController.dispose} (e.g. from
91
+ * `onDestroy`) to stop the timer and detach listeners — this is the disposer.
92
+ */
93
+ export interface SystemFeedController {
94
+ /** Latest mapped panels; `[]` until the first successful tick. */
95
+ readonly panels: ShellSystemPanel[];
96
+ /** Latest mapped chips; `[]` until the first successful tick. */
97
+ readonly chips: ShellStatusChip[];
98
+ /** Lifecycle phase of the most recent tick. */
99
+ readonly status: SystemFeedStatus;
100
+ /** The most recent caught error, or `null` after any success. */
101
+ readonly error: unknown;
102
+ /**
103
+ * Whether the poll timer is currently armed. Stays `false` for single-shot
104
+ * feeds (`intervalMs <= 0`), under SSR (no `document`), and after `stop()` /
105
+ * `dispose()`.
106
+ */
107
+ readonly running: boolean;
108
+ /** Fetch + map once, off-schedule. Resolves after state is updated. */
109
+ refresh(): Promise<void>;
110
+ /**
111
+ * (Re)arm the poll timer. Idempotent. No-op when already running, after
112
+ * {@link dispose}, when `intervalMs <= 0`, or under SSR (no `document`).
113
+ */
114
+ start(): void;
115
+ /** Disarm the poll timer without tearing down listeners. Idempotent. */
116
+ stop(): void;
117
+ /** Stop the timer, detach listeners, abort any in-flight fetch. Terminal. */
118
+ dispose(): void;
119
+ }
120
+ /**
121
+ * Create a polling feed that maps an app status endpoint into the AdminShell
122
+ * system-scope presentation contracts.
123
+ *
124
+ * The returned {@link SystemFeedController} exposes reactive `panels` / `chips`
125
+ * and a `dispose()` disposer. Bind the getters to the shell components and call
126
+ * `dispose()` on teardown.
127
+ */
128
+ export declare function systemFeed<T>(options: SystemFeedOptions<T>): SystemFeedController;
129
+ //# sourceMappingURL=system-feed.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"system-feed.svelte.d.ts","sourceRoot":"","sources":["../../../../src/components/workspace/live/system-feed.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EACjB,MAAM,yBAAyB,CAAC;AAEjC,yDAAyD;AACzD,MAAM,MAAM,gBAAgB,GACxB,MAAM,GACN,SAAS,GACT,SAAS,GACT,OAAO,GACP,SAAS,CAAC;AAEd;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC5B,KAAK,CAAC,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,sCAAsC;AACtC,MAAM,WAAW,iBAAiB,CAAC,CAAC;IAClC;;;;;OAKG;IACH,KAAK,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC/C;;;;OAIG;IACH,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,cAAc,CAAC;IACjC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAED;;;;;GAKG;AACH,MAAM,WAAW,oBAAoB;IACnC,kEAAkE;IAClE,QAAQ,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC;IACpC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,eAAe,EAAE,CAAC;IAClC,+CAA+C;IAC/C,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,uEAAuE;IACvE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB;;;OAGG;IACH,KAAK,IAAI,IAAI,CAAC;IACd,wEAAwE;IACxE,IAAI,IAAI,IAAI,CAAC;IACb,6EAA6E;IAC7E,OAAO,IAAI,IAAI,CAAC;CACjB;AAID;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAC1B,OAAO,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAC5B,oBAAoB,CA6ItB"}
@@ -0,0 +1,201 @@
1
+ /**
2
+ * `systemFeed` — a transport-light polling helper for the AdminShell system
3
+ * scope (issue #1774, epic #1766).
4
+ *
5
+ * The System edge presents jobs / schedules / dispatch — data that lives in the
6
+ * `_smrt_*` SYSTEM tables. Those tables are intentionally **excluded** from the
7
+ * core change-feed (`packages/core/src/change-feed.ts`) and are **not** surfaced
8
+ * as `smrt-web` collections, so the live-collection path used by tenant/app
9
+ * data does not apply here. Instead, an app exposes its own status endpoint
10
+ * (a `+server.ts` that reads `_smrt_*` through the `smrt-jobs` query API) and
11
+ * this helper polls it on an interval, mapping the response into the
12
+ * presentation contracts the shell already renders:
13
+ * `ShellSystemPanel[]` (for `SystemScopePanel`) and `ShellStatusChip[]`
14
+ * (for `SystemStatusChips`).
15
+ *
16
+ * This module is deliberately kept OUT of the `./workspace` presentation barrel
17
+ * and the `./web` (smrt-web / TanStack) entry: it is opt-in via the
18
+ * `@happyvertical/smrt-svelte/workspace/live` subpath and pulls no runtime
19
+ * dependency beyond Svelte's reactivity. It only imports the shell's *types*.
20
+ *
21
+ * @example
22
+ * ```svelte
23
+ * <script lang="ts">
24
+ * import { systemFeed } from '@happyvertical/smrt-svelte/workspace/live';
25
+ * import { SystemScopePanel, SystemStatusChips } from
26
+ * '@happyvertical/smrt-svelte/workspace';
27
+ * import { onDestroy } from 'svelte';
28
+ *
29
+ * const feed = systemFeed({
30
+ * fetch: () => fetch('/admin/system/status').then((r) => r.json()),
31
+ * intervalMs: 5000,
32
+ * map: (status) => ({ panels: toPanels(status), chips: toChips(status) }),
33
+ * });
34
+ * onDestroy(feed.dispose);
35
+ * </script>
36
+ *
37
+ * <SystemStatusChips chips={feed.chips} />
38
+ * <SystemScopePanel panels={feed.panels} />
39
+ * ```
40
+ */
41
+ const DEFAULT_INTERVAL_MS = 5000;
42
+ /**
43
+ * Create a polling feed that maps an app status endpoint into the AdminShell
44
+ * system-scope presentation contracts.
45
+ *
46
+ * The returned {@link SystemFeedController} exposes reactive `panels` / `chips`
47
+ * and a `dispose()` disposer. Bind the getters to the shell components and call
48
+ * `dispose()` on teardown.
49
+ */
50
+ export function systemFeed(options) {
51
+ const { fetch: fetcher, map, intervalMs = DEFAULT_INTERVAL_MS, immediate = true, pauseWhenHidden = true, onError, } = options;
52
+ let panels = $state([]);
53
+ let chips = $state([]);
54
+ let status = $state('idle');
55
+ let error = $state(null);
56
+ let running = $state(false);
57
+ // Non-reactive machinery.
58
+ let timer = null;
59
+ let inFlight = null;
60
+ let disposed = false;
61
+ // Guards against a slow tick resolving after a newer one (or after dispose)
62
+ // and clobbering fresher state.
63
+ let generation = 0;
64
+ const hasDocument = typeof document !== 'undefined';
65
+ const usesVisibility = pauseWhenHidden && hasDocument;
66
+ async function tick() {
67
+ if (disposed)
68
+ return;
69
+ // When hidden, skip the network entirely; the visibility listener will
70
+ // refresh on the way back to visible.
71
+ if (usesVisibility && document.hidden)
72
+ return;
73
+ const myGeneration = ++generation;
74
+ // Supersede any still-running fetch so its result can't land late.
75
+ inFlight?.abort();
76
+ const controller = new AbortController();
77
+ inFlight = controller;
78
+ status = 'loading';
79
+ try {
80
+ const raw = await fetcher(controller.signal);
81
+ if (disposed || myGeneration !== generation)
82
+ return;
83
+ const view = map(raw);
84
+ if (disposed || myGeneration !== generation)
85
+ return;
86
+ if (view.panels)
87
+ panels = view.panels;
88
+ if (view.chips)
89
+ chips = view.chips;
90
+ error = null;
91
+ status = 'success';
92
+ }
93
+ catch (caught) {
94
+ if (disposed || myGeneration !== generation)
95
+ return;
96
+ // An abort is an intentional supersede/teardown, not a feed failure.
97
+ if (isAbortError(caught))
98
+ return;
99
+ error = caught;
100
+ status = 'error';
101
+ // Contain a throwing consumer callback: it must not reject the tick and
102
+ // surface as an unhandled rejection from the interval / visibility path.
103
+ try {
104
+ onError?.(caught);
105
+ }
106
+ catch {
107
+ // Swallow — the feed's own error handling already recorded `caught`.
108
+ }
109
+ }
110
+ finally {
111
+ if (inFlight === controller)
112
+ inFlight = null;
113
+ }
114
+ }
115
+ function start() {
116
+ if (disposed || running)
117
+ return;
118
+ // Arm a timer only in a DOM environment (never spin a server-side interval
119
+ // during SSR) and only when an interval is requested. `running` reflects an
120
+ // actually-armed timer, so it stays false for single-shot feeds
121
+ // (`intervalMs <= 0`) and under SSR.
122
+ if (!hasDocument || intervalMs <= 0)
123
+ return;
124
+ running = true;
125
+ timer = setInterval(() => void tick(), intervalMs);
126
+ }
127
+ function stop() {
128
+ running = false;
129
+ if (timer !== null) {
130
+ clearInterval(timer);
131
+ timer = null;
132
+ }
133
+ }
134
+ function handleVisibility() {
135
+ // Only catch up on becoming visible while actively polling — a stopped
136
+ // (or disposed) feed must stay quiet.
137
+ if (disposed || !running)
138
+ return;
139
+ if (!document.hidden)
140
+ void tick();
141
+ }
142
+ function dispose() {
143
+ if (disposed)
144
+ return;
145
+ disposed = true;
146
+ stop();
147
+ // Invalidate any in-flight tick and abort its fetch.
148
+ generation++;
149
+ inFlight?.abort();
150
+ inFlight = null;
151
+ if (usesVisibility) {
152
+ document.removeEventListener('visibilitychange', handleVisibility);
153
+ }
154
+ status = 'stopped';
155
+ }
156
+ // Everything below activates the feed. Under SSR (no `document`) the feed is
157
+ // created inert — no listener, no immediate fetch, no timer — so a consumer
158
+ // can safely construct it in a component script; the client re-runs this and
159
+ // brings it to life. Callers can also drive an inert feed manually via
160
+ // `refresh()` / `start()`.
161
+ if (hasDocument) {
162
+ if (usesVisibility) {
163
+ document.addEventListener('visibilitychange', handleVisibility);
164
+ }
165
+ if (immediate)
166
+ void tick();
167
+ start();
168
+ }
169
+ return {
170
+ get panels() {
171
+ return panels;
172
+ },
173
+ get chips() {
174
+ return chips;
175
+ },
176
+ get status() {
177
+ return status;
178
+ },
179
+ get error() {
180
+ return error;
181
+ },
182
+ get running() {
183
+ return running;
184
+ },
185
+ refresh: tick,
186
+ start,
187
+ stop,
188
+ dispose,
189
+ };
190
+ }
191
+ function isAbortError(error) {
192
+ // Guard `DOMException` — it is undefined in some non-DOM runtimes, so a bare
193
+ // `instanceof` would throw a ReferenceError and defeat the SSR-safe contract.
194
+ if (typeof DOMException !== 'undefined' && error instanceof DOMException) {
195
+ return error.name === 'AbortError';
196
+ }
197
+ return (typeof error === 'object' &&
198
+ error !== null &&
199
+ 'name' in error &&
200
+ error.name === 'AbortError');
201
+ }
@@ -56,5 +56,21 @@ export declare const M: {
56
56
  readonly 'ui.system_scope_panel.no_running_work': "ui.system_scope_panel.no_running_work";
57
57
  readonly 'ui.system_status_chips.system_status': "ui.system_status_chips.system_status";
58
58
  readonly 'ui.tenant_nav.tenant_navigation': "ui.tenant_nav.tenant_navigation";
59
+ readonly 'ui.system_feed.title': "ui.system_feed.title";
60
+ readonly 'ui.system_feed.description': "ui.system_feed.description";
61
+ readonly 'ui.system_feed.pause': "ui.system_feed.pause";
62
+ readonly 'ui.system_feed.resume': "ui.system_feed.resume";
63
+ readonly 'ui.system_feed.refresh': "ui.system_feed.refresh";
64
+ readonly 'ui.system_feed.fail_next': "ui.system_feed.fail_next";
65
+ readonly 'ui.system_feed.status_label': "ui.system_feed.status_label";
66
+ readonly 'ui.system_feed.tick_label': "ui.system_feed.tick_label";
67
+ readonly 'ui.system_feed.error_label': "ui.system_feed.error_label";
68
+ readonly 'ui.system_feed.jobs_panel': "ui.system_feed.jobs_panel";
69
+ readonly 'ui.system_feed.schedules_panel': "ui.system_feed.schedules_panel";
70
+ readonly 'ui.system_feed.dispatch_panel': "ui.system_feed.dispatch_panel";
71
+ readonly 'ui.system_feed.chip_workers': "ui.system_feed.chip_workers";
72
+ readonly 'ui.system_feed.chip_running': "ui.system_feed.chip_running";
73
+ readonly 'ui.system_feed.chip_queued': "ui.system_feed.chip_queued";
74
+ readonly 'ui.system_feed.chip_failed': "ui.system_feed.chip_failed";
59
75
  };
60
76
  //# sourceMappingURL=strings.workspace.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"strings.workspace.d.ts","sourceRoot":"","sources":["../../src/i18n/strings.workspace.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwDZ,CAAC"}
1
+ {"version":3,"file":"strings.workspace.d.ts","sourceRoot":"","sources":["../../src/i18n/strings.workspace.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2EZ,CAAC"}
@@ -61,4 +61,21 @@ export const M = defineMessages({
61
61
  'ui.system_scope_panel.no_running_work': 'No running work',
62
62
  'ui.system_status_chips.system_status': 'System status',
63
63
  'ui.tenant_nav.tenant_navigation': 'Tenant navigation',
64
+ // components/workspace/live/* (systemFeed demo — issue #1774)
65
+ 'ui.system_feed.title': 'System feed',
66
+ 'ui.system_feed.description': 'Polls an app-provided status endpoint and maps it into the system-scope panels and status chips.',
67
+ 'ui.system_feed.pause': 'Pause polling',
68
+ 'ui.system_feed.resume': 'Resume polling',
69
+ 'ui.system_feed.refresh': 'Refresh now',
70
+ 'ui.system_feed.fail_next': 'Fail next fetch',
71
+ 'ui.system_feed.status_label': 'Feed status',
72
+ 'ui.system_feed.tick_label': 'Ticks',
73
+ 'ui.system_feed.error_label': 'Last error',
74
+ 'ui.system_feed.jobs_panel': 'Jobs',
75
+ 'ui.system_feed.schedules_panel': 'Schedules',
76
+ 'ui.system_feed.dispatch_panel': 'Dispatch',
77
+ 'ui.system_feed.chip_workers': 'Workers',
78
+ 'ui.system_feed.chip_running': 'Running',
79
+ 'ui.system_feed.chip_queued': 'Queued',
80
+ 'ui.system_feed.chip_failed': 'Failed',
64
81
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-svelte",
3
- "version": "0.38.1",
3
+ "version": "0.38.2",
4
4
  "description": "Svelte 5 components for SMRT user management - auth, users, tenants, roles, permissions, groups",
5
5
  "type": "module",
6
6
  "smrtRawPrimitives": "strict",
@@ -36,6 +36,12 @@
36
36
  "import": "./dist/components/workspace/server/index.js",
37
37
  "default": "./dist/components/workspace/server/index.js"
38
38
  },
39
+ "./workspace/live": {
40
+ "types": "./dist/components/workspace/live/index.d.ts",
41
+ "svelte": "./dist/components/workspace/live/index.js",
42
+ "import": "./dist/components/workspace/live/index.js",
43
+ "default": "./dist/components/workspace/live/index.js"
44
+ },
39
45
  "./browser-ai": {
40
46
  "types": "./dist/browser-ai/index.d.ts",
41
47
  "import": "./dist/browser-ai/index.js",
@@ -86,10 +92,10 @@
86
92
  "@tanstack/db": "^0.6.14",
87
93
  "@tanstack/svelte-db": "^0.1.91",
88
94
  "esm-env": "^1.2.2",
89
- "@happyvertical/smrt-languages": "0.38.1",
90
- "@happyvertical/smrt-types": "0.38.1",
91
- "@happyvertical/smrt-ui": "0.38.1",
92
- "@happyvertical/smrt-web": "0.38.1"
95
+ "@happyvertical/smrt-types": "0.38.2",
96
+ "@happyvertical/smrt-ui": "0.38.2",
97
+ "@happyvertical/smrt-web": "0.38.2",
98
+ "@happyvertical/smrt-languages": "0.38.2"
93
99
  },
94
100
  "peerDependencies": {
95
101
  "@huggingface/transformers": ">=3.8.1",