@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.
@@ -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,31 @@ 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.activity_feed.title': "ui.activity_feed.title";
60
+ readonly 'ui.activity_feed.description': "ui.activity_feed.description";
61
+ readonly 'ui.activity_feed.enqueue_job': "ui.activity_feed.enqueue_job";
62
+ readonly 'ui.activity_feed.advance_job': "ui.activity_feed.advance_job";
63
+ readonly 'ui.activity_feed.complete_job': "ui.activity_feed.complete_job";
64
+ readonly 'ui.activity_feed.fail_job': "ui.activity_feed.fail_job";
65
+ readonly 'ui.activity_feed.clear_finished': "ui.activity_feed.clear_finished";
66
+ readonly 'ui.activity_feed.rows_heading': "ui.activity_feed.rows_heading";
67
+ readonly 'ui.activity_feed.no_rows': "ui.activity_feed.no_rows";
68
+ readonly 'ui.activity_feed.job_label': "ui.activity_feed.job_label";
69
+ readonly 'ui.system_feed.title': "ui.system_feed.title";
70
+ readonly 'ui.system_feed.description': "ui.system_feed.description";
71
+ readonly 'ui.system_feed.pause': "ui.system_feed.pause";
72
+ readonly 'ui.system_feed.resume': "ui.system_feed.resume";
73
+ readonly 'ui.system_feed.refresh': "ui.system_feed.refresh";
74
+ readonly 'ui.system_feed.fail_next': "ui.system_feed.fail_next";
75
+ readonly 'ui.system_feed.status_label': "ui.system_feed.status_label";
76
+ readonly 'ui.system_feed.tick_label': "ui.system_feed.tick_label";
77
+ readonly 'ui.system_feed.error_label': "ui.system_feed.error_label";
78
+ readonly 'ui.system_feed.jobs_panel': "ui.system_feed.jobs_panel";
79
+ readonly 'ui.system_feed.schedules_panel': "ui.system_feed.schedules_panel";
80
+ readonly 'ui.system_feed.dispatch_panel': "ui.system_feed.dispatch_panel";
81
+ readonly 'ui.system_feed.chip_workers': "ui.system_feed.chip_workers";
82
+ readonly 'ui.system_feed.chip_running': "ui.system_feed.chip_running";
83
+ readonly 'ui.system_feed.chip_queued': "ui.system_feed.chip_queued";
84
+ readonly 'ui.system_feed.chip_failed': "ui.system_feed.chip_failed";
59
85
  };
60
86
  //# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwFZ,CAAC"}
