@happyvertical/smrt-svelte 0.38.1 → 0.38.3

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.
package/AGENTS.md CHANGED
@@ -263,6 +263,23 @@ compatibility.
263
263
 
264
264
  See `src/components/workspace/MIGRATION.md` for the old-to-new concept map.
265
265
 
266
+ ### Live activity feed adapter (`./web`, #1779)
267
+
268
+ `activityFeed({ collection, map, shell })` (from `@happyvertical/smrt-svelte/web`)
269
+ bridges a `@happyvertical/smrt-web` live collection into a `ShellState` activity
270
+ registry: it subscribes the collection through `liveCollection`, reactively maps
271
+ each row → `ShellActivity` via the app-supplied editorial `map`, and drives
272
+ `upsertActivity` / `updateActivity` / `removeActivity` as rows appear, change,
273
+ and vanish (a row mapping to `null` is excluded / retracted). Returns a disposer
274
+ that removes exactly the activities it created. Must be called during component
275
+ init (installs a `$effect`); the subscription tears down on unmount.
276
+
277
+ It lives behind the opt-in `./web` entry — which pulls the TanStack client-data
278
+ engine — and is **never** imported under `components/workspace/`, so the
279
+ AdminShell core (`./workspace`) stays transport-agnostic and TanStack-free (epic
280
+ #1766). The pure diff core is `ActivityFeedReconciler` (engine-free, unit-tested
281
+ against a real `ShellState`). Demo: `playground/.../admin-shell-activity-feed`.
282
+
266
283
  ### Dock availability gates (server-side)
267
284
 
268
285
  `ToolDef.gates?: string[]` declares the gates a tool must pass to be visible.
@@ -76,7 +76,7 @@ function checkExpanded(item: NavItem): boolean {
76
76
  */
77
77
  function checkEffectiveActive(item: NavItem): boolean {
78
78
  if (checkActive(item)) return true;
79
- if (collapsed && !!item.children?.length && checkParentActive(item))
79
+ if (collapsed && item.children?.length && checkParentActive(item))
80
80
  return true;
81
81
  return false;
82
82
  }
@@ -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`.