sh3-core 0.22.0 → 0.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/__test__/fixtures.js +1 -1
  2. package/dist/__test__/reset.js +1 -3
  3. package/dist/__test__/smoke.test.js +2 -2
  4. package/dist/actions/contextMenuModel.test.js +6 -3
  5. package/dist/actions/ctx-actions.svelte.test.js +9 -9
  6. package/dist/actions/dispatcher-v3.test.js +8 -0
  7. package/dist/actions/dispatcher.svelte.d.ts +1 -2
  8. package/dist/actions/dispatcher.svelte.js +6 -7
  9. package/dist/actions/dispatcher.test.js +9 -12
  10. package/dist/actions/listActionsFromEntries.test.js +1 -2
  11. package/dist/actions/listActive.test.js +2 -3
  12. package/dist/actions/menuBarModel.test.js +1 -7
  13. package/dist/actions/paletteModel.test.js +1 -3
  14. package/dist/actions/scope-helpers.test.js +4 -4
  15. package/dist/actions/shardContext.test.js +2 -2
  16. package/dist/actions/state.svelte.d.ts +12 -2
  17. package/dist/actions/state.svelte.js +15 -12
  18. package/dist/actions/state.test.js +4 -4
  19. package/dist/api.d.ts +4 -3
  20. package/dist/api.js +1 -1
  21. package/dist/app/admin/adminShard.svelte.js +1 -1
  22. package/dist/app/store/storeShard.svelte.js +10 -5
  23. package/dist/app-appearance/appearanceShard.svelte.js +1 -5
  24. package/dist/apps/lifecycle.js +49 -64
  25. package/dist/apps/lifecycle.test.js +30 -76
  26. package/dist/conflicts/adapter-documents.js +1 -2
  27. package/dist/createShell.js +1 -1
  28. package/dist/documents/handle.d.ts +9 -4
  29. package/dist/documents/handle.js +40 -29
  30. package/dist/documents/handle.test.js +60 -51
  31. package/dist/documents/index.d.ts +1 -1
  32. package/dist/documents/types.d.ts +16 -26
  33. package/dist/host.d.ts +1 -1
  34. package/dist/host.js +9 -56
  35. package/dist/host.svelte.test.js +31 -63
  36. package/dist/layouts-shard/LayoutsSection.svelte +1 -1
  37. package/dist/layouts-shard/layoutsShard.svelte.js +2 -5
  38. package/dist/layouts-shard/layoutsShard.svelte.test.js +2 -2
  39. package/dist/projects-shard/projectsShard.svelte.js +1 -5
  40. package/dist/registry/installer.js +1 -1
  41. package/dist/registry/loader.d.ts +1 -1
  42. package/dist/registry/loader.js +3 -3
  43. package/dist/registry/permission-descriptions.test.js +2 -2
  44. package/dist/registry/register.js +1 -1
  45. package/dist/registry/register.test.js +1 -1
  46. package/dist/runtime/runVerb-shell.test.js +1 -1
  47. package/dist/runtime/runVerb.js +2 -2
  48. package/dist/runtime/runVerb.test.js +9 -9
  49. package/dist/server-shard/types.d.ts +56 -0
  50. package/dist/sh3Api/headless.js +1 -1
  51. package/dist/sh3core-shard/sh3coreShard.svelte.js +1 -6
  52. package/dist/shards/ctx-fetch.test.js +9 -9
  53. package/dist/shards/lifecycle.svelte.d.ts +108 -0
  54. package/dist/shards/lifecycle.svelte.js +551 -0
  55. package/dist/shards/lifecycle.test.js +139 -0
  56. package/dist/shards/types.d.ts +30 -63
  57. package/dist/shell-shard/shellShard.svelte.js +1 -4
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/package.json +1 -1
  61. package/dist/shards/activate-browse.test.js +0 -120
  62. package/dist/shards/activate-contributions.test.js +0 -141
  63. package/dist/shards/activate-error-isolation.test.d.ts +0 -1
  64. package/dist/shards/activate-error-isolation.test.js +0 -98
  65. package/dist/shards/activate-fields.svelte.test.d.ts +0 -1
  66. package/dist/shards/activate-fields.svelte.test.js +0 -121
  67. package/dist/shards/activate-on-key-revoked.test.d.ts +0 -1
  68. package/dist/shards/activate-on-key-revoked.test.js +0 -60
  69. package/dist/shards/activate-runtime.test.d.ts +0 -1
  70. package/dist/shards/activate-runtime.test.js +0 -344
  71. package/dist/shards/activate-scopeid.test.d.ts +0 -1
  72. package/dist/shards/activate-scopeid.test.js +0 -21
  73. package/dist/shards/activate.svelte.d.ts +0 -102
  74. package/dist/shards/activate.svelte.js +0 -407
  75. package/dist/shards/app-binding.svelte.d.ts +0 -8
  76. package/dist/shards/app-binding.svelte.js +0 -30
  77. package/dist/shards/app-binding.test.d.ts +0 -1
  78. package/dist/shards/app-binding.test.js +0 -25
  79. /package/dist/{shards/activate-browse.test.d.ts → actions/dispatcher-v3.test.d.ts} +0 -0
  80. /package/dist/shards/{activate-contributions.test.d.ts → lifecycle.test.d.ts} +0 -0
