@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,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
+ }
@@ -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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/web/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,cAAc,GACf,MAAM,6BAA6B,CAAC"}
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.1",
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",
@@ -36,6 +36,12 @@
36
36
  "import": "./dist/components/workspace/server/index.js",
37
37
  "default": "./dist/components/workspace/server/index.js"
38
38
  },
39
+ "./workspace/live": {
40
+ "types": "./dist/components/workspace/live/index.d.ts",
41
+ "svelte": "./dist/components/workspace/live/index.js",
42
+ "import": "./dist/components/workspace/live/index.js",
43
+ "default": "./dist/components/workspace/live/index.js"
44
+ },
39
45
  "./browser-ai": {
40
46
  "types": "./dist/browser-ai/index.d.ts",
41
47
  "import": "./dist/browser-ai/index.js",
@@ -82,14 +88,14 @@
82
88
  "access": "public"
83
89
  },
84
90
  "dependencies": {
85
- "@happyvertical/logger": "^0.74.11",
91
+ "@happyvertical/logger": "^0.76.2",
86
92
  "@tanstack/db": "^0.6.14",
87
93
  "@tanstack/svelte-db": "^0.1.91",
88
94
  "esm-env": "^1.2.2",
89
- "@happyvertical/smrt-languages": "0.38.1",
90
- "@happyvertical/smrt-types": "0.38.1",
91
- "@happyvertical/smrt-ui": "0.38.1",
92
- "@happyvertical/smrt-web": "0.38.1"
95
+ "@happyvertical/smrt-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"
93
99
  },
94
100
  "peerDependencies": {
95
101
  "@huggingface/transformers": ">=3.8.1",
@@ -127,7 +133,7 @@
127
133
  "svelte": "^5.56.4",
128
134
  "svelte-check": "^4.7.1",
129
135
  "typescript": "^5.9.3",
130
- "vite": "^8.1.2",
136
+ "vite": "^8.1.3",
131
137
  "vitest": "^4.1.9"
132
138
  },
133
139
  "scripts": {