@happyvertical/smrt-svelte 0.38.2 → 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 +17 -0
- package/dist/components/workspace/NavTree.svelte +1 -1
- package/dist/i18n/strings.workspace.d.ts +10 -0
- package/dist/i18n/strings.workspace.d.ts.map +1 -1
- package/dist/i18n/strings.workspace.js +11 -0
- package/dist/web/__tests__/activity-feed-harness.svelte +32 -0
- package/dist/web/__tests__/activity-feed-harness.svelte.d.ts +18 -0
- package/dist/web/__tests__/activity-feed-harness.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/activity-feed.integration.svelte.test.js +189 -0
- package/dist/web/__tests__/activity-feed.svelte.test.js +235 -0
- package/dist/web/activity-feed.svelte.d.ts +210 -0
- package/dist/web/activity-feed.svelte.d.ts.map +1 -0
- package/dist/web/activity-feed.svelte.js +302 -0
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +6 -0
- package/package.json +7 -7
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 &&
|
|
79
|
+
if (collapsed && item.children?.length && checkParentActive(item))
|
|
80
80
|
return true;
|
|
81
81
|
return false;
|
|
82
82
|
}
|
|
@@ -56,6 +56,16 @@ 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";
|
|
59
69
|
readonly 'ui.system_feed.title': "ui.system_feed.title";
|
|
60
70
|
readonly 'ui.system_feed.description': "ui.system_feed.description";
|
|
61
71
|
readonly 'ui.system_feed.pause': "ui.system_feed.pause";
|
|
@@ -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
|
|
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,6 +61,17 @@ 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',
|
|
64
75
|
// components/workspace/live/* (systemFeed demo — issue #1774)
|
|
65
76
|
'ui.system_feed.title': 'System feed',
|
|
66
77
|
'ui.system_feed.description': 'Polls an app-provided status endpoint and maps it into the system-scope panels and status chips.',
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the AdminShell activity feed adapter's reconciliation core
|
|
3
|
+
* (#1779).
|
|
4
|
+
*
|
|
5
|
+
* These exercise {@link ActivityFeedReconciler} — the pure diff between a set of
|
|
6
|
+
* live rows and the shell activity registry — against a REAL {@link ShellState}.
|
|
7
|
+
* Per repo policy nothing here is mocked: the shell, its activity events, and
|
|
8
|
+
* the mapping are all real; the reconciler is fed plain row arrays that stand in
|
|
9
|
+
* for successive live-collection ticks (`liveCollection`'s engine is exercised
|
|
10
|
+
* separately in `activity-feed.integration.svelte.test.ts`). This isolates the
|
|
11
|
+
* mapping / create-update-remove / ownership behavior the adapter guarantees.
|
|
12
|
+
*/
|
|
13
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
14
|
+
import { createShellState } from '../../components/workspace/admin-shell/state.svelte.js';
|
|
15
|
+
import { ActivityFeedReconciler, } from '../activity-feed.svelte.js';
|
|
16
|
+
/** Editorial map: a job row → focus-scope activity, or `null` for a draft. */
|
|
17
|
+
const mapJob = (row) => {
|
|
18
|
+
if (row.draft)
|
|
19
|
+
return null;
|
|
20
|
+
return {
|
|
21
|
+
kind: 'render-job',
|
|
22
|
+
scope: 'focus',
|
|
23
|
+
label: row.title,
|
|
24
|
+
status: row.state,
|
|
25
|
+
progress: row.progress ?? null,
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** Record every activity event the shell emits, for sequence assertions. */
|
|
29
|
+
function recordEvents(shell) {
|
|
30
|
+
const events = [];
|
|
31
|
+
const stop = shell.watchActivities((event) => events.push(event));
|
|
32
|
+
return { events, stop };
|
|
33
|
+
}
|
|
34
|
+
describe('ActivityFeedReconciler', () => {
|
|
35
|
+
const teardown = [];
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
for (const fn of teardown.splice(0))
|
|
38
|
+
fn();
|
|
39
|
+
});
|
|
40
|
+
function setup() {
|
|
41
|
+
const shell = createShellState();
|
|
42
|
+
const recorded = recordEvents(shell);
|
|
43
|
+
teardown.push(recorded.stop);
|
|
44
|
+
const reconciler = new ActivityFeedReconciler(shell, mapJob);
|
|
45
|
+
return { shell, reconciler, events: recorded.events };
|
|
46
|
+
}
|
|
47
|
+
it('upserts a shell activity when a row first appears', () => {
|
|
48
|
+
const { shell, reconciler, events } = setup();
|
|
49
|
+
reconciler.reconcile([
|
|
50
|
+
{ id: 'job-1', title: 'Render intro', state: 'running', progress: 20 },
|
|
51
|
+
]);
|
|
52
|
+
const activities = shell.listActivities();
|
|
53
|
+
expect(activities).toHaveLength(1);
|
|
54
|
+
expect(activities[0]).toMatchObject({
|
|
55
|
+
id: 'job-1',
|
|
56
|
+
kind: 'render-job',
|
|
57
|
+
scope: 'focus',
|
|
58
|
+
label: 'Render intro',
|
|
59
|
+
status: 'running',
|
|
60
|
+
progress: 20,
|
|
61
|
+
});
|
|
62
|
+
// The activity id defaults to the row id, and the shell stamped createdAt.
|
|
63
|
+
expect(activities[0].createdAt).toBeTruthy();
|
|
64
|
+
// A single create event (upsert), no transition/remove.
|
|
65
|
+
expect(events.map((e) => e.type)).toEqual(['upsert']);
|
|
66
|
+
});
|
|
67
|
+
it('updates the activity in place when a row changes (status transition)', () => {
|
|
68
|
+
const { shell, reconciler, events } = setup();
|
|
69
|
+
reconciler.reconcile([
|
|
70
|
+
{ id: 'job-1', title: 'Render intro', state: 'running', progress: 20 },
|
|
71
|
+
]);
|
|
72
|
+
const createdAt = shell.listActivities()[0].createdAt;
|
|
73
|
+
// Same row id, advanced progress + status flip → an UPDATE, not a second
|
|
74
|
+
// create, and the shell reports it as a `transition` (status changed).
|
|
75
|
+
reconciler.reconcile([
|
|
76
|
+
{ id: 'job-1', title: 'Render intro', state: 'completed', progress: 100 },
|
|
77
|
+
]);
|
|
78
|
+
const activities = shell.listActivities();
|
|
79
|
+
expect(activities).toHaveLength(1);
|
|
80
|
+
expect(activities[0]).toMatchObject({
|
|
81
|
+
id: 'job-1',
|
|
82
|
+
status: 'completed',
|
|
83
|
+
progress: 100,
|
|
84
|
+
});
|
|
85
|
+
// createdAt preserved across the update (update went through updateActivity,
|
|
86
|
+
// not a fresh upsert that would re-stamp it).
|
|
87
|
+
expect(activities[0].createdAt).toBe(createdAt);
|
|
88
|
+
expect(events.map((e) => e.type)).toEqual(['upsert', 'transition']);
|
|
89
|
+
});
|
|
90
|
+
it('performs no shell mutation when a reconcile tick is unchanged', () => {
|
|
91
|
+
const { reconciler, events } = setup();
|
|
92
|
+
const rows = [
|
|
93
|
+
{ id: 'job-1', title: 'Render intro', state: 'running', progress: 20 },
|
|
94
|
+
];
|
|
95
|
+
reconciler.reconcile(rows);
|
|
96
|
+
// Re-feed identical content (a common no-op reactive tick): a new array with
|
|
97
|
+
// an equal-but-distinct row object. Must not emit a second event.
|
|
98
|
+
reconciler.reconcile([{ ...rows[0] }]);
|
|
99
|
+
expect(events.map((e) => e.type)).toEqual(['upsert']);
|
|
100
|
+
});
|
|
101
|
+
it('removes the activity when its row vanishes from the collection', () => {
|
|
102
|
+
const { shell, reconciler, events } = setup();
|
|
103
|
+
reconciler.reconcile([
|
|
104
|
+
{ id: 'job-1', title: 'Render intro', state: 'running' },
|
|
105
|
+
{ id: 'job-2', title: 'Render outro', state: 'queued' },
|
|
106
|
+
]);
|
|
107
|
+
expect(shell.listActivities()).toHaveLength(2);
|
|
108
|
+
// job-1 gone from the next tick → removed; job-2 unchanged → left alone.
|
|
109
|
+
reconciler.reconcile([
|
|
110
|
+
{ id: 'job-2', title: 'Render outro', state: 'queued' },
|
|
111
|
+
]);
|
|
112
|
+
const remaining = shell.listActivities();
|
|
113
|
+
expect(remaining).toHaveLength(1);
|
|
114
|
+
expect(remaining[0].id).toBe('job-2');
|
|
115
|
+
expect(events.map((e) => e.type)).toEqual([
|
|
116
|
+
'upsert', // job-1 create
|
|
117
|
+
'upsert', // job-2 create
|
|
118
|
+
'remove', // job-1 removed
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
it('treats a row that maps to null as absent (excluded, then removed when it flips)', () => {
|
|
122
|
+
const { shell, reconciler } = setup();
|
|
123
|
+
// A draft row maps to null → never becomes an activity.
|
|
124
|
+
reconciler.reconcile([
|
|
125
|
+
{ id: 'job-1', title: 'Draft', state: 'queued', draft: true },
|
|
126
|
+
]);
|
|
127
|
+
expect(shell.listActivities()).toHaveLength(0);
|
|
128
|
+
// It leaves draft → now an activity appears.
|
|
129
|
+
reconciler.reconcile([
|
|
130
|
+
{ id: 'job-1', title: 'Published', state: 'running' },
|
|
131
|
+
]);
|
|
132
|
+
expect(shell.listActivities()).toHaveLength(1);
|
|
133
|
+
// It goes back to draft (maps to null again) → activity is removed.
|
|
134
|
+
reconciler.reconcile([
|
|
135
|
+
{ id: 'job-1', title: 'Published', state: 'running', draft: true },
|
|
136
|
+
]);
|
|
137
|
+
expect(shell.listActivities()).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
it('lets the map decouple the activity id from the row id', () => {
|
|
140
|
+
const shell = createShellState();
|
|
141
|
+
const reconciler = new ActivityFeedReconciler(shell, (row) => ({
|
|
142
|
+
id: `activity:${row.id}`,
|
|
143
|
+
kind: 'render-job',
|
|
144
|
+
scope: 'system',
|
|
145
|
+
label: row.title,
|
|
146
|
+
status: row.state,
|
|
147
|
+
}));
|
|
148
|
+
reconciler.reconcile([{ id: 'job-1', title: 'Job', state: 'running' }]);
|
|
149
|
+
const activities = shell.listActivities();
|
|
150
|
+
expect(activities).toHaveLength(1);
|
|
151
|
+
expect(activities[0].id).toBe('activity:job-1');
|
|
152
|
+
});
|
|
153
|
+
it('routes an activity to the shell edge for its scope', () => {
|
|
154
|
+
const { shell, reconciler } = setup();
|
|
155
|
+
reconciler.reconcile([{ id: 'job-1', title: 'Job', state: 'running' }]);
|
|
156
|
+
// scope 'focus' homes to the right edge (SCOPE_EDGES.focus === 'right').
|
|
157
|
+
expect(shell.listActivities({ edge: 'right' })).toHaveLength(1);
|
|
158
|
+
});
|
|
159
|
+
it('re-homes the activity to the new edge when its scope changes (no explicit edge)', () => {
|
|
160
|
+
const shell = createShellState();
|
|
161
|
+
// Map keys scope off the row so a later tick can change it; no `edge` set.
|
|
162
|
+
const reconciler = new ActivityFeedReconciler(shell, (row) => ({
|
|
163
|
+
kind: 'render-job',
|
|
164
|
+
// Reuse the unused `draft` flag as a cheap "move to system scope" toggle.
|
|
165
|
+
scope: row.draft ? 'system' : 'focus',
|
|
166
|
+
label: row.title,
|
|
167
|
+
status: row.state,
|
|
168
|
+
}));
|
|
169
|
+
reconciler.reconcile([{ id: 'job-1', title: 'Job', state: 'running' }]);
|
|
170
|
+
// Focus → right edge.
|
|
171
|
+
expect(shell.listActivities({ edge: 'right' })).toHaveLength(1);
|
|
172
|
+
expect(shell.listActivities({ edge: 'bottom' })).toHaveLength(0);
|
|
173
|
+
// Scope flips to system; the map still sets no explicit edge. The activity
|
|
174
|
+
// must RE-DERIVE its edge from the new scope (system → bottom), not stay
|
|
175
|
+
// pinned to the old right edge.
|
|
176
|
+
reconciler.reconcile([
|
|
177
|
+
{ id: 'job-1', title: 'Job', state: 'running', draft: true },
|
|
178
|
+
]);
|
|
179
|
+
expect(shell.listActivities({ edge: 'right' })).toHaveLength(0);
|
|
180
|
+
expect(shell.listActivities({ edge: 'bottom' })).toHaveLength(1);
|
|
181
|
+
expect(shell.listActivities()[0].scope).toBe('system');
|
|
182
|
+
});
|
|
183
|
+
it('clears a previously-set optional field when a later mapping drops it', () => {
|
|
184
|
+
const shell = createShellState();
|
|
185
|
+
// Map emits `message`/`detailHref` only while the job is running.
|
|
186
|
+
const reconciler = new ActivityFeedReconciler(shell, (row) => ({
|
|
187
|
+
kind: 'render-job',
|
|
188
|
+
scope: 'focus',
|
|
189
|
+
label: row.title,
|
|
190
|
+
status: row.state,
|
|
191
|
+
...(row.state === 'running'
|
|
192
|
+
? { message: 'Rendering frames', detailHref: '/jobs/job-1' }
|
|
193
|
+
: {}),
|
|
194
|
+
}));
|
|
195
|
+
reconciler.reconcile([{ id: 'job-1', title: 'Job', state: 'running' }]);
|
|
196
|
+
let activity = shell.listActivities()[0];
|
|
197
|
+
expect(activity.message).toBe('Rendering frames');
|
|
198
|
+
expect(activity.detailHref).toBe('/jobs/job-1');
|
|
199
|
+
// The job completes → the map no longer emits message/detailHref. Those
|
|
200
|
+
// stale values must be CLEARED, not retained from the previous mapping.
|
|
201
|
+
reconciler.reconcile([{ id: 'job-1', title: 'Job', state: 'completed' }]);
|
|
202
|
+
activity = shell.listActivities()[0];
|
|
203
|
+
expect(activity.status).toBe('completed');
|
|
204
|
+
expect(activity.message).toBeUndefined();
|
|
205
|
+
expect(activity.detailHref).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
it('dispose() removes exactly the feed activities and stops reconciling', () => {
|
|
208
|
+
const { shell, reconciler, events } = setup();
|
|
209
|
+
// An activity from ANOTHER source the feed must never touch.
|
|
210
|
+
shell.upsertActivity({
|
|
211
|
+
id: 'external-1',
|
|
212
|
+
label: 'External',
|
|
213
|
+
kind: 'other',
|
|
214
|
+
scope: 'app',
|
|
215
|
+
status: 'running',
|
|
216
|
+
});
|
|
217
|
+
reconciler.reconcile([
|
|
218
|
+
{ id: 'job-1', title: 'Render intro', state: 'running' },
|
|
219
|
+
]);
|
|
220
|
+
expect(shell.listActivities()).toHaveLength(2);
|
|
221
|
+
reconciler.dispose();
|
|
222
|
+
// Only the feed's own activity was retracted; the external one survives.
|
|
223
|
+
const remaining = shell.listActivities();
|
|
224
|
+
expect(remaining).toHaveLength(1);
|
|
225
|
+
expect(remaining[0].id).toBe('external-1');
|
|
226
|
+
expect(reconciler.isDisposed).toBe(true);
|
|
227
|
+
// Reconciling after dispose is a no-op (no new activities, no events).
|
|
228
|
+
const eventsBefore = events.length;
|
|
229
|
+
reconciler.reconcile([
|
|
230
|
+
{ id: 'job-2', title: 'Late job', state: 'running' },
|
|
231
|
+
]);
|
|
232
|
+
expect(shell.listActivities()).toHaveLength(1);
|
|
233
|
+
expect(events.length).toBe(eventsBefore);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminShell activity feed adapter — bridge a `@happyvertical/smrt-web` live
|
|
3
|
+
* collection into the AdminShell activity registry (#1779, part of the WASD
|
|
4
|
+
* AdminShell epic #1766).
|
|
5
|
+
*
|
|
6
|
+
* This is a thin, OPT-IN CONSUMER of already-shipped infrastructure, not a new
|
|
7
|
+
* generated surface. It sits at the intersection of two existing contracts:
|
|
8
|
+
*
|
|
9
|
+
* - the runes-reactive {@link liveCollection} view over a `SmrtWebCollection`
|
|
10
|
+
* (`@happyvertical/smrt-svelte/web`, slice A of #1761), and
|
|
11
|
+
* - the `ShellState` activity registry (`upsertActivity` / `updateActivity` /
|
|
12
|
+
* `removeActivity`, from `components/workspace/admin-shell`).
|
|
13
|
+
*
|
|
14
|
+
* The app supplies (a) the `@smrt()` DOMAIN collection (a `SmrtWebCollection`)
|
|
15
|
+
* and (b) an editorial {@link ActivityFeedMap} that turns each row into the
|
|
16
|
+
* shell-facing activity fields (`kind`, `scope`, `label`, `progress`, …). The
|
|
17
|
+
* adapter reconciles the mapped rows against the shell as rows appear, change,
|
|
18
|
+
* and vanish, and returns a disposer that removes the activities it created.
|
|
19
|
+
*
|
|
20
|
+
* ── Why it lives behind the `/web` opt-in entry ─────────────────────────────
|
|
21
|
+
* `liveCollection` pulls the client-data engine (`@tanstack/db` +
|
|
22
|
+
* `@tanstack/svelte-db`). Keeping this adapter in the `/web` subpath keeps that
|
|
23
|
+
* engine OUT of the AdminShell core: nothing under `components/workspace/` may
|
|
24
|
+
* import this module, so `@happyvertical/smrt-svelte/workspace` stays
|
|
25
|
+
* transport-agnostic and TanStack-free (a hard constraint of #1766). The app
|
|
26
|
+
* wires the adapter at the edge, next to where it already opts into `/web`.
|
|
27
|
+
*
|
|
28
|
+
* ── Engine-absorption boundary ──────────────────────────────────────────────
|
|
29
|
+
* No `@tanstack/*` type appears here. The adapter reconciles against the plain
|
|
30
|
+
* DTO rows exposed by {@link liveCollection} (which already strips the engine's
|
|
31
|
+
* `$`-prefixed virtual props), so the engine stays swappable behind the same
|
|
32
|
+
* boundary the rest of `/web` respects.
|
|
33
|
+
*
|
|
34
|
+
* ── Reconciliation, not change-diffing ──────────────────────────────────────
|
|
35
|
+
* Rather than decode the engine's change payload, the adapter re-derives the
|
|
36
|
+
* activity set from the current live rows on every reactive tick (via an
|
|
37
|
+
* internal `$effect` over `view.rows`) and diffs it against what it last pushed
|
|
38
|
+
* to the shell. This is robust to the change-notification shape (which is
|
|
39
|
+
* engine-internal) and mirrors how the runtime's own consumers treat a change
|
|
40
|
+
* as a "something moved, re-read" signal.
|
|
41
|
+
*/
|
|
42
|
+
import type { SmrtWebCollection, SmrtWebRow } from '@happyvertical/smrt-web';
|
|
43
|
+
import type { ShellState } from '../components/workspace/admin-shell/state.svelte.js';
|
|
44
|
+
import type { ShellActivity } from '../components/workspace/admin-shell/types.js';
|
|
45
|
+
/**
|
|
46
|
+
* The shell-facing fields an {@link ActivityFeedMap} produces for one row — the
|
|
47
|
+
* editorial half of a {@link ShellActivity}. The adapter owns the bookkeeping
|
|
48
|
+
* fields (`id`, `createdAt`, `updatedAt`), so they are omitted here; `id` may be
|
|
49
|
+
* supplied to decouple the activity id from the row id (it defaults to the row's
|
|
50
|
+
* `id`). Everything a consumer legitimately controls (`kind`, `scope`, `label`,
|
|
51
|
+
* `status`, `subject`, `progress`, `detailHref`, `message`, `edge`, `cancel`)
|
|
52
|
+
* passes straight through.
|
|
53
|
+
*/
|
|
54
|
+
export type ShellActivityInput = Omit<ShellActivity, 'id' | 'createdAt' | 'updatedAt'> & {
|
|
55
|
+
/**
|
|
56
|
+
* Explicit activity id. Defaults to the row's `id` when omitted — the common
|
|
57
|
+
* case (one activity per row). Supply it only to map a row onto a
|
|
58
|
+
* differently-keyed activity.
|
|
59
|
+
*/
|
|
60
|
+
id?: string;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Map one live-collection row to its shell activity fields, or `null` to
|
|
64
|
+
* exclude the row from the feed.
|
|
65
|
+
*
|
|
66
|
+
* Returning `null` is a first-class signal: a row that maps to `null` is NOT an
|
|
67
|
+
* activity (e.g. a draft the shell should ignore), and a row that STOPS mapping
|
|
68
|
+
* to an activity — flips from a value to `null` — is removed from the shell,
|
|
69
|
+
* exactly as if it had vanished from the collection. This lets the editorial map
|
|
70
|
+
* gate which rows surface without the consumer filtering the collection.
|
|
71
|
+
*
|
|
72
|
+
* @typeParam TData - the row DTO shape carried by the collection.
|
|
73
|
+
*/
|
|
74
|
+
export type ActivityFeedMap<TData extends object> = (row: SmrtWebRow<TData>) => ShellActivityInput | null;
|
|
75
|
+
/**
|
|
76
|
+
* Options for {@link activityFeed}.
|
|
77
|
+
*
|
|
78
|
+
* @typeParam TData - the row DTO shape carried by {@link collection}.
|
|
79
|
+
*/
|
|
80
|
+
export interface ActivityFeedOptions<TData extends object> {
|
|
81
|
+
/**
|
|
82
|
+
* The domain live collection to bridge — a `SmrtWebCollection` from
|
|
83
|
+
* `@happyvertical/smrt-web` (e.g. built by the app's `createSmrtCollection`
|
|
84
|
+
* over its `@smrt()` class). Its rows drive the feed.
|
|
85
|
+
*/
|
|
86
|
+
collection: SmrtWebCollection<TData>;
|
|
87
|
+
/**
|
|
88
|
+
* The editorial mapping from a row to its shell activity fields (or `null` to
|
|
89
|
+
* exclude the row). See {@link ActivityFeedMap}.
|
|
90
|
+
*/
|
|
91
|
+
map: ActivityFeedMap<TData>;
|
|
92
|
+
/** The AdminShell state whose activity registry this feed drives. */
|
|
93
|
+
shell: ShellState;
|
|
94
|
+
/**
|
|
95
|
+
* Forwarded to the underlying {@link liveCollection}: trigger the collection's
|
|
96
|
+
* first load eagerly when the feed is created. Defaults to `true`.
|
|
97
|
+
*/
|
|
98
|
+
preload?: boolean;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A handle to a running {@link activityFeed}. Call {@link dispose} to detach the
|
|
102
|
+
* feed and remove every activity it created; read {@link isDisposed} to check.
|
|
103
|
+
*/
|
|
104
|
+
export interface ActivityFeedHandle {
|
|
105
|
+
/**
|
|
106
|
+
* Detach the feed: remove every activity this feed still owns from the shell
|
|
107
|
+
* and stop reconciling. Idempotent. The underlying live-query subscription is
|
|
108
|
+
* torn down by Svelte when the hosting component unmounts (that is where
|
|
109
|
+
* `activityFeed` must be called); call this to remove the feed's activities
|
|
110
|
+
* sooner, or when tearing a feed down without unmounting.
|
|
111
|
+
*/
|
|
112
|
+
dispose(): void;
|
|
113
|
+
/** True once {@link dispose} has run. */
|
|
114
|
+
readonly isDisposed: boolean;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The pure reconciliation core of {@link activityFeed}: it owns the diff between
|
|
118
|
+
* a set of live rows and the shell's activity registry, with NO Svelte reactive
|
|
119
|
+
* or client-data-engine dependency. {@link activityFeed} wraps one of these in a
|
|
120
|
+
* `$effect` over a {@link liveCollection} view; this split keeps the mapping /
|
|
121
|
+
* create-update-remove / ownership logic testable against a real `ShellState`
|
|
122
|
+
* with plain row arrays (mock only externals — see the `__tests__`).
|
|
123
|
+
*
|
|
124
|
+
* @internal Not part of the public surface — use {@link activityFeed}.
|
|
125
|
+
* @typeParam TData - the row DTO shape being reconciled.
|
|
126
|
+
*/
|
|
127
|
+
export declare class ActivityFeedReconciler<TData extends object> {
|
|
128
|
+
private readonly shell;
|
|
129
|
+
private readonly map;
|
|
130
|
+
/**
|
|
131
|
+
* Activities this feed currently owns, keyed by activity id, each with the
|
|
132
|
+
* last resolved input + fingerprint so a reconcile can tell created / changed
|
|
133
|
+
* / unchanged apart and remove exactly its own set on teardown.
|
|
134
|
+
*/
|
|
135
|
+
private readonly owned;
|
|
136
|
+
private disposed;
|
|
137
|
+
constructor(shell: ShellState, map: ActivityFeedMap<TData>);
|
|
138
|
+
/** True once {@link dispose} has run. */
|
|
139
|
+
get isDisposed(): boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Reconcile `rows` against the owned activity set: upsert newly-appearing
|
|
142
|
+
* activities, update changed ones, remove vanished ones. A pure diff — an
|
|
143
|
+
* unchanged tick (same rows, same mappings) performs ZERO shell mutations.
|
|
144
|
+
* No-op once disposed.
|
|
145
|
+
*/
|
|
146
|
+
reconcile(rows: ReadonlyArray<SmrtWebRow<TData>>): void;
|
|
147
|
+
/**
|
|
148
|
+
* Retract exactly this feed's activities from the shell and stop reconciling.
|
|
149
|
+
* Idempotent. Activities owned by other sources are left untouched.
|
|
150
|
+
*/
|
|
151
|
+
dispose(): void;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Bridge a `@happyvertical/smrt-web` live collection into an AdminShell's
|
|
155
|
+
* activity registry: each mapped row becomes a {@link ShellActivity} that
|
|
156
|
+
* appears, updates, and disappears in the shell as the collection changes.
|
|
157
|
+
*
|
|
158
|
+
* MUST be called during Svelte component initialization — it delegates to
|
|
159
|
+
* {@link liveCollection} (which installs a `$effect`) and installs its own
|
|
160
|
+
* reconciliation `$effect`. Both bind to the calling component's lifecycle, so
|
|
161
|
+
* the live subscription and reconciliation tear down automatically on unmount.
|
|
162
|
+
* Unmount ALSO auto-retracts the feed's activities from the shell (via an
|
|
163
|
+
* `$effect` teardown), so they never linger after the host component is gone —
|
|
164
|
+
* no manual cleanup required. The returned {@link ActivityFeedHandle.dispose}
|
|
165
|
+
* retracts them SOONER (without unmounting) and is idempotent, so calling it and
|
|
166
|
+
* then unmounting is safe.
|
|
167
|
+
*
|
|
168
|
+
* Lifecycle per row:
|
|
169
|
+
* - a row that newly maps to an activity → `shell.upsertActivity(...)` (creates
|
|
170
|
+
* it, stamping `createdAt`);
|
|
171
|
+
* - an owned row whose mapping changes → `shell.updateActivity(id, patch)`
|
|
172
|
+
* (preserves `createdAt`, bumps `updatedAt`, and the shell emits a
|
|
173
|
+
* `transition` event when `status` changed — driving toasts);
|
|
174
|
+
* - a row that vanishes, or whose mapping flips to `null` →
|
|
175
|
+
* `shell.removeActivity(id)`.
|
|
176
|
+
*
|
|
177
|
+
* Only activities this feed created are ever touched: activities pushed to the
|
|
178
|
+
* shell by other sources (or a second feed) are left untouched, and `dispose`
|
|
179
|
+
* removes exactly this feed's set.
|
|
180
|
+
*
|
|
181
|
+
* @typeParam TData - the row DTO shape carried by {@link ActivityFeedOptions.collection}.
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```svelte
|
|
185
|
+
* <script lang="ts">
|
|
186
|
+
* import { activityFeed } from '@happyvertical/smrt-svelte/web';
|
|
187
|
+
* import { useAdminShell } from '@happyvertical/smrt-svelte/workspace';
|
|
188
|
+
* import { createSmrtCollection } from '@happyvertical/smrt-web';
|
|
189
|
+
* import { getCollectionDefinition } from '@happyvertical/smrt-virt-web';
|
|
190
|
+
*
|
|
191
|
+
* const shell = useAdminShell();
|
|
192
|
+
* const encodes = createSmrtCollection(getCollectionDefinition('encodes'), {});
|
|
193
|
+
*
|
|
194
|
+
* // Reconciles `encodes` rows into the shell's Focus scope; disposes on unmount.
|
|
195
|
+
* activityFeed({
|
|
196
|
+
* collection: encodes,
|
|
197
|
+
* shell,
|
|
198
|
+
* map: (row) => ({
|
|
199
|
+
* kind: 'video-encode',
|
|
200
|
+
* scope: 'focus',
|
|
201
|
+
* label: row.title,
|
|
202
|
+
* status: row.state, // 'running' | 'completed' | …
|
|
203
|
+
* progress: row.progress,
|
|
204
|
+
* }),
|
|
205
|
+
* });
|
|
206
|
+
* </script>
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
export declare function activityFeed<TData extends object>(options: ActivityFeedOptions<TData>): ActivityFeedHandle;
|
|
210
|
+
//# sourceMappingURL=activity-feed.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"activity-feed.svelte.d.ts","sourceRoot":"","sources":["../../src/web/activity-feed.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC7E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qDAAqD,CAAC;AACtF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8CAA8C,CAAC;AAGlF;;;;;;;;GAQG;AACH,MAAM,MAAM,kBAAkB,GAAG,IAAI,CACnC,aAAa,EACb,IAAI,GAAG,WAAW,GAAG,WAAW,CACjC,GAAG;IACF;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,eAAe,CAAC,KAAK,SAAS,MAAM,IAAI,CAClD,GAAG,EAAE,UAAU,CAAC,KAAK,CAAC,KACnB,kBAAkB,GAAG,IAAI,CAAC;AAE/B;;;;GAIG;AACH,MAAM,WAAW,mBAAmB,CAAC,KAAK,SAAS,MAAM;IACvD;;;;OAIG;IACH,UAAU,EAAE,iBAAiB,CAAC,KAAK,CAAC,CAAC;IACrC;;;OAGG;IACH,GAAG,EAAE,eAAe,CAAC,KAAK,CAAC,CAAC;IAC5B,qEAAqE;IACrE,KAAK,EAAE,UAAU,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;;;;OAMG;IACH,OAAO,IAAI,IAAI,CAAC;IAChB,yCAAyC;IACzC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;CAC9B;AA+GD;;;;;;;;;;GAUG;AACH,qBAAa,sBAAsB,CAAC,KAAK,SAAS,MAAM;IAUpD,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAVtB;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAoC;IAC1D,OAAO,CAAC,QAAQ,CAAS;gBAGN,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,eAAe,CAAC,KAAK,CAAC;IAG9C,yCAAyC;IACzC,IAAI,UAAU,IAAI,OAAO,CAExB;IAED;;;;;OAKG;IACH,SAAS,CAAC,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,GAAG,IAAI;IA+CvD;;;OAGG;IACH,OAAO,IAAI,IAAI;CAMhB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH,wBAAgB,YAAY,CAAC,KAAK,SAAS,MAAM,EAC/C,OAAO,EAAE,mBAAmB,CAAC,KAAK,CAAC,GAClC,kBAAkB,CAyCpB"}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminShell activity feed adapter — bridge a `@happyvertical/smrt-web` live
|
|
3
|
+
* collection into the AdminShell activity registry (#1779, part of the WASD
|
|
4
|
+
* AdminShell epic #1766).
|
|
5
|
+
*
|
|
6
|
+
* This is a thin, OPT-IN CONSUMER of already-shipped infrastructure, not a new
|
|
7
|
+
* generated surface. It sits at the intersection of two existing contracts:
|
|
8
|
+
*
|
|
9
|
+
* - the runes-reactive {@link liveCollection} view over a `SmrtWebCollection`
|
|
10
|
+
* (`@happyvertical/smrt-svelte/web`, slice A of #1761), and
|
|
11
|
+
* - the `ShellState` activity registry (`upsertActivity` / `updateActivity` /
|
|
12
|
+
* `removeActivity`, from `components/workspace/admin-shell`).
|
|
13
|
+
*
|
|
14
|
+
* The app supplies (a) the `@smrt()` DOMAIN collection (a `SmrtWebCollection`)
|
|
15
|
+
* and (b) an editorial {@link ActivityFeedMap} that turns each row into the
|
|
16
|
+
* shell-facing activity fields (`kind`, `scope`, `label`, `progress`, …). The
|
|
17
|
+
* adapter reconciles the mapped rows against the shell as rows appear, change,
|
|
18
|
+
* and vanish, and returns a disposer that removes the activities it created.
|
|
19
|
+
*
|
|
20
|
+
* ── Why it lives behind the `/web` opt-in entry ─────────────────────────────
|
|
21
|
+
* `liveCollection` pulls the client-data engine (`@tanstack/db` +
|
|
22
|
+
* `@tanstack/svelte-db`). Keeping this adapter in the `/web` subpath keeps that
|
|
23
|
+
* engine OUT of the AdminShell core: nothing under `components/workspace/` may
|
|
24
|
+
* import this module, so `@happyvertical/smrt-svelte/workspace` stays
|
|
25
|
+
* transport-agnostic and TanStack-free (a hard constraint of #1766). The app
|
|
26
|
+
* wires the adapter at the edge, next to where it already opts into `/web`.
|
|
27
|
+
*
|
|
28
|
+
* ── Engine-absorption boundary ──────────────────────────────────────────────
|
|
29
|
+
* No `@tanstack/*` type appears here. The adapter reconciles against the plain
|
|
30
|
+
* DTO rows exposed by {@link liveCollection} (which already strips the engine's
|
|
31
|
+
* `$`-prefixed virtual props), so the engine stays swappable behind the same
|
|
32
|
+
* boundary the rest of `/web` respects.
|
|
33
|
+
*
|
|
34
|
+
* ── Reconciliation, not change-diffing ──────────────────────────────────────
|
|
35
|
+
* Rather than decode the engine's change payload, the adapter re-derives the
|
|
36
|
+
* activity set from the current live rows on every reactive tick (via an
|
|
37
|
+
* internal `$effect` over `view.rows`) and diffs it against what it last pushed
|
|
38
|
+
* to the shell. This is robust to the change-notification shape (which is
|
|
39
|
+
* engine-internal) and mirrors how the runtime's own consumers treat a change
|
|
40
|
+
* as a "something moved, re-read" signal.
|
|
41
|
+
*/
|
|
42
|
+
import { liveCollection } from './live-collection.svelte.js';
|
|
43
|
+
/**
|
|
44
|
+
* Compute the change-detection fingerprint for a resolved activity input. The
|
|
45
|
+
* signature is a JSON projection of the shell-relevant fields in a FIXED key
|
|
46
|
+
* order (so key ordering in the mapper output never registers as a change); the
|
|
47
|
+
* non-serializable `cancel` is carried alongside for identity comparison.
|
|
48
|
+
*/
|
|
49
|
+
function fingerprintOf(input) {
|
|
50
|
+
const signature = JSON.stringify([
|
|
51
|
+
input.id,
|
|
52
|
+
input.label,
|
|
53
|
+
input.kind,
|
|
54
|
+
input.scope,
|
|
55
|
+
input.status,
|
|
56
|
+
input.progress ?? null,
|
|
57
|
+
input.message ?? null,
|
|
58
|
+
input.detailHref ?? null,
|
|
59
|
+
input.edge ?? null,
|
|
60
|
+
input.subject
|
|
61
|
+
? [input.subject.type, input.subject.id, input.subject.label ?? null]
|
|
62
|
+
: null,
|
|
63
|
+
]);
|
|
64
|
+
return { signature, cancel: input.cancel };
|
|
65
|
+
}
|
|
66
|
+
/** Two fingerprints are equal when their signatures AND `cancel` identity match. */
|
|
67
|
+
function fingerprintsEqual(a, b) {
|
|
68
|
+
return a.signature === b.signature && a.cancel === b.cancel;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve a mapped input to a fully-keyed activity input, defaulting the
|
|
72
|
+
* activity `id` to the row `id`. Returns `null` when the row has neither a
|
|
73
|
+
* mapped `id` nor a usable row `id` (it cannot be tracked, so it is dropped
|
|
74
|
+
* rather than pushed to the shell under an unstable key).
|
|
75
|
+
*/
|
|
76
|
+
function resolveInput(row, mapped) {
|
|
77
|
+
const id = mapped.id ?? row.id;
|
|
78
|
+
if (typeof id !== 'string' || id.length === 0)
|
|
79
|
+
return null;
|
|
80
|
+
return { ...mapped, id };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build the update patch for a re-mapped activity so it REPLACES the mapped
|
|
84
|
+
* field set rather than merging over it.
|
|
85
|
+
*
|
|
86
|
+
* `ShellState.updateActivity(id, patch)` merges `{ ...current, ...patch }`, so a
|
|
87
|
+
* key the new mapping OMITS would otherwise retain its stale prior value. To
|
|
88
|
+
* keep the shell activity an exact mirror of the current mapping, every field a
|
|
89
|
+
* map controls is listed EXPLICITLY here — an omitted optional field is carried
|
|
90
|
+
* as `undefined`, which overwrites the stale value on merge. Two consequences:
|
|
91
|
+
*
|
|
92
|
+
* - Dropped optionals (`message`, `detailHref`, `subject`, `progress`, `cancel`)
|
|
93
|
+
* are cleared, not kept (codex P2).
|
|
94
|
+
* - `edge` is passed through as the mapping left it — `undefined` when the map
|
|
95
|
+
* did not pin an edge — so when a row's `scope` changes without an explicit
|
|
96
|
+
* `edge`, the merged activity carries `edge: undefined` and `upsertActivity`
|
|
97
|
+
* RE-DERIVES the edge from the new scope (`edge ?? homeEdgeForScope(scope)`),
|
|
98
|
+
* re-homing it instead of pinning it to the previous scope's edge (copilot).
|
|
99
|
+
*
|
|
100
|
+
* `id` is deliberately excluded (it is the update key, not a patched field).
|
|
101
|
+
*/
|
|
102
|
+
function toUpdatePatch(input) {
|
|
103
|
+
return {
|
|
104
|
+
label: input.label,
|
|
105
|
+
kind: input.kind,
|
|
106
|
+
scope: input.scope,
|
|
107
|
+
status: input.status,
|
|
108
|
+
subject: input.subject,
|
|
109
|
+
edge: input.edge,
|
|
110
|
+
progress: input.progress,
|
|
111
|
+
detailHref: input.detailHref,
|
|
112
|
+
message: input.message,
|
|
113
|
+
cancel: input.cancel,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The pure reconciliation core of {@link activityFeed}: it owns the diff between
|
|
118
|
+
* a set of live rows and the shell's activity registry, with NO Svelte reactive
|
|
119
|
+
* or client-data-engine dependency. {@link activityFeed} wraps one of these in a
|
|
120
|
+
* `$effect` over a {@link liveCollection} view; this split keeps the mapping /
|
|
121
|
+
* create-update-remove / ownership logic testable against a real `ShellState`
|
|
122
|
+
* with plain row arrays (mock only externals — see the `__tests__`).
|
|
123
|
+
*
|
|
124
|
+
* @internal Not part of the public surface — use {@link activityFeed}.
|
|
125
|
+
* @typeParam TData - the row DTO shape being reconciled.
|
|
126
|
+
*/
|
|
127
|
+
export class ActivityFeedReconciler {
|
|
128
|
+
shell;
|
|
129
|
+
map;
|
|
130
|
+
/**
|
|
131
|
+
* Activities this feed currently owns, keyed by activity id, each with the
|
|
132
|
+
* last resolved input + fingerprint so a reconcile can tell created / changed
|
|
133
|
+
* / unchanged apart and remove exactly its own set on teardown.
|
|
134
|
+
*/
|
|
135
|
+
owned = new Map();
|
|
136
|
+
disposed = false;
|
|
137
|
+
constructor(shell, map) {
|
|
138
|
+
this.shell = shell;
|
|
139
|
+
this.map = map;
|
|
140
|
+
}
|
|
141
|
+
/** True once {@link dispose} has run. */
|
|
142
|
+
get isDisposed() {
|
|
143
|
+
return this.disposed;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Reconcile `rows` against the owned activity set: upsert newly-appearing
|
|
147
|
+
* activities, update changed ones, remove vanished ones. A pure diff — an
|
|
148
|
+
* unchanged tick (same rows, same mappings) performs ZERO shell mutations.
|
|
149
|
+
* No-op once disposed.
|
|
150
|
+
*/
|
|
151
|
+
reconcile(rows) {
|
|
152
|
+
if (this.disposed)
|
|
153
|
+
return;
|
|
154
|
+
// Resolve this tick's activities from the rows. A row mapping to `null`, or
|
|
155
|
+
// resolving to no usable id, is simply absent from `next` — which makes the
|
|
156
|
+
// removal pass below retract it if the feed owned it last tick.
|
|
157
|
+
const next = new Map();
|
|
158
|
+
for (const row of rows) {
|
|
159
|
+
const mapped = this.map(row);
|
|
160
|
+
if (mapped === null)
|
|
161
|
+
continue;
|
|
162
|
+
const input = resolveInput(row, mapped);
|
|
163
|
+
if (input === null)
|
|
164
|
+
continue;
|
|
165
|
+
// Last write wins if two rows resolve to the same activity id — matches
|
|
166
|
+
// the shell's own upsert-by-id semantics.
|
|
167
|
+
next.set(input.id, { input, fingerprint: fingerprintOf(input) });
|
|
168
|
+
}
|
|
169
|
+
// Removals: ids the feed owned last tick but that are gone now.
|
|
170
|
+
for (const id of this.owned.keys()) {
|
|
171
|
+
if (!next.has(id)) {
|
|
172
|
+
this.shell.removeActivity(id);
|
|
173
|
+
this.owned.delete(id);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Creates + updates.
|
|
177
|
+
for (const [id, entry] of next) {
|
|
178
|
+
const prior = this.owned.get(id);
|
|
179
|
+
if (!prior) {
|
|
180
|
+
// First appearance: create it (stamps createdAt/updatedAt in the shell).
|
|
181
|
+
this.shell.upsertActivity({ ...entry.input });
|
|
182
|
+
this.owned.set(id, entry);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!fingerprintsEqual(prior.fingerprint, entry.fingerprint)) {
|
|
186
|
+
// Changed: patch it, preserving createdAt and letting the shell emit a
|
|
187
|
+
// `transition` when the status changed. The patch lists every mappable
|
|
188
|
+
// field explicitly (see `toUpdatePatch`) so it REPLACES the mapped set
|
|
189
|
+
// rather than merging over it — dropped optionals are cleared and a
|
|
190
|
+
// changed `scope` re-derives the `edge` instead of pinning the old one.
|
|
191
|
+
this.shell.updateActivity(id, toUpdatePatch(entry.input));
|
|
192
|
+
this.owned.set(id, entry);
|
|
193
|
+
}
|
|
194
|
+
// Unchanged: no shell mutation.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Retract exactly this feed's activities from the shell and stop reconciling.
|
|
199
|
+
* Idempotent. Activities owned by other sources are left untouched.
|
|
200
|
+
*/
|
|
201
|
+
dispose() {
|
|
202
|
+
if (this.disposed)
|
|
203
|
+
return;
|
|
204
|
+
this.disposed = true;
|
|
205
|
+
for (const id of this.owned.keys())
|
|
206
|
+
this.shell.removeActivity(id);
|
|
207
|
+
this.owned.clear();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Bridge a `@happyvertical/smrt-web` live collection into an AdminShell's
|
|
212
|
+
* activity registry: each mapped row becomes a {@link ShellActivity} that
|
|
213
|
+
* appears, updates, and disappears in the shell as the collection changes.
|
|
214
|
+
*
|
|
215
|
+
* MUST be called during Svelte component initialization — it delegates to
|
|
216
|
+
* {@link liveCollection} (which installs a `$effect`) and installs its own
|
|
217
|
+
* reconciliation `$effect`. Both bind to the calling component's lifecycle, so
|
|
218
|
+
* the live subscription and reconciliation tear down automatically on unmount.
|
|
219
|
+
* Unmount ALSO auto-retracts the feed's activities from the shell (via an
|
|
220
|
+
* `$effect` teardown), so they never linger after the host component is gone —
|
|
221
|
+
* no manual cleanup required. The returned {@link ActivityFeedHandle.dispose}
|
|
222
|
+
* retracts them SOONER (without unmounting) and is idempotent, so calling it and
|
|
223
|
+
* then unmounting is safe.
|
|
224
|
+
*
|
|
225
|
+
* Lifecycle per row:
|
|
226
|
+
* - a row that newly maps to an activity → `shell.upsertActivity(...)` (creates
|
|
227
|
+
* it, stamping `createdAt`);
|
|
228
|
+
* - an owned row whose mapping changes → `shell.updateActivity(id, patch)`
|
|
229
|
+
* (preserves `createdAt`, bumps `updatedAt`, and the shell emits a
|
|
230
|
+
* `transition` event when `status` changed — driving toasts);
|
|
231
|
+
* - a row that vanishes, or whose mapping flips to `null` →
|
|
232
|
+
* `shell.removeActivity(id)`.
|
|
233
|
+
*
|
|
234
|
+
* Only activities this feed created are ever touched: activities pushed to the
|
|
235
|
+
* shell by other sources (or a second feed) are left untouched, and `dispose`
|
|
236
|
+
* removes exactly this feed's set.
|
|
237
|
+
*
|
|
238
|
+
* @typeParam TData - the row DTO shape carried by {@link ActivityFeedOptions.collection}.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```svelte
|
|
242
|
+
* <script lang="ts">
|
|
243
|
+
* import { activityFeed } from '@happyvertical/smrt-svelte/web';
|
|
244
|
+
* import { useAdminShell } from '@happyvertical/smrt-svelte/workspace';
|
|
245
|
+
* import { createSmrtCollection } from '@happyvertical/smrt-web';
|
|
246
|
+
* import { getCollectionDefinition } from '@happyvertical/smrt-virt-web';
|
|
247
|
+
*
|
|
248
|
+
* const shell = useAdminShell();
|
|
249
|
+
* const encodes = createSmrtCollection(getCollectionDefinition('encodes'), {});
|
|
250
|
+
*
|
|
251
|
+
* // Reconciles `encodes` rows into the shell's Focus scope; disposes on unmount.
|
|
252
|
+
* activityFeed({
|
|
253
|
+
* collection: encodes,
|
|
254
|
+
* shell,
|
|
255
|
+
* map: (row) => ({
|
|
256
|
+
* kind: 'video-encode',
|
|
257
|
+
* scope: 'focus',
|
|
258
|
+
* label: row.title,
|
|
259
|
+
* status: row.state, // 'running' | 'completed' | …
|
|
260
|
+
* progress: row.progress,
|
|
261
|
+
* }),
|
|
262
|
+
* });
|
|
263
|
+
* </script>
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
export function activityFeed(options) {
|
|
267
|
+
const { collection, map, shell, preload = true } = options;
|
|
268
|
+
// Build the runes-reactive live view over the domain collection. This is the
|
|
269
|
+
// sole place the client-data engine is reached (through the `/web` binding),
|
|
270
|
+
// and it MUST run during component init — as must `activityFeed` itself.
|
|
271
|
+
const view = liveCollection(collection, { preload });
|
|
272
|
+
// The pure diff core: holds ownership + create/update/remove logic, with no
|
|
273
|
+
// reactive or engine dependency (unit-tested directly). This function just
|
|
274
|
+
// feeds it the live rows on every reactive tick.
|
|
275
|
+
const reconciler = new ActivityFeedReconciler(shell, map);
|
|
276
|
+
// Reconcile whenever the live rows change. `view.rows` is a `$derived`-backed
|
|
277
|
+
// reactive array; reading it inside `$effect` subscribes this effect to it, so
|
|
278
|
+
// every insert/update/delete/rollback surfaced by the live view re-reconciles.
|
|
279
|
+
// The effect binds to the hosting component and tears down on unmount.
|
|
280
|
+
$effect(() => {
|
|
281
|
+
reconciler.reconcile(view.rows);
|
|
282
|
+
});
|
|
283
|
+
// Auto-cleanup on unmount: retract this feed's activities from the shell when
|
|
284
|
+
// the hosting component is destroyed, so they never linger after the UI that
|
|
285
|
+
// created them is gone. This effect reads NO reactive state, so it never
|
|
286
|
+
// re-runs — its teardown fires only when the effect is destroyed (component
|
|
287
|
+
// unmount / `$effect.root` teardown), exactly the unmount hook we want. The
|
|
288
|
+
// returned `dispose()` stays available for callers that want to retract the
|
|
289
|
+
// feed's activities sooner (without unmounting); `dispose` is idempotent, so
|
|
290
|
+
// an explicit call followed by the unmount teardown is safe.
|
|
291
|
+
$effect(() => {
|
|
292
|
+
return () => reconciler.dispose();
|
|
293
|
+
});
|
|
294
|
+
return {
|
|
295
|
+
dispose() {
|
|
296
|
+
reconciler.dispose();
|
|
297
|
+
},
|
|
298
|
+
get isDisposed() {
|
|
299
|
+
return reconciler.isDisposed;
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
package/dist/web/index.d.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
* stays an implementation detail of the runtime + this binding; nothing here
|
|
8
8
|
* re-exports an `@tanstack/*` type.
|
|
9
9
|
*
|
|
10
|
+
* The {@link activityFeed} adapter (#1779) is layered on {@link liveCollection}
|
|
11
|
+
* to bridge a live collection into the AdminShell activity registry. It lives
|
|
12
|
+
* behind this opt-in `/web` entry — NOT under `components/workspace/` — so the
|
|
13
|
+
* AdminShell core stays transport-agnostic and TanStack-free (epic #1766).
|
|
14
|
+
*
|
|
10
15
|
* @packageDocumentation
|
|
11
16
|
*/
|
|
17
|
+
export { type ActivityFeedHandle, type ActivityFeedMap, type ActivityFeedOptions, activityFeed, type ShellActivityInput, } from './activity-feed.svelte.js';
|
|
12
18
|
export { type LiveCollection, type LiveCollectionMutation, type LiveCollectionOptions, type LiveCollectionStatus, liveCollection, } from './live-collection.svelte.js';
|
|
13
19
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/web/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/web/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/web/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EACL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,mBAAmB,EACxB,YAAY,EACZ,KAAK,kBAAkB,GACxB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,cAAc,GACf,MAAM,6BAA6B,CAAC"}
|
package/dist/web/index.js
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* stays an implementation detail of the runtime + this binding; nothing here
|
|
8
8
|
* re-exports an `@tanstack/*` type.
|
|
9
9
|
*
|
|
10
|
+
* The {@link activityFeed} adapter (#1779) is layered on {@link liveCollection}
|
|
11
|
+
* to bridge a live collection into the AdminShell activity registry. It lives
|
|
12
|
+
* behind this opt-in `/web` entry — NOT under `components/workspace/` — so the
|
|
13
|
+
* AdminShell core stays transport-agnostic and TanStack-free (epic #1766).
|
|
14
|
+
*
|
|
10
15
|
* @packageDocumentation
|
|
11
16
|
*/
|
|
17
|
+
export { activityFeed, } from './activity-feed.svelte.js';
|
|
12
18
|
export { liveCollection, } from './live-collection.svelte.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-svelte",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.3",
|
|
4
4
|
"description": "Svelte 5 components for SMRT user management - auth, users, tenants, roles, permissions, groups",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"smrtRawPrimitives": "strict",
|
|
@@ -88,14 +88,14 @@
|
|
|
88
88
|
"access": "public"
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
|
-
"@happyvertical/logger": "^0.
|
|
91
|
+
"@happyvertical/logger": "^0.76.2",
|
|
92
92
|
"@tanstack/db": "^0.6.14",
|
|
93
93
|
"@tanstack/svelte-db": "^0.1.91",
|
|
94
94
|
"esm-env": "^1.2.2",
|
|
95
|
-
"@happyvertical/smrt-
|
|
96
|
-
"@happyvertical/smrt-
|
|
97
|
-
"@happyvertical/smrt-
|
|
98
|
-
"@happyvertical/smrt-
|
|
95
|
+
"@happyvertical/smrt-languages": "0.38.3",
|
|
96
|
+
"@happyvertical/smrt-types": "0.38.3",
|
|
97
|
+
"@happyvertical/smrt-ui": "0.38.3",
|
|
98
|
+
"@happyvertical/smrt-web": "0.38.3"
|
|
99
99
|
},
|
|
100
100
|
"peerDependencies": {
|
|
101
101
|
"@huggingface/transformers": ">=3.8.1",
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
"svelte": "^5.56.4",
|
|
134
134
|
"svelte-check": "^4.7.1",
|
|
135
135
|
"typescript": "^5.9.3",
|
|
136
|
-
"vite": "^8.1.
|
|
136
|
+
"vite": "^8.1.3",
|
|
137
137
|
"vitest": "^4.1.9"
|
|
138
138
|
},
|
|
139
139
|
"scripts": {
|