@@ -0,0 +1,551 @@
1
+ /*
2
+ * Shard lifecycle (v3) — the only entry the framework uses to bring
3
+ * shards online and bind them to apps. Replaces shards/activate.svelte.ts
4
+ * and shards/app-binding.svelte.ts.
5
+ *
6
+ * See docs/superpowers/specs/2026-05-18-shard-contract-v3-design.md
7
+ * and ADR-027 for the design rationale.
8
+ */
9
+ import { sh3 } from '../sh3Runtime.svelte';
10
+ import { registerView, registerVerb as fwRegisterVerb } from './registry';
11
+ import { createDocumentHandle, getDocumentBackend, getActiveScopeId } from '../documents';
12
+ import { getEnvServerUrl } from '../env/index';
13
+ import { apiFetch } from '../transport/apiFetch';
14
+ import { isAdmin as checkIsAdmin } from '../auth/index';
15
+ import { createZoneManager } from '../state/manage';
16
+ import { PERMISSION_STATE_MANAGE } from '../state/types';
17
+ import { PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_READ, PERMISSION_DOCUMENTS_WRITE, } from '../documents/types';
18
+ import { createBrowseCapability } from '../documents/browse';
19
+ import { createDocumentPicker } from '../documents/picker-primitive';
20
+ import { createShardKeysApi } from '../keys/client';
21
+ import { PERMISSION_KEYS_MINT } from '../keys/types';
22
+ import { makeSh3Api } from '../sh3Api/headless';
23
+ import { register as contributionsRegister, list as contributionsList, listPoints as contributionsListPoints, onChange as contributionsOnChange, onAnyChange as contributionsOnAnyChange, } from '../contributions';
24
+ import { registerAction } from '../actions/registry';
25
+ import { makeSelectionApi } from '../actions/selection.svelte';
26
+ import { openContextMenu as sh3OpenContextMenu, openPalette as sh3OpenPalette, } from '../actions/listeners';
27
+ import { unregisterView, unregisterVerb as fwUnregisterVerb } from './registry';
28
+ import { clearSelectionForShard } from '../actions/selection.svelte';
29
+ import { fetchEnvState } from '../env/client';
30
+ import { subscribe as subscribeKeyRevocation } from '../keys/revocation-bus.svelte';
31
+ const shardAppBindings = $state(new Map());
32
+ /**
33
+ * Reactive registry of every shard known to the host. Keys are shard ids.
34
+ * Populated by `registerShard`.
35
+ */
36
+ export const registeredShards = $state(new Map());
37
+ export const erroredShards = $state(new Map());
38
+ /** Read the app id currently bound to this shard, or null. */
39
+ export function getShardBinding(shardId) {
40
+ var _a;
41
+ return (_a = shardAppBindings.get(shardId)) !== null && _a !== void 0 ? _a : null;
42
+ }
43
+ /**
44
+ * Update which app's namespace this shard's `ctx.documents` resolves to.
45
+ * Pass `appId` to bind, `null` to unbind. Internal — only the lifecycle
46
+ * module and `apps/lifecycle.ts` call this.
47
+ */
48
+ export function rotateShardDocumentNamespace(shardId, appId) {
49
+ if (appId === null)
50
+ shardAppBindings.delete(shardId);
51
+ else
52
+ shardAppBindings.set(shardId, appId);
53
+ }
54
+ let scopeResolver = null;
55
+ export function __setScopeResolver(resolver) {
56
+ scopeResolver = resolver;
57
+ }
58
+ /**
59
+ * Build a ShardContext for the given shard. The ctx is permanent for the
60
+ * shard's lifetime; the same instance is passed to `register`, every
61
+ * `onAppActivate`/`onAppDeactivate`, and every auxiliary hook.
62
+ *
63
+ * `entry` tracks cleanup bags. When the shard is inside an `onAppActivate`
64
+ * call, register sites route disposers to the per-app bag (keyed by
65
+ * `entry.activeAppId`); otherwise they go to the boot bag.
66
+ */
67
+ export function buildShardContext(shard, entry) {
68
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
69
+ const id = shard.manifest.id;
70
+ function trackDisposer(fn) {
71
+ var _a;
72
+ if (entry.activeAppId !== null) {
73
+ const bag = (_a = entry.appCleanupBags.get(entry.activeAppId)) !== null && _a !== void 0 ? _a : [];
74
+ bag.push(fn);
75
+ entry.appCleanupBags.set(entry.activeAppId, bag);
76
+ }
77
+ else {
78
+ entry.bootCleanupFns.push(fn);
79
+ }
80
+ }
81
+ // envState must be declared at top level for Svelte 5 runes.
82
+ const envState = $state({
83
+ proxy: null,
84
+ defaults: null,
85
+ });
86
+ const contributions = {
87
+ register(pointId, descriptor, contribOpts) {
88
+ var _a;
89
+ let stored = descriptor;
90
+ if (pointId === 'sh3.controllable-field') {
91
+ const owner = { shardId: id };
92
+ if (((_a = contribOpts === null || contribOpts === void 0 ? void 0 : contribOpts.scope) === null || _a === void 0 ? void 0 : _a.slotId) !== undefined) {
93
+ owner.slotId = contribOpts.scope.slotId;
94
+ }
95
+ stored = { owner, descriptor };
96
+ }
97
+ const dispose = contributionsRegister(pointId, stored, contribOpts);
98
+ trackDisposer(() => dispose());
99
+ return dispose;
100
+ },
101
+ list(pointId) {
102
+ return contributionsList(pointId);
103
+ },
104
+ listPoints() {
105
+ return contributionsListPoints();
106
+ },
107
+ onChange(pointId, cb) {
108
+ const off = contributionsOnChange(pointId, cb);
109
+ trackDisposer(() => off());
110
+ return off;
111
+ },
112
+ onAnyChange(cb) {
113
+ const off = contributionsOnAnyChange(cb);
114
+ trackDisposer(() => off());
115
+ return off;
116
+ },
117
+ };
118
+ const hasBrowse = (_a = shard.manifest.permissions) === null || _a === void 0 ? void 0 : _a.includes(PERMISSION_DOCUMENTS_BROWSE);
119
+ const browseCap = hasBrowse
120
+ ? createBrowseCapability(getActiveScopeId, getDocumentBackend(), {
121
+ canRead: (_c = (_b = shard.manifest.permissions) === null || _b === void 0 ? void 0 : _b.includes(PERMISSION_DOCUMENTS_READ)) !== null && _c !== void 0 ? _c : false,
122
+ canWrite: (_e = (_d = shard.manifest.permissions) === null || _d === void 0 ? void 0 : _d.includes(PERMISSION_DOCUMENTS_WRITE)) !== null && _e !== void 0 ? _e : false,
123
+ })
124
+ : undefined;
125
+ // Pre-mint exactly one document handle per shard. The handle's
126
+ // namespace resolves lazily on every operation via `getShardBinding`.
127
+ const documents = createDocumentHandle(getActiveScopeId, () => { var _a; return (_a = getShardBinding(id)) !== null && _a !== void 0 ? _a : id; }, getDocumentBackend());
128
+ entry.bootCleanupFns.push(() => documents.dispose());
129
+ // Internal expose so registerAllShards can hydrate env state after register().
130
+ const exposedEnv = envState;
131
+ const ctx = {
132
+ state: (schema) => sh3.state(id, schema),
133
+ registerView: (viewId, factory) => {
134
+ registerView(viewId, factory, shard.manifest.id);
135
+ entry.viewIds.add(viewId);
136
+ },
137
+ registerVerb: (verb) => {
138
+ var _a;
139
+ let prefixed;
140
+ if (id === 'shell') {
141
+ prefixed = verb.name;
142
+ }
143
+ else {
144
+ const ns = (_a = shard.manifest.verbNamespace) !== null && _a !== void 0 ? _a : id;
145
+ prefixed = `${ns}:${verb.name}`;
146
+ }
147
+ if (fwRegisterVerb(prefixed, Object.assign(Object.assign({}, verb), { name: prefixed }), id)) {
148
+ entry.verbNames.add(prefixed);
149
+ }
150
+ },
151
+ documents,
152
+ fetch(path, init) {
153
+ return apiFetch(this.resolveUrl(path), init);
154
+ },
155
+ get serverUrl() {
156
+ return getEnvServerUrl();
157
+ },
158
+ resolveUrl(path) {
159
+ const isAbsolute = path.startsWith('http://') || path.startsWith('https://');
160
+ if (isAbsolute)
161
+ return path;
162
+ const base = getEnvServerUrl();
163
+ const sep = path.startsWith('/') ? '' : '/';
164
+ return `${base}${sep}${path}`;
165
+ },
166
+ env(defaults) {
167
+ if (envState.proxy) {
168
+ console.warn(`[sh3] Shard "${id}" called ctx.env() more than once; extra calls are ignored.`);
169
+ return envState.proxy;
170
+ }
171
+ envState.defaults = defaults;
172
+ envState.proxy = Object.assign({}, defaults);
173
+ return envState.proxy;
174
+ },
175
+ async envUpdate(patch) {
176
+ if (!envState.proxy || !envState.defaults) {
177
+ throw new Error(`Shard "${id}" called envUpdate() without declaring env state`);
178
+ }
179
+ const { putEnvState } = await import('../env/client');
180
+ const previous = $state.snapshot(envState.proxy);
181
+ Object.assign(envState.proxy, patch);
182
+ try {
183
+ const snapshot = $state.snapshot(envState.proxy);
184
+ await putEnvState(id, snapshot);
185
+ }
186
+ catch (err) {
187
+ Object.assign(envState.proxy, previous);
188
+ throw err;
189
+ }
190
+ },
191
+ get isAdmin() {
192
+ return checkIsAdmin();
193
+ },
194
+ get tenantId() {
195
+ return getActiveScopeId();
196
+ },
197
+ getScope: () => { var _a; return (_a = scopeResolver === null || scopeResolver === void 0 ? void 0 : scopeResolver()) !== null && _a !== void 0 ? _a : 'tenant'; },
198
+ zones: ((_f = shard.manifest.permissions) === null || _f === void 0 ? void 0 : _f.includes(PERMISSION_STATE_MANAGE))
199
+ ? createZoneManager()
200
+ : undefined,
201
+ browse: browseCap,
202
+ documentPicker: browseCap
203
+ ? createDocumentPicker(() => browseCap.listDocuments())
204
+ : createDocumentPicker(async () => {
205
+ const docs = await getDocumentBackend().list(getActiveScopeId(), id);
206
+ return docs.map(d => (Object.assign(Object.assign({}, d), { shardId: id })));
207
+ }),
208
+ keys: ((_g = shard.manifest.permissions) === null || _g === void 0 ? void 0 : _g.includes(PERMISSION_KEYS_MINT))
209
+ ? createShardKeysApi({
210
+ shardId: id,
211
+ shardPermissions: (_h = shard.manifest.permissions) !== null && _h !== void 0 ? _h : [],
212
+ })
213
+ : undefined,
214
+ contributions,
215
+ actions: {
216
+ register(action) {
217
+ const dispose = registerAction(action, id);
218
+ trackDisposer(() => dispose());
219
+ return dispose;
220
+ },
221
+ selection: makeSelectionApi(id),
222
+ openContextMenu(opts) { sh3OpenContextMenu(opts); },
223
+ openPalette(opts) { sh3OpenPalette(opts); },
224
+ },
225
+ sh3: makeSh3Api({
226
+ callerKind: 'shard',
227
+ callerShardId: id,
228
+ zones: ((_j = shard.manifest.permissions) === null || _j === void 0 ? void 0 : _j.includes(PERMISSION_STATE_MANAGE))
229
+ ? createZoneManager()
230
+ : undefined,
231
+ }),
232
+ };
233
+ // Stash env state on the ctx for registerAllShards' hydration step.
234
+ ctx.__envState = exposedEnv;
235
+ return ctx;
236
+ }
237
+ export const shardEntries = $state(new Map());
238
+ export const activeShards = $state(new Map());
239
+ /**
240
+ * Run `register(ctx)` on every registered shard. Idempotent — calling on
241
+ * an already-entered shard is a no-op. Errors are recorded in
242
+ * `erroredShards` with phase 'register'; one failure does not block others.
243
+ */
244
+ export async function registerAllShards() {
245
+ for (const [id, shard] of registeredShards) {
246
+ if (shardEntries.has(id))
247
+ continue;
248
+ const entry = {
249
+ shard,
250
+ ctx: undefined,
251
+ viewIds: new Set(),
252
+ verbNames: new Set(),
253
+ bootCleanupFns: [],
254
+ appCleanupBags: new Map(),
255
+ activeAppId: null,
256
+ };
257
+ const ctx = buildShardContext(shard, entry);
258
+ entry.ctx = ctx;
259
+ shardEntries.set(id, entry);
260
+ activeShards.set(id, shard);
261
+ try {
262
+ await shard.register(ctx);
263
+ for (const view of shard.manifest.views) {
264
+ if (!entry.viewIds.has(view.id)) {
265
+ throw new Error(`Shard "${id}" declared view "${view.id}" in its manifest but registered no factory for it.`);
266
+ }
267
+ }
268
+ // Wire onKeyRevoked subscription per shard at register time. Only
269
+ // shards that declare the hook incur the subscription overhead.
270
+ if (shard.onKeyRevoked) {
271
+ const off = subscribeKeyRevocation(id, async (keyId) => {
272
+ try {
273
+ await shard.onKeyRevoked(ctx, keyId);
274
+ }
275
+ catch (err) {
276
+ console.error(`[sh3] onKeyRevoked failed in "${id}":`, err);
277
+ }
278
+ });
279
+ entry.bootCleanupFns.push(() => off());
280
+ }
281
+ // Env hydrate parity with v2.
282
+ const envState = ctx.__envState;
283
+ if ((envState === null || envState === void 0 ? void 0 : envState.proxy) && envState.defaults) {
284
+ try {
285
+ const stored = await fetchEnvState(id);
286
+ const merged = Object.assign({}, envState.defaults, stored);
287
+ Object.assign(envState.proxy, merged);
288
+ }
289
+ catch (err) {
290
+ console.warn(`[sh3] Failed to hydrate env state for shard "${id}":`, err instanceof Error ? err.message : err);
291
+ }
292
+ }
293
+ erroredShards.delete(id);
294
+ }
295
+ catch (err) {
296
+ for (const fn of entry.bootCleanupFns) {
297
+ try {
298
+ void fn();
299
+ }
300
+ catch ( /* swallow */_a) { /* swallow */ }
301
+ }
302
+ for (const name of entry.verbNames)
303
+ fwUnregisterVerb(name);
304
+ for (const viewId of entry.viewIds)
305
+ unregisterView(viewId);
306
+ clearSelectionForShard(id);
307
+ shardEntries.delete(id);
308
+ activeShards.delete(id);
309
+ erroredShards.set(id, { id, error: err, phase: 'register', timestamp: Date.now() });
310
+ console.error(`[sh3] Shard "${id}" failed to register:`, err);
311
+ }
312
+ }
313
+ }
314
+ export async function runAppActivate(shardId, appId) {
315
+ const entry = shardEntries.get(shardId);
316
+ if (!entry)
317
+ return;
318
+ rotateShardDocumentNamespace(shardId, appId);
319
+ if (entry.shard.onAppActivate) {
320
+ entry.activeAppId = appId;
321
+ try {
322
+ await entry.shard.onAppActivate(entry.ctx, appId);
323
+ }
324
+ finally {
325
+ entry.activeAppId = null;
326
+ }
327
+ }
328
+ }
329
+ export async function runAppDeactivate(shardId, appId) {
330
+ const entry = shardEntries.get(shardId);
331
+ if (!entry)
332
+ return;
333
+ if (entry.shard.onAppDeactivate) {
334
+ try {
335
+ await entry.shard.onAppDeactivate(entry.ctx, appId);
336
+ }
337
+ catch (err) {
338
+ console.error(`[sh3] onAppDeactivate for "${shardId}" / "${appId}" threw:`, err);
339
+ }
340
+ }
341
+ const bag = entry.appCleanupBags.get(appId);
342
+ if (bag) {
343
+ for (const fn of bag) {
344
+ try {
345
+ await fn();
346
+ }
347
+ catch (err) {
348
+ console.error(`[sh3] app-cleanup disposer threw:`, err);
349
+ }
350
+ }
351
+ entry.appCleanupBags.delete(appId);
352
+ }
353
+ rotateShardDocumentNamespace(shardId, null);
354
+ }
355
+ /**
356
+ * Tear down the active entry for a shard and rebuild it from the current
357
+ * `registeredShards.get(id)` value. Used by `registerShard` when replacing
358
+ * an existing shard with a fresh module (package update, dev hot-reload).
359
+ *
360
+ * Fires `onAppDeactivate` for every bound app, calls `deactivate?.()`,
361
+ * flushes all cleanup bags, builds a fresh ctx, then re-runs `register()`.
362
+ * Caller is responsible for re-invoking `runAppActivate` for any apps
363
+ * currently active that require this shard.
364
+ */
365
+ export async function rebuildShardEntry(shardId) {
366
+ var _a, _b;
367
+ const old = shardEntries.get(shardId);
368
+ if (old) {
369
+ for (const appId of old.appCleanupBags.keys()) {
370
+ await runAppDeactivate(shardId, appId);
371
+ }
372
+ try {
373
+ await ((_b = (_a = old.shard).deactivate) === null || _b === void 0 ? void 0 : _b.call(_a));
374
+ }
375
+ catch (err) {
376
+ console.error(err);
377
+ }
378
+ for (const fn of old.bootCleanupFns) {
379
+ try {
380
+ await fn();
381
+ }
382
+ catch ( /* swallow */_c) { /* swallow */ }
383
+ }
384
+ for (const name of old.verbNames)
385
+ fwUnregisterVerb(name);
386
+ for (const viewId of old.viewIds)
387
+ unregisterView(viewId);
388
+ clearSelectionForShard(shardId);
389
+ shardEntries.delete(shardId);
390
+ activeShards.delete(shardId);
391
+ }
392
+ const fresh = registeredShards.get(shardId);
393
+ if (!fresh)
394
+ return;
395
+ const entry = {
396
+ shard: fresh,
397
+ ctx: undefined,
398
+ viewIds: new Set(),
399
+ verbNames: new Set(),
400
+ bootCleanupFns: [],
401
+ appCleanupBags: new Map(),
402
+ activeAppId: null,
403
+ };
404
+ entry.ctx = buildShardContext(fresh, entry);
405
+ shardEntries.set(shardId, entry);
406
+ activeShards.set(shardId, fresh);
407
+ await fresh.register(entry.ctx);
408
+ }
409
+ /**
410
+ * Register (or re-register) a shard. Records the shard in `registeredShards`.
411
+ * If the shard is already active (in `shardEntries`), triggers a hot-swap
412
+ * via `rebuildShardEntry` so the new module replaces the old one cleanly.
413
+ */
414
+ export function registerShard(shard) {
415
+ const id = shard.manifest.id;
416
+ const wasActive = shardEntries.has(id);
417
+ registeredShards.set(id, shard);
418
+ erroredShards.delete(id);
419
+ if (wasActive) {
420
+ void rebuildShardEntry(id);
421
+ }
422
+ }
423
+ /** True if the shard has been registered AND its register() has run. */
424
+ export function isActive(id) {
425
+ return shardEntries.has(id);
426
+ }
427
+ /** Return the ShardContext for an active shard, or undefined. */
428
+ export function getShardContext(id) {
429
+ var _a;
430
+ return (_a = shardEntries.get(id)) === null || _a === void 0 ? void 0 : _a.ctx;
431
+ }
432
+ /**
433
+ * Enumerate every view declared as `standalone` across the currently
434
+ * registered/active shards.
435
+ */
436
+ export function listStandaloneViews() {
437
+ const out = [];
438
+ for (const shard of activeShards.values()) {
439
+ for (const view of shard.manifest.views) {
440
+ if (view.standalone) {
441
+ out.push({ shardId: shard.manifest.id, viewId: view.id, label: view.label });
442
+ }
443
+ }
444
+ }
445
+ return out;
446
+ }
447
+ /** Test-only reset. */
448
+ export function __resetLifecycleForTest() {
449
+ shardAppBindings.clear();
450
+ shardEntries.clear();
451
+ activeShards.clear();
452
+ }
453
+ /**
454
+ * Test-only reset for the full shard registry. Wipes all live entries,
455
+ * registered shards, and error records.
456
+ */
457
+ export function __resetShardRegistryForTest() {
458
+ shardAppBindings.clear();
459
+ shardEntries.clear();
460
+ activeShards.clear();
461
+ registeredShards.clear();
462
+ erroredShards.clear();
463
+ }
464
+ /**
465
+ * @deprecated v2 compat shim — use `registerAllShards()` to run register
466
+ * for every registered shard. This shim activates a single shard by
467
+ * invoking its register hook directly. Retained for test fixtures during
468
+ * the migration; Phase 7 sweeps callers.
469
+ */
470
+ export async function activateShard(id) {
471
+ if (shardEntries.has(id))
472
+ return;
473
+ const shard = registeredShards.get(id);
474
+ if (!shard)
475
+ throw new Error(`Cannot activate shard "${id}": not registered`);
476
+ const entry = {
477
+ shard,
478
+ ctx: undefined,
479
+ viewIds: new Set(),
480
+ verbNames: new Set(),
481
+ bootCleanupFns: [],
482
+ appCleanupBags: new Map(),
483
+ activeAppId: null,
484
+ };
485
+ const ctx = buildShardContext(shard, entry);
486
+ entry.ctx = ctx;
487
+ shardEntries.set(id, entry);
488
+ activeShards.set(id, shard);
489
+ try {
490
+ await shard.register(ctx);
491
+ for (const view of shard.manifest.views) {
492
+ if (!entry.viewIds.has(view.id)) {
493
+ throw new Error(`Shard "${id}" declared view "${view.id}" in its manifest but registered no factory for it.`);
494
+ }
495
+ }
496
+ if (shard.onKeyRevoked) {
497
+ const off = subscribeKeyRevocation(id, async (keyId) => {
498
+ try {
499
+ await shard.onKeyRevoked(ctx, keyId);
500
+ }
501
+ catch (err) {
502
+ console.error(`[sh3] onKeyRevoked failed in "${id}":`, err);
503
+ }
504
+ });
505
+ entry.bootCleanupFns.push(() => off());
506
+ }
507
+ erroredShards.delete(id);
508
+ }
509
+ catch (err) {
510
+ for (const fn of entry.bootCleanupFns) {
511
+ try {
512
+ void fn();
513
+ }
514
+ catch ( /* swallow */_a) { /* swallow */ }
515
+ }
516
+ for (const name of entry.verbNames)
517
+ fwUnregisterVerb(name);
518
+ for (const viewId of entry.viewIds)
519
+ unregisterView(viewId);
520
+ clearSelectionForShard(id);
521
+ shardEntries.delete(id);
522
+ activeShards.delete(id);
523
+ erroredShards.set(id, { id, error: err, phase: 'register', timestamp: Date.now() });
524
+ throw err;
525
+ }
526
+ }
527
+ /**
528
+ * @deprecated v2 compat shim — in v3, shards stay alive for the whole
529
+ * session. This shim performs a full teardown of the entry (for tests
530
+ * that explicitly want to verify cleanup paths).
531
+ */
532
+ export function deactivateShard(id) {
533
+ var _a, _b;
534
+ const entry = shardEntries.get(id);
535
+ if (!entry)
536
+ return;
537
+ void ((_b = (_a = entry.shard).deactivate) === null || _b === void 0 ? void 0 : _b.call(_a));
538
+ for (const fn of entry.bootCleanupFns) {
539
+ try {
540
+ void fn();
541
+ }
542
+ catch ( /* swallow */_c) { /* swallow */ }
543
+ }
544
+ for (const name of entry.verbNames)
545
+ fwUnregisterVerb(name);
546
+ for (const viewId of entry.viewIds)
547
+ unregisterView(viewId);
548
+ clearSelectionForShard(id);
549
+ shardEntries.delete(id);
550
+ activeShards.delete(id);
551
+ }
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { getShardBinding, rotateShardDocumentNamespace, __resetLifecycleForTest, registerAllShards, runAppActivate, runAppDeactivate, rebuildShardEntry, shardEntries, } from './lifecycle.svelte';
3
+ import { registerShard, __resetShardRegistryForTest, erroredShards } from './lifecycle.svelte';
4
+ describe('shards/lifecycle — binding map', () => {
5
+ beforeEach(() => __resetLifecycleForTest());
6
+ it('returns null for an unbound shard', () => {
7
+ expect(getShardBinding('foo')).toBeNull();
8
+ });
9
+ it('records and reads back a binding', () => {
10
+ rotateShardDocumentNamespace('foo', 'myApp');
11
+ expect(getShardBinding('foo')).toBe('myApp');
12
+ });
13
+ it('clears a binding when passed null', () => {
14
+ rotateShardDocumentNamespace('foo', 'myApp');
15
+ rotateShardDocumentNamespace('foo', null);
16
+ expect(getShardBinding('foo')).toBeNull();
17
+ });
18
+ it('overwrites an existing binding', () => {
19
+ rotateShardDocumentNamespace('foo', 'app1');
20
+ rotateShardDocumentNamespace('foo', 'app2');
21
+ expect(getShardBinding('foo')).toBe('app2');
22
+ });
23
+ });
24
+ describe('registerAllShards', () => {
25
+ beforeEach(() => {
26
+ __resetLifecycleForTest();
27
+ __resetShardRegistryForTest();
28
+ });
29
+ it('runs register(ctx) on every registered shard', async () => {
30
+ const registered = [];
31
+ const shardA = {
32
+ manifest: { id: 'reg-a', label: 'A', version: '0.0.0', views: [] },
33
+ register(_ctx) { registered.push('reg-a'); },
34
+ };
35
+ const shardB = {
36
+ manifest: { id: 'reg-b', label: 'B', version: '0.0.0', views: [] },
37
+ register(_ctx) { registered.push('reg-b'); },
38
+ };
39
+ registerShard(shardA);
40
+ registerShard(shardB);
41
+ await registerAllShards();
42
+ expect(registered.sort()).toEqual(['reg-a', 'reg-b']);
43
+ expect(shardEntries.has('reg-a')).toBe(true);
44
+ expect(shardEntries.has('reg-b')).toBe(true);
45
+ });
46
+ it('verifies every manifest view received a factory', async () => {
47
+ var _a;
48
+ const shardWithMissingFactory = {
49
+ manifest: { id: 'miss-x', label: 'X', version: '0.0.0', views: [{ id: 'miss-x:view', label: 'V' }] },
50
+ register(_ctx) { },
51
+ };
52
+ registerShard(shardWithMissingFactory);
53
+ await expect(registerAllShards()).resolves.toBeUndefined();
54
+ expect((_a = erroredShards.get('miss-x')) === null || _a === void 0 ? void 0 : _a.error).toBeInstanceOf(Error);
55
+ });
56
+ it('continues registering remaining shards when one throws', async () => {
57
+ const calls = [];
58
+ const shardBad = {
59
+ manifest: { id: 'bad-reg', label: 'Bad', version: '0.0.0', views: [] },
60
+ register(_ctx) { calls.push('bad-reg'); throw new Error('boom'); },
61
+ };
62
+ const shardGood = {
63
+ manifest: { id: 'good-reg', label: 'Good', version: '0.0.0', views: [] },
64
+ register(_ctx) { calls.push('good-reg'); },
65
+ };
66
+ registerShard(shardBad);
67
+ registerShard(shardGood);
68
+ await registerAllShards();
69
+ expect(calls).toContain('good-reg');
70
+ });
71
+ });
72
+ describe('runAppActivate / runAppDeactivate', () => {
73
+ beforeEach(() => {
74
+ __resetLifecycleForTest();
75
+ __resetShardRegistryForTest();
76
+ });
77
+ it('rotates document namespace and calls onAppActivate', async () => {
78
+ const calls = [];
79
+ const shard = {
80
+ manifest: { id: 'rotation-s', label: 'S', version: '0.0.0', views: [] },
81
+ register(_ctx) { },
82
+ onAppActivate(_ctx, appId) {
83
+ calls.push({ hook: 'activate', appId });
84
+ },
85
+ onAppDeactivate(_ctx, appId) {
86
+ calls.push({ hook: 'deactivate', appId });
87
+ },
88
+ };
89
+ registerShard(shard);
90
+ await registerAllShards();
91
+ await runAppActivate('rotation-s', 'myApp');
92
+ expect(getShardBinding('rotation-s')).toBe('myApp');
93
+ expect(calls).toContainEqual({ hook: 'activate', appId: 'myApp' });
94
+ await runAppDeactivate('rotation-s', 'myApp');
95
+ expect(getShardBinding('rotation-s')).toBeNull();
96
+ expect(calls).toContainEqual({ hook: 'deactivate', appId: 'myApp' });
97
+ });
98
+ it('auto-disposes contributions registered inside onAppActivate', async () => {
99
+ const shard = {
100
+ manifest: { id: 'bag-s', label: 'S', version: '0.0.0', views: [] },
101
+ register(_ctx) { },
102
+ onAppActivate(ctxParam) {
103
+ const ctx = ctxParam;
104
+ ctx.actions.register({ id: 'bag-s.action', label: 'A', scope: ['app'], run() { } });
105
+ },
106
+ };
107
+ registerShard(shard);
108
+ await registerAllShards();
109
+ await runAppActivate('bag-s', 'myApp');
110
+ const { listActions } = await import('../actions/registry');
111
+ expect(listActions().find(e => e.action.id === 'bag-s.action')).toBeDefined();
112
+ await runAppDeactivate('bag-s', 'myApp');
113
+ expect(listActions().find(e => e.action.id === 'bag-s.action')).toBeUndefined();
114
+ });
115
+ });
116
+ describe('hot-swap on re-register', () => {
117
+ beforeEach(() => {
118
+ __resetLifecycleForTest();
119
+ __resetShardRegistryForTest();
120
+ });
121
+ it('tears down old entry and rebuilds when shard re-registers', async () => {
122
+ var _a, _b, _c;
123
+ const original = {
124
+ manifest: { id: 'hot-s', label: 'Original', version: '0.0.0', views: [] },
125
+ register(_ctx) { },
126
+ };
127
+ registerShard(original);
128
+ await registerAllShards();
129
+ expect((_a = shardEntries.get('hot-s')) === null || _a === void 0 ? void 0 : _a.shard.manifest.label).toBe('Original');
130
+ const updated = {
131
+ manifest: { id: 'hot-s', label: 'Updated', version: '0.0.1', views: [] },
132
+ register(_ctx) { },
133
+ };
134
+ registerShard(updated);
135
+ await rebuildShardEntry('hot-s');
136
+ expect((_b = shardEntries.get('hot-s')) === null || _b === void 0 ? void 0 : _b.shard.manifest.label).toBe('Updated');
137
+ expect((_c = shardEntries.get('hot-s')) === null || _c === void 0 ? void 0 : _c.shard.manifest.version).toBe('0.0.1');
138
+ });
139
+ });