@@ -61,4 +61,32 @@ 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
+ // web/activity-feed adapter demo (#1779) — playground/admin-shell-activity-feed
65
+ 'ui.activity_feed.title': 'Live activity feed',
66
+ 'ui.activity_feed.description': 'A smrt-web live collection reconciled into the shell activity registry.',
67
+ 'ui.activity_feed.enqueue_job': 'Enqueue job',
68
+ 'ui.activity_feed.advance_job': 'Advance job',
69
+ 'ui.activity_feed.complete_job': 'Complete job',
70
+ 'ui.activity_feed.fail_job': 'Fail job',
71
+ 'ui.activity_feed.clear_finished': 'Clear finished',
72
+ 'ui.activity_feed.rows_heading': 'Backing collection rows',
73
+ 'ui.activity_feed.no_rows': 'No rows yet',
74
+ 'ui.activity_feed.job_label': 'Render job',
75
+ // components/workspace/live/* (systemFeed demo — issue #1774)
76
+ 'ui.system_feed.title': 'System feed',
77
+ 'ui.system_feed.description': 'Polls an app-provided status endpoint and maps it into the system-scope panels and status chips.',
78
+ 'ui.system_feed.pause': 'Pause polling',
79
+ 'ui.system_feed.resume': 'Resume polling',
80
+ 'ui.system_feed.refresh': 'Refresh now',
81
+ 'ui.system_feed.fail_next': 'Fail next fetch',
82
+ 'ui.system_feed.status_label': 'Feed status',
83
+ 'ui.system_feed.tick_label': 'Ticks',
84
+ 'ui.system_feed.error_label': 'Last error',
85
+ 'ui.system_feed.jobs_panel': 'Jobs',
86
+ 'ui.system_feed.schedules_panel': 'Schedules',
87
+ 'ui.system_feed.dispatch_panel': 'Dispatch',
88
+ 'ui.system_feed.chip_workers': 'Workers',
89
+ 'ui.system_feed.chip_running': 'Running',
90
+ 'ui.system_feed.chip_queued': 'Queued',
91
+ 'ui.system_feed.chip_failed': 'Failed',
64
92
  });
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Integration harness — invokes activityFeed() inside a component-init scope so
4
+ * the `$effect` it (and the underlying `liveCollection`) installs wires up to a
5
+ * real component lifecycle, then hands the feed handle back to the test.
6
+ */
7
+ import type { SmrtWebCollection } from '@happyvertical/smrt-web';
8
+ import type { ShellState } from '../../components/workspace/index.js';
9
+ import {
10
+ type ActivityFeedHandle,
11
+ type ActivityFeedMap,
12
+ activityFeed,
13
+ } from '../activity-feed.svelte.js';
14
+
15
+ interface Props {
16
+ collection: SmrtWebCollection<Record<string, unknown>>;
17
+ shell: ShellState;
18
+ map: ActivityFeedMap<Record<string, unknown>>;
19
+ onReady: (handle: ActivityFeedHandle) => void;
20
+ }
21
+
22
+ const props: Props = $props();
23
+ // This harness intentionally snapshots its props during setup.
24
+ // svelte-ignore state_referenced_locally
25
+ const handle = activityFeed({
26
+ collection: props.collection,
27
+ shell: props.shell,
28
+ map: props.map,
29
+ });
30
+ // svelte-ignore state_referenced_locally
31
+ props.onReady(handle);
32
+ </script>
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Integration harness — invokes activityFeed() inside a component-init scope so
3
+ * the `$effect` it (and the underlying `liveCollection`) installs wires up to a
4
+ * real component lifecycle, then hands the feed handle back to the test.
5
+ */
6
+ import type { SmrtWebCollection } from '@happyvertical/smrt-web';
7
+ import type { ShellState } from '../../components/workspace/index.js';
8
+ import { type ActivityFeedHandle, type ActivityFeedMap } from '../activity-feed.svelte.js';
9
+ interface Props {
10
+ collection: SmrtWebCollection<Record<string, unknown>>;
11
+ shell: ShellState;
12
+ map: ActivityFeedMap<Record<string, unknown>>;
13
+ onReady: (handle: ActivityFeedHandle) => void;
14
+ }
15
+ declare const ActivityFeedHarness: import("svelte").Component<Props, {}, "">;
16
+ type ActivityFeedHarness = ReturnType<typeof ActivityFeedHarness>;
17
+ export default ActivityFeedHarness;
18
+ //# sourceMappingURL=activity-feed-harness.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"activity-feed-harness.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/activity-feed-harness.svelte.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qCAAqC,CAAC;AACtE,OAAO,EACL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EAErB,MAAM,4BAA4B,CAAC;AAGpC,UAAU,KAAK;IACb,UAAU,EAAE,iBAAiB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACvD,KAAK,EAAE,UAAU,CAAC;IAClB,GAAG,EAAE,eAAe,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC9C,OAAO,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,IAAI,CAAC;CAC/C;AAsBD,QAAA,MAAM,mBAAmB,2CAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Integration test for the AdminShell activity feed adapter (#1779) end to end.
3
+ *
4
+ * Unlike the reconciler unit tests (which feed plain arrays), this exercises the
5
+ * FULL stack — `activityFeed` → `liveCollection` → the real `@happyvertical/smrt-web`
6
+ * collection with its client-data engine — through a mounted harness, so the
7
+ * `$effect` that drives reconciliation has a genuine component-init scope. Only
8
+ * the network boundary is mocked (the generated REST fetchers passed to
9
+ * `createSmrtCollection`); the engine, live view, runes reactivity, and the real
10
+ * `ShellState` are all live. This proves the reactive wiring drives the shell:
11
+ * a loaded row and an optimistic insert both surface as shell activities.
12
+ */
13
+ import { createSmrtCollection, } from '@happyvertical/smrt-web';
14
+ import { flushSync, mount, unmount } from 'svelte';
15
+ import { afterEach, describe, expect, it } from 'vitest';
16
+ import { createShellState } from '../../components/workspace/admin-shell/state.svelte.js';
17
+ import Harness from './activity-feed-harness.svelte';
18
+ function jobDefinition(name) {
19
+ return {
20
+ name,
21
+ className: 'RenderJob',
22
+ endpoint: `/${name}`,
23
+ idField: 'id',
24
+ actions: ['create', 'get', 'list'],
25
+ fields: {
26
+ title: { type: 'text', required: true },
27
+ state: { type: 'text' },
28
+ },
29
+ };
30
+ }
31
+ /** Scripted stand-in for the generated REST fetchers (see live-collection test). */
32
+ function makeScriptedFetchers(initialRows) {
33
+ const serverRows = [...initialRows];
34
+ const fetchers = {
35
+ list: async () => serverRows.map((row) => ({ ...row })),
36
+ create: async (data) => {
37
+ const created = { ...data, id: `server-${serverRows.length + 1}` };
38
+ serverRows.push(created);
39
+ return { ...created };
40
+ },
41
+ };
42
+ return { fetchers };
43
+ }
44
+ const mapJob = (row) => ({
45
+ kind: 'render-job',
46
+ scope: 'focus',
47
+ label: row.title,
48
+ status: row.state ?? 'running',
49
+ });
50
+ function mountFeed(collection, shell) {
51
+ const target = document.createElement('div');
52
+ document.body.appendChild(target);
53
+ let exposed = undefined;
54
+ const component = mount(Harness, {
55
+ target,
56
+ props: {
57
+ // The harness is typed against the base object shape; JobData is a
58
+ // structural subtype, so this cross-cast is purely nominal.
59
+ collection: collection,
60
+ shell,
61
+ map: mapJob,
62
+ onReady: (handle) => {
63
+ exposed = handle;
64
+ },
65
+ },
66
+ });
67
+ return {
68
+ handle: exposed,
69
+ teardown: () => {
70
+ unmount(component);
71
+ target.remove();
72
+ },
73
+ };
74
+ }
75
+ describe('activityFeed (integration)', () => {
76
+ const cleanup = [];
77
+ afterEach(() => {
78
+ for (const fn of cleanup.splice(0))
79
+ fn();
80
+ });
81
+ it('reconciles loaded collection rows into shell activities', async () => {
82
+ const scripted = makeScriptedFetchers([
83
+ { id: 'job-1', title: 'Render intro', state: 'running' },
84
+ { id: 'job-2', title: 'Render outro', state: 'queued' },
85
+ ]);
86
+ const collection = createSmrtCollection(jobDefinition('feed-load'), {
87
+ fetchers: scripted.fetchers,
88
+ staleTimeMs: 60_000,
89
+ });
90
+ const shell = createShellState();
91
+ const mounted = mountFeed(collection, shell);
92
+ cleanup.push(mounted.teardown);
93
+ await collection.preload();
94
+ flushSync();
95
+ const activities = shell.listActivities();
96
+ expect(activities.map((a) => a.id).sort()).toEqual(['job-1', 'job-2']);
97
+ expect(activities.every((a) => a.kind === 'render-job')).toBe(true);
98
+ // Homed to the focus edge (right) per the map's scope.
99
+ expect(shell.listActivities({ edge: 'right' })).toHaveLength(2);
100
+ });
101
+ it('surfaces an optimistic insert as a shell activity reactively', async () => {
102
+ const scripted = makeScriptedFetchers([
103
+ { id: 'job-1', title: 'Render intro', state: 'running' },
104
+ ]);
105
+ const collection = createSmrtCollection(jobDefinition('feed-insert'), {
106
+ fetchers: scripted.fetchers,
107
+ staleTimeMs: 60_000,
108
+ });
109
+ const shell = createShellState();
110
+ const mounted = mountFeed(collection, shell);
111
+ cleanup.push(mounted.teardown);
112
+ await collection.preload();
113
+ flushSync();
114
+ expect(shell.listActivities()).toHaveLength(1);
115
+ // An optimistic insert appears in the live view synchronously → the feed
116
+ // reconciles it into the shell on the reactive tick.
117
+ const tx = collection.insert({
118
+ id: 'local-1',
119
+ title: 'Render credits',
120
+ state: 'running',
121
+ });
122
+ flushSync();
123
+ expect(shell
124
+ .listActivities()
125
+ .map((a) => a.label)
126
+ .sort()).toEqual(['Render credits', 'Render intro']);
127
+ // Once persisted, the server-assigned row replaces the optimistic one; the
128
+ // activity for the temp id is removed and the server row's activity remains.
129
+ await tx.isPersisted.promise;
130
+ flushSync();
131
+ const labels = shell
132
+ .listActivities()
133
+ .map((a) => a.label)
134
+ .sort();
135
+ expect(labels).toEqual(['Render credits', 'Render intro']);
136
+ // The optimistic-id activity is gone; the server id is present.
137
+ const ids = shell.listActivities().map((a) => a.id);
138
+ expect(ids).toContain('server-2');
139
+ expect(ids).not.toContain('local-1');
140
+ });
141
+ it('dispose() retracts the feed activities from the shell', async () => {
142
+ const scripted = makeScriptedFetchers([
143
+ { id: 'job-1', title: 'Render intro', state: 'running' },
144
+ ]);
145
+ const collection = createSmrtCollection(jobDefinition('feed-dispose'), {
146
+ fetchers: scripted.fetchers,
147
+ staleTimeMs: 60_000,
148
+ });
149
+ const shell = createShellState();
150
+ const mounted = mountFeed(collection, shell);
151
+ cleanup.push(mounted.teardown);
152
+ await collection.preload();
153
+ flushSync();
154
+ expect(shell.listActivities()).toHaveLength(1);
155
+ mounted.handle.dispose();
156
+ expect(shell.listActivities()).toHaveLength(0);
157
+ expect(mounted.handle.isDisposed).toBe(true);
158
+ });
159
+ it('auto-retracts the feed activities when the host component unmounts', async () => {
160
+ const scripted = makeScriptedFetchers([
161
+ { id: 'job-1', title: 'Render intro', state: 'running' },
162
+ { id: 'job-2', title: 'Render outro', state: 'queued' },
163
+ ]);
164
+ const collection = createSmrtCollection(jobDefinition('feed-unmount'), {
165
+ fetchers: scripted.fetchers,
166
+ staleTimeMs: 60_000,
167
+ });
168
+ const shell = createShellState();
169
+ // An activity from another source must survive the feed's unmount.
170
+ shell.upsertActivity({
171
+ id: 'external-1',
172
+ label: 'External',
173
+ kind: 'other',
174
+ scope: 'app',
175
+ status: 'running',
176
+ });
177
+ const mounted = mountFeed(collection, shell);
178
+ await collection.preload();
179
+ flushSync();
180
+ expect(shell.listActivities()).toHaveLength(3);
181
+ // Unmount WITHOUT calling dispose() manually: the adapter's $effect teardown
182
+ // must retract exactly its own activities, leaving the external one intact.
183
+ mounted.teardown();
184
+ flushSync();
185
+ const remaining = shell.listActivities();
186
+ expect(remaining).toHaveLength(1);
187
+ expect(remaining[0].id).toBe('external-1');
188
+ });
189
+ });