sh3-core 0.21.2 → 0.22.0

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 (40) hide show
  1. package/dist/__test__/reset.js +2 -0
  2. package/dist/apps/lifecycle.js +49 -2
  3. package/dist/apps/lifecycle.test.js +234 -0
  4. package/dist/artifact.d.ts +2 -0
  5. package/dist/build.js +1 -1
  6. package/dist/documents/handle.d.ts +1 -1
  7. package/dist/documents/handle.js +37 -24
  8. package/dist/documents/handle.test.js +63 -0
  9. package/dist/documents/types.d.ts +6 -0
  10. package/dist/layout/LayoutRenderer.svelte +1 -1
  11. package/dist/layout/SlotContainer.svelte +1 -0
  12. package/dist/layout/inspection.js +19 -14
  13. package/dist/layout/inspection.svelte.test.js +136 -1
  14. package/dist/layout/slotHostPool.svelte.d.ts +2 -1
  15. package/dist/layout/slotHostPool.svelte.js +6 -3
  16. package/dist/layout/slotHostPool.test.js +17 -0
  17. package/dist/layout/store.projectScope.test.d.ts +1 -0
  18. package/dist/layout/store.projectScope.test.js +76 -0
  19. package/dist/layout/store.svelte.d.ts +6 -0
  20. package/dist/layout/store.svelte.js +43 -13
  21. package/dist/layout/tree-walk.d.ts +8 -1
  22. package/dist/layout/tree-walk.js +11 -1
  23. package/dist/layout/tree-walk.test.js +53 -1
  24. package/dist/layout/types.d.ts +27 -0
  25. package/dist/layout/types.test.js +28 -0
  26. package/dist/overlays/FloatFrame.svelte +4 -1
  27. package/dist/overlays/float.d.ts +7 -1
  28. package/dist/overlays/float.js +4 -0
  29. package/dist/projects-shard/ProjectsSection.svelte +1 -5
  30. package/dist/shards/activate-runtime.test.js +45 -0
  31. package/dist/shards/activate.svelte.js +5 -1
  32. package/dist/shards/app-binding.svelte.d.ts +8 -0
  33. package/dist/shards/app-binding.svelte.js +30 -0
  34. package/dist/shards/app-binding.test.d.ts +1 -0
  35. package/dist/shards/app-binding.test.js +25 -0
  36. package/dist/shards/types.d.ts +77 -10
  37. package/dist/shell-shard/shellShard.svelte.js +10 -1
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +1 -1
@@ -9,6 +9,7 @@ import { __resetLayoutStoreForTest } from '../layout/store.svelte';
9
9
  import { resetSlotHostPool } from '../layout/slotHostPool.svelte';
10
10
  import { __resetViewRegistryForTest } from '../shards/registry';
11
11
  import { __resetShardRegistryForTest } from '../shards/activate.svelte';
12
+ import { __resetShardBindingsForTest } from '../shards/app-binding.svelte';
12
13
  import { __resetAppRegistryForTest } from '../apps/registry.svelte';
13
14
  import { __resetDispatcherStateForTest } from '../actions/state.svelte';
14
15
  import { __resetSelectionForTest } from '../actions/selection.svelte';
@@ -35,6 +36,7 @@ export function resetFramework() {
35
36
  resetSlotHostPool();
36
37
  __resetViewRegistryForTest();
37
38
  __resetShardRegistryForTest();
39
+ __resetShardBindingsForTest();
38
40
  __resetAppRegistryForTest();
39
41
  __resetDispatcherStateForTest();
40
42
  __resetSelectionForTest();
@@ -14,7 +14,8 @@
14
14
  import { createStateZones } from '../state/zones.svelte';
15
15
  import { createGestureRegistry } from '../gestures';
16
16
  import { activateShard, deactivateShard, getShardContext, registeredShards, } from '../shards/activate.svelte';
17
- import { attachApp, acquireAppSlotHolds, detachApp, switchToApp, switchToHome, } from '../layout/store.svelte';
17
+ import { bindShardToApp, clearShardBinding } from '../shards/app-binding.svelte';
18
+ import { attachApp, acquireAppSlotHolds, detachApp, switchToApp, switchToHome, getActiveAppTree, } from '../layout/store.svelte';
18
19
  import { activeApp, breadcrumbApp, getRegisteredApp, registeredApps } from './registry.svelte';
19
20
  import { createZoneManager } from '../state/manage';
20
21
  import { PERMISSION_STATE_MANAGE } from '../state/types';
@@ -23,8 +24,10 @@ import { clearSelectionUnconditional } from '../actions/selection.svelte';
23
24
  import { loadUserBindings } from '../actions/bindings-store';
24
25
  import { toastManager } from '../overlays/toast';
25
26
  import { clearAppNavEntries } from '../navigation/back-stack';
26
- import { getActiveScopeId } from '../documents/config';
27
+ import { getActiveScopeId, getDocumentBackend } from '../documents/config';
27
28
  import { sessionState } from '../projects/session-state.svelte';
29
+ import { createDocumentHandle } from '../documents/handle';
30
+ import { collectRestoredSlots } from '../layout/tree-walk';
28
31
  // ---------- last-active-app user zone ------------------------------------
29
32
  /**
30
33
  * Framework-reserved user-zone slot storing which app to boot into on
@@ -144,6 +147,11 @@ export async function launchApp(id, opts = {}) {
144
147
  for (const shardId of app.manifest.requiredShards) {
145
148
  await activateShard(shardId, { phase: 'launch' });
146
149
  }
150
+ for (const shardId of app.manifest.requiredShards) {
151
+ const shard = registeredShards.get(shardId);
152
+ if (!(shard === null || shard === void 0 ? void 0 : shard.autostart))
153
+ bindShardToApp(shardId, id);
154
+ }
147
155
  }
148
156
  catch (err) {
149
157
  detachApp();
@@ -156,6 +164,31 @@ export async function launchApp(id, opts = {}) {
156
164
  }
157
165
  throw err;
158
166
  }
167
+ // Notify shards that an app is activating so they can set up app-scoped resources.
168
+ const appActivateCtx = {
169
+ appId: id,
170
+ documents(options) {
171
+ return createDocumentHandle(getActiveScopeId(), id, getDocumentBackend(), Object.assign(Object.assign({ format: 'text' }, options), { appId: id }));
172
+ },
173
+ };
174
+ for (const shardId of app.manifest.requiredShards) {
175
+ const shard = registeredShards.get(shardId);
176
+ const shardCtx = getShardContext(shardId);
177
+ if ((shard === null || shard === void 0 ? void 0 : shard.onAppActivate) && shardCtx) {
178
+ await shard.onAppActivate(id, appActivateCtx);
179
+ }
180
+ }
181
+ // Collect the layout's restored slots for the restore hooks.
182
+ const tree = getActiveAppTree();
183
+ const restoredSlots = tree ? collectRestoredSlots(tree) : [];
184
+ // Await onLayoutWillRestore so async slot-contribution registrations
185
+ // complete before acquireAppSlotHolds triggers view factory mount.
186
+ for (const shardId of app.manifest.requiredShards) {
187
+ const shard = registeredShards.get(shardId);
188
+ if (shard === null || shard === void 0 ? void 0 : shard.onLayoutWillRestore) {
189
+ await shard.onLayoutWillRestore(restoredSlots);
190
+ }
191
+ }
159
192
  // Shards have registered their view factories — safe to take the
160
193
  // refcount holds on the app's slots now (pool's factory lookup
161
194
  // happens in a microtask from this call).
@@ -165,6 +198,12 @@ export async function launchApp(id, opts = {}) {
165
198
  setActiveApp(id, new Set((_g = app.manifest.requiredShards) !== null && _g !== void 0 ? _g : []));
166
199
  void loadUserBindings(id).then(setUserBindings);
167
200
  switchToApp();
201
+ for (const shardId of app.manifest.requiredShards) {
202
+ const shard = registeredShards.get(shardId);
203
+ if (shard === null || shard === void 0 ? void 0 : shard.onLayoutRestored) {
204
+ shard.onLayoutRestored(restoredSlots);
205
+ }
206
+ }
168
207
  void ((_h = app.onAppReady) === null || _h === void 0 ? void 0 : _h.call(app, getOrCreateAppContext(id)));
169
208
  if (!opts.skipLastApp)
170
209
  writeLastApp(id);
@@ -189,6 +228,13 @@ export function unloadApp(id, skipSwitchToHome = false) {
189
228
  if (!app)
190
229
  return;
191
230
  void ((_a = app.deactivate) === null || _a === void 0 ? void 0 : _a.call(app));
231
+ // Notify shards that the app is deactivating so they can tear down app-scoped resources.
232
+ for (const shardId of app.manifest.requiredShards) {
233
+ const shard = registeredShards.get(shardId);
234
+ if (shard === null || shard === void 0 ? void 0 : shard.onAppDeactivate) {
235
+ void shard.onAppDeactivate(id);
236
+ }
237
+ }
192
238
  // Detach layout (releases the refcount holds; pool cleanup runs on
193
239
  // the next microtask for any slots that no longer have a renderer).
194
240
  // Switch to home first so LayoutRenderer stops reading the app's
@@ -207,6 +253,7 @@ export function unloadApp(id, skipSwitchToHome = false) {
207
253
  const shard = registeredShards.get(shardId);
208
254
  if (!shard)
209
255
  continue;
256
+ clearShardBinding(shardId);
210
257
  if (shard.autostart)
211
258
  continue; // self-starter stays running
212
259
  deactivateShard(shardId);
@@ -660,3 +660,237 @@ describe('launchApp — ctx.args', () => {
660
660
  expect(receivedArgs).toEqual({});
661
661
  });
662
662
  });
663
+ // ---------------------------------------------------------------------------
664
+ // Scenario — onAppActivate / onAppDeactivate / layout hooks
665
+ // ---------------------------------------------------------------------------
666
+ describe('launchApp — onAppActivate hook', () => {
667
+ beforeEach(resetFramework);
668
+ it('calls onAppActivate on required shards after activation with correct appId', async () => {
669
+ const calls = [];
670
+ const shard = makeShard({
671
+ manifest: makeShardManifest({ id: 'activate-hook-shard' }),
672
+ activate() { },
673
+ onAppActivate(appId) { calls.push({ shardId: 'activate-hook-shard', appId }); },
674
+ });
675
+ registerShard(shard);
676
+ const app = makeApp({
677
+ manifest: makeAppManifest({ id: 'activate-hook-app', requiredShards: ['activate-hook-shard'] }),
678
+ });
679
+ registerApp(app);
680
+ await launchApp('activate-hook-app');
681
+ expect(calls).toEqual([{ shardId: 'activate-hook-shard', appId: 'activate-hook-app' }]);
682
+ });
683
+ it('provides a documents() factory on AppActivateContext that uses appId namespace', async () => {
684
+ const { MemoryDocumentBackend } = await import('../documents/backends');
685
+ const { __setDocumentBackend } = await import('../documents/config');
686
+ const backend = new MemoryDocumentBackend();
687
+ __setDocumentBackend(backend);
688
+ // Capture appCtx so we can test synchronously after launch
689
+ let capturedAppCtx = null;
690
+ const shard = makeShard({
691
+ manifest: makeShardManifest({ id: 'ns-shard2' }),
692
+ activate() { },
693
+ onAppActivate(_appId, appCtx) { capturedAppCtx = appCtx; },
694
+ });
695
+ registerShard(shard);
696
+ const app = makeApp({
697
+ manifest: makeAppManifest({ id: 'ns-app2', requiredShards: ['ns-shard2'] }),
698
+ });
699
+ registerApp(app);
700
+ await launchApp('ns-app2');
701
+ const handle = capturedAppCtx.documents({ format: 'text' });
702
+ await handle.write('probe.txt', 'hello');
703
+ // Written under appId namespace, not shardId (scope is 'local' by default)
704
+ const inAppNs = await backend.list('local', 'ns-app2');
705
+ const inShardNs = await backend.list('local', 'ns-shard2');
706
+ expect(inAppNs.some((k) => k.path === 'probe.txt')).toBe(true);
707
+ expect(inShardNs).toHaveLength(0);
708
+ });
709
+ });
710
+ describe('unloadApp — onAppDeactivate hook', () => {
711
+ beforeEach(resetFramework);
712
+ it('calls onAppDeactivate on required shards when the app is unloaded', async () => {
713
+ const deactivated = [];
714
+ const shard = makeShard({
715
+ manifest: makeShardManifest({ id: 'deact-shard' }),
716
+ activate() { },
717
+ onAppDeactivate(appId) { deactivated.push(appId); },
718
+ });
719
+ registerShard(shard);
720
+ const app = makeApp({
721
+ manifest: makeAppManifest({ id: 'deact-app', requiredShards: ['deact-shard'] }),
722
+ });
723
+ registerApp(app);
724
+ await launchApp('deact-app');
725
+ unregisterApp('deact-app');
726
+ expect(deactivated).toEqual(['deact-app']);
727
+ });
728
+ });
729
+ describe('launchApp — onLayoutWillRestore / onLayoutRestored hooks', () => {
730
+ beforeEach(resetFramework);
731
+ it('calls onLayoutWillRestore before slot acquisition and onLayoutRestored after switchToApp', async () => {
732
+ const order = [];
733
+ const capturedSlots = [];
734
+ const shard = makeShard({
735
+ manifest: makeShardManifest({ id: 'restore-shard' }),
736
+ activate() { },
737
+ onLayoutWillRestore(slots) {
738
+ order.push('onLayoutWillRestore');
739
+ capturedSlots.push([...slots]);
740
+ },
741
+ onLayoutRestored(slots) {
742
+ order.push('onLayoutRestored');
743
+ capturedSlots.push([...slots]);
744
+ },
745
+ });
746
+ registerShard(shard);
747
+ const app = makeApp({
748
+ manifest: makeAppManifest({ id: 'restore-app', requiredShards: ['restore-shard'] }),
749
+ initialLayout: [
750
+ { name: 'default', tree: makeTree(makeSlotNode('r-slot', 'restore:view')) },
751
+ ],
752
+ });
753
+ registerApp(app);
754
+ await launchApp('restore-app');
755
+ await Promise.resolve();
756
+ expect(order).toEqual(['onLayoutWillRestore', 'onLayoutRestored']);
757
+ // Both calls receive the same slot list
758
+ expect(capturedSlots[0]).toEqual(capturedSlots[1]);
759
+ expect(capturedSlots[0]).toHaveLength(1);
760
+ expect(capturedSlots[0][0]).toMatchObject({ slotId: 'r-slot', viewId: 'restore:view' });
761
+ });
762
+ it('awaits onLayoutWillRestore before slot factories mount', async () => {
763
+ // This is the load-bearing assertion: a slow async hook must complete
764
+ // before any acquireAppSlotHosts triggers a view factory mount() call.
765
+ // If launchApp uses `void shard.onLayoutWillRestore(...)`, the Promise
766
+ // resolves AFTER the microtask that mounts views — failing this test.
767
+ const order = [];
768
+ let resolveHook = null;
769
+ const hookGate = new Promise((r) => { resolveHook = r; });
770
+ registerView('slow-restore:view', {
771
+ mount() {
772
+ order.push('mount');
773
+ return { unmount() { } };
774
+ },
775
+ });
776
+ const shard = makeShard({
777
+ manifest: makeShardManifest({ id: 'slow-restore-shard' }),
778
+ activate() { },
779
+ async onLayoutWillRestore() {
780
+ order.push('onLayoutWillRestore:start');
781
+ await hookGate;
782
+ order.push('onLayoutWillRestore:end');
783
+ },
784
+ });
785
+ registerShard(shard);
786
+ const app = makeApp({
787
+ manifest: makeAppManifest({ id: 'slow-restore-app', requiredShards: ['slow-restore-shard'] }),
788
+ initialLayout: [
789
+ { name: 'default', tree: makeTree(makeSlotNode('sr-slot', 'slow-restore:view')) },
790
+ ],
791
+ });
792
+ registerApp(app);
793
+ const launching = launchApp('slow-restore-app');
794
+ // Let microtasks run so the hook has started (launchApp awaits shard
795
+ // activation and onAppActivate before reaching onLayoutWillRestore, so
796
+ // we need several drains to get past those).
797
+ for (let i = 0; i < 10; i++)
798
+ await Promise.resolve();
799
+ // At this point the hook is suspended on the gate; mount must NOT have run yet.
800
+ expect(order).toEqual(['onLayoutWillRestore:start']);
801
+ resolveHook();
802
+ await launching;
803
+ for (let i = 0; i < 5; i++)
804
+ await Promise.resolve();
805
+ // The hook completed before any view factory ran.
806
+ const hookEndIdx = order.indexOf('onLayoutWillRestore:end');
807
+ const mountIdx = order.indexOf('mount');
808
+ expect(hookEndIdx).toBeGreaterThanOrEqual(0);
809
+ expect(mountIdx).toBeGreaterThanOrEqual(0);
810
+ expect(hookEndIdx).toBeLessThan(mountIdx);
811
+ });
812
+ });
813
+ // ---------------------------------------------------------------------------
814
+ // Scenario E — document auto-scope: two shards in same app share namespace
815
+ // ---------------------------------------------------------------------------
816
+ describe('launchApp — document auto-scope', () => {
817
+ beforeEach(resetFramework);
818
+ it('routes ctx.documents() from two non-autostart shards in the same app to the appId namespace', async () => {
819
+ const { MemoryDocumentBackend } = await import('../documents/backends');
820
+ const { __setDocumentBackend } = await import('../documents/config');
821
+ const backend = new MemoryDocumentBackend();
822
+ __setDocumentBackend(backend);
823
+ let handleA = null;
824
+ let handleB = null;
825
+ registerShard(makeShard({
826
+ manifest: makeShardManifest({ id: 'shard-share-A' }),
827
+ activate(ctx) { handleA = ctx.documents({ format: 'text' }); },
828
+ }));
829
+ registerShard(makeShard({
830
+ manifest: makeShardManifest({ id: 'shard-share-B' }),
831
+ activate(ctx) { handleB = ctx.documents({ format: 'text' }); },
832
+ }));
833
+ registerApp(makeApp({
834
+ manifest: makeAppManifest({
835
+ id: 'app-share',
836
+ requiredShards: ['shard-share-A', 'shard-share-B'],
837
+ }),
838
+ }));
839
+ await launchApp('app-share');
840
+ await handleA.write('shared.json', '{"from":"A"}');
841
+ expect(await handleB.read('shared.json')).toBe('{"from":"A"}');
842
+ expect(await backend.list('local', 'app-share')).toHaveLength(1);
843
+ expect(await backend.list('local', 'shard-share-A')).toHaveLength(0);
844
+ expect(await backend.list('local', 'shard-share-B')).toHaveLength(0);
845
+ });
846
+ });
847
+ describe('launchApp — document auto-scope autostart exception', () => {
848
+ beforeEach(resetFramework);
849
+ it('keeps autostart shards on shard-scoped namespace even when required by an active app', async () => {
850
+ const { MemoryDocumentBackend } = await import('../documents/backends');
851
+ const { __setDocumentBackend } = await import('../documents/config');
852
+ const backend = new MemoryDocumentBackend();
853
+ __setDocumentBackend(backend);
854
+ let handle = null;
855
+ registerShard(makeShard({
856
+ manifest: makeShardManifest({ id: 'autostart-shard' }),
857
+ activate(ctx) { handle = ctx.documents({ format: 'text' }); },
858
+ autostart() { },
859
+ }));
860
+ registerApp(makeApp({
861
+ manifest: makeAppManifest({
862
+ id: 'app-with-autostart',
863
+ requiredShards: ['autostart-shard'],
864
+ }),
865
+ }));
866
+ await launchApp('app-with-autostart');
867
+ await handle.write('persistent.txt', 'lives in autostart-shard');
868
+ expect((await backend.list('local', 'autostart-shard')).map(d => d.path))
869
+ .toContain('persistent.txt');
870
+ expect(await backend.list('local', 'app-with-autostart')).toHaveLength(0);
871
+ });
872
+ });
873
+ describe('unloadApp — clears document binding', () => {
874
+ beforeEach(resetFramework);
875
+ it('reverts a non-autostart shard back to shard-scope when the app is unloaded', async () => {
876
+ const { MemoryDocumentBackend } = await import('../documents/backends');
877
+ const { __setDocumentBackend } = await import('../documents/config');
878
+ const backend = new MemoryDocumentBackend();
879
+ __setDocumentBackend(backend);
880
+ const { getShardBinding } = await import('../shards/app-binding.svelte');
881
+ registerShard(makeShard({
882
+ manifest: makeShardManifest({ id: 'binding-shard' }),
883
+ activate() { },
884
+ }));
885
+ registerApp(makeApp({
886
+ manifest: makeAppManifest({
887
+ id: 'binding-app',
888
+ requiredShards: ['binding-shard'],
889
+ }),
890
+ }));
891
+ await launchApp('binding-app');
892
+ expect(getShardBinding('binding-shard')).toBe('binding-app');
893
+ unregisterApp('binding-app');
894
+ expect(getShardBinding('binding-shard')).toBeNull();
895
+ });
896
+ });
@@ -31,4 +31,6 @@ export interface ArtifactManifest {
31
31
  }>;
32
32
  /** Shard ids this app requires to be installed on the server. Written by sh3Artifact for app/combo bundles. */
33
33
  requiredShards?: string[];
34
+ /** Shard ids that are bundled inside this combo artifact. Written by sh3Artifact for combo bundles. */
35
+ bundledShards?: string[];
34
36
  }
package/dist/build.js CHANGED
@@ -362,7 +362,7 @@ export function sh3Artifact(options = {}) {
362
362
  if (!finalAuthor) {
363
363
  throw new Error('[sh3-artifact] Missing "author". Add it to package.json or pass it via sh3Artifact({ manifest: { author } }).');
364
364
  }
365
- const manifest = Object.assign(Object.assign(Object.assign(Object.assign({ id: id || 'unknown', type, label: label || id || 'unknown', version: artifactVersion, contractVersion: 1 }, (hasServer ? { server: 'server.js' } : {})), { description: finalDescription, author: finalAuthor }), ((type === 'app' || type === 'combo') ? { requiredShards } : {})), overrides);
365
+ const manifest = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ id: id || 'unknown', type, label: label || id || 'unknown', version: artifactVersion, contractVersion: 1 }, (hasServer ? { server: 'server.js' } : {})), { description: finalDescription, author: finalAuthor }), ((type === 'app' || type === 'combo') ? { requiredShards } : {})), (type === 'combo' && bundledShardIds.size > 0 ? { bundledShards: [...bundledShardIds] } : {})), overrides);
366
366
  // Read the emitted JS files as bytes for the archive
367
367
  const clientBytes = readFileSync(join(outDir, 'client.js'));
368
368
  const serverBytes = hasServer ? readFileSync(join(outDir, 'server.js')) : undefined;
@@ -3,4 +3,4 @@ import type { DocumentBackend, DocumentHandle, DocumentHandleOptions } from './t
3
3
  * Create a document handle scoped to a tenant, shard, and file filter.
4
4
  * The framework calls this from `ShardContext.documents()`.
5
5
  */
6
- export declare function createDocumentHandle(tenantId: string, shardId: string, backend: DocumentBackend, options: DocumentHandleOptions): DocumentHandle;
6
+ export declare function createDocumentHandle(tenantId: string | (() => string), shardOrNamespace: string | (() => string), backend: DocumentBackend, options: DocumentHandleOptions): DocumentHandle;
@@ -23,9 +23,16 @@ const DEFAULT_DEBOUNCE_MS = 1000;
23
23
  * Create a document handle scoped to a tenant, shard, and file filter.
24
24
  * The framework calls this from `ShardContext.documents()`.
25
25
  */
26
- export function createDocumentHandle(tenantId, shardId, backend, options) {
26
+ export function createDocumentHandle(tenantId, shardOrNamespace, backend, options) {
27
27
  const controllers = new Set();
28
28
  const unsubscribers = new Set();
29
+ const resolveBoundTenant = typeof tenantId === 'function' ? tenantId : () => tenantId;
30
+ const baseNamespace = typeof shardOrNamespace === 'function' ? shardOrNamespace : () => shardOrNamespace;
31
+ // `options.appId` is the explicit override — when set it wins over the lazy
32
+ // resolver (used by appCtx.documents and as a power-user escape hatch).
33
+ const resolveNamespace = options.appId
34
+ ? () => options.appId
35
+ : baseNamespace;
29
36
  function matchesExtensions(path) {
30
37
  if (!options.extensions || options.extensions.length === 0)
31
38
  return true;
@@ -33,21 +40,21 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
33
40
  }
34
41
  function resolveTenant(opts) {
35
42
  var _a;
36
- return (_a = opts === null || opts === void 0 ? void 0 : opts.scope) !== null && _a !== void 0 ? _a : tenantId;
43
+ return (_a = opts === null || opts === void 0 ? void 0 : opts.scope) !== null && _a !== void 0 ? _a : resolveBoundTenant();
37
44
  }
38
45
  function emitChange(type, path, tid) {
39
- documentChanges.emit({ type, path, tenantId: tid, shardId });
46
+ documentChanges.emit({ type, path, tenantId: tid, shardId: resolveNamespace() });
40
47
  }
41
48
  const handle = {
42
49
  async list(opts) {
43
50
  const tid = resolveTenant(opts);
44
- const all = await backend.list(tid, shardId);
51
+ const all = await backend.list(tid, resolveNamespace());
45
52
  if (!options.extensions || options.extensions.length === 0)
46
53
  return all;
47
54
  return all.filter((meta) => matchesExtensions(meta.path));
48
55
  },
49
56
  async read(path, opts) {
50
- const content = await backend.read(resolveTenant(opts), shardId, path);
57
+ const content = await backend.read(resolveTenant(opts), resolveNamespace(), path);
51
58
  if (content === null)
52
59
  return null;
53
60
  // Phase 1: text format only. Binary returns as-is from the backend
@@ -56,14 +63,16 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
56
63
  },
57
64
  async write(path, content, opts) {
58
65
  const tid = resolveTenant(opts);
59
- const existed = await backend.exists(tid, shardId, path);
60
- await backend.write(tid, shardId, path, content);
66
+ const ns = resolveNamespace();
67
+ const existed = await backend.exists(tid, ns, path);
68
+ await backend.write(tid, ns, path, content);
61
69
  emitChange(existed ? 'update' : 'create', path, tid);
62
70
  },
63
71
  async delete(path, opts) {
64
72
  const tid = resolveTenant(opts);
65
- const existed = await backend.exists(tid, shardId, path);
66
- await backend.delete(tid, shardId, path);
73
+ const ns = resolveNamespace();
74
+ const existed = await backend.exists(tid, ns, path);
75
+ await backend.delete(tid, ns, path);
67
76
  if (existed)
68
77
  emitChange('delete', path, tid);
69
78
  },
@@ -77,19 +86,21 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
77
86
  }
78
87
  }
79
88
  const tid = resolveTenant(opts);
80
- await backend.rename(tid, shardId, oldPath, newPath);
89
+ const ns = resolveNamespace();
90
+ await backend.rename(tid, ns, oldPath, newPath);
81
91
  documentChanges.emit({
82
92
  type: 'rename',
83
93
  path: newPath,
84
94
  oldPath,
85
95
  tenantId: tid,
86
- shardId,
96
+ shardId: ns,
87
97
  });
88
98
  },
89
99
  async mkdir(path, opts) {
90
100
  const tid = resolveTenant(opts);
91
- await backend.mkdir(tid, shardId, path);
92
- documentChanges.emit({ type: 'folder-create', path, tenantId: tid, shardId });
101
+ const ns = resolveNamespace();
102
+ await backend.mkdir(tid, ns, path);
103
+ documentChanges.emit({ type: 'folder-create', path, tenantId: tid, shardId: ns });
93
104
  },
94
105
  async rmdir(path, opts) {
95
106
  var _a;
@@ -103,8 +114,9 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
103
114
  }
104
115
  }
105
116
  const tid = resolveTenant(opts);
106
- await backend.rmdir(tid, shardId, path, { recursive });
107
- documentChanges.emit({ type: 'folder-delete', path, tenantId: tid, shardId });
117
+ const ns = resolveNamespace();
118
+ await backend.rmdir(tid, ns, path, { recursive });
119
+ documentChanges.emit({ type: 'folder-delete', path, tenantId: tid, shardId: ns });
108
120
  },
109
121
  async renameFolder(oldPath, newPath, opts) {
110
122
  const folderPrefix = oldPath + '/';
@@ -114,25 +126,26 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
114
126
  }
115
127
  }
116
128
  const tid = resolveTenant(opts);
117
- await backend.renameFolder(tid, shardId, oldPath, newPath);
129
+ const ns = resolveNamespace();
130
+ await backend.renameFolder(tid, ns, oldPath, newPath);
118
131
  documentChanges.emit({
119
132
  type: 'folder-rename',
120
133
  path: newPath,
121
134
  oldPath,
122
135
  tenantId: tid,
123
- shardId,
136
+ shardId: ns,
124
137
  });
125
138
  },
126
139
  async listFolders(prefix, opts) {
127
- return backend.listFolders(resolveTenant(opts), shardId, prefix !== null && prefix !== void 0 ? prefix : '');
140
+ return backend.listFolders(resolveTenant(opts), resolveNamespace(), prefix !== null && prefix !== void 0 ? prefix : '');
128
141
  },
129
142
  async exists(path) {
130
- return backend.exists(tenantId, shardId, path);
143
+ return backend.exists(resolveBoundTenant(), resolveNamespace(), path);
131
144
  },
132
145
  async status(path) {
133
146
  if (!backend.readMeta)
134
147
  throw new Error('Backend does not support status()');
135
- return backend.readMeta(tenantId, shardId, path);
148
+ return backend.readMeta(resolveBoundTenant(), resolveNamespace(), path);
136
149
  },
137
150
  async resolveConflict(path, choice) {
138
151
  if (!backend.resolve)
@@ -140,19 +153,19 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
140
153
  if (typeof choice !== 'string' && !(typeof choice === 'object' && 'origin' in choice)) {
141
154
  throw new Error('choice must be a string or { origin } object');
142
155
  }
143
- return backend.resolve(tenantId, shardId, path, choice);
156
+ return backend.resolve(resolveBoundTenant(), resolveNamespace(), path, choice);
144
157
  },
145
158
  async readBranch(path, origin) {
146
159
  if (!backend.readBranch)
147
160
  throw new Error('Backend does not support readBranch()');
148
- return backend.readBranch(tenantId, shardId, path, origin);
161
+ return backend.readBranch(resolveBoundTenant(), resolveNamespace(), path, origin);
149
162
  },
150
163
  watch(callback) {
151
164
  // Subscribe to global emitter, filtered to this handle's scope.
152
165
  const unsub = documentChanges.subscribe((change) => {
153
- if (change.tenantId !== tenantId)
166
+ if (change.tenantId !== resolveBoundTenant())
154
167
  return;
155
- if (change.shardId !== shardId)
168
+ if (change.shardId !== resolveNamespace())
156
169
  return;
157
170
  if (!matchesExtensions(change.path))
158
171
  return;
@@ -251,3 +251,66 @@ describe('DocumentHandle folder ops', () => {
251
251
  ]);
252
252
  });
253
253
  });
254
+ describe('createDocumentHandle — appId override', () => {
255
+ it('uses appId as the namespace root when provided', async () => {
256
+ const backend = new MemoryDocumentBackend();
257
+ const handle = createDocumentHandle('tenant-1', 'sh3-editor', backend, { format: 'text', appId: 'guml-ide' });
258
+ await handle.write('test.guml', 'content');
259
+ const keysAppId = await backend.list('tenant-1', 'guml-ide');
260
+ expect(keysAppId.some((k) => k.path === 'test.guml')).toBe(true);
261
+ const keysShardId = await backend.list('tenant-1', 'sh3-editor');
262
+ expect(keysShardId).toHaveLength(0);
263
+ });
264
+ it('falls back to shardId when appId is not provided', async () => {
265
+ const backend = new MemoryDocumentBackend();
266
+ const handle = createDocumentHandle('tenant-1', 'sh3-editor', backend, { format: 'text' });
267
+ await handle.write('test.guml', 'content');
268
+ const keys = await backend.list('tenant-1', 'sh3-editor');
269
+ expect(keys.some((k) => k.path === 'test.guml')).toBe(true);
270
+ });
271
+ });
272
+ describe('createDocumentHandle — lazy resolvers', () => {
273
+ it('resolves namespace per operation from a function', async () => {
274
+ const { MemoryDocumentBackend } = await import('./backends');
275
+ const backend = new MemoryDocumentBackend();
276
+ let ns = 'shard-A';
277
+ const handle = createDocumentHandle('local', () => ns, backend, { format: 'text' });
278
+ await handle.write('foo.txt', 'first');
279
+ expect((await backend.list('local', 'shard-A')).map(d => d.path)).toContain('foo.txt');
280
+ ns = 'app-X';
281
+ await handle.write('bar.txt', 'second');
282
+ expect((await backend.list('local', 'app-X')).map(d => d.path)).toContain('bar.txt');
283
+ expect((await backend.list('local', 'shard-A')).map(d => d.path)).not.toContain('bar.txt');
284
+ });
285
+ it('resolves tenant per operation from a function', async () => {
286
+ const { MemoryDocumentBackend } = await import('./backends');
287
+ const backend = new MemoryDocumentBackend();
288
+ let tenant = 'alice';
289
+ const handle = createDocumentHandle(() => tenant, () => 'shard-A', backend, { format: 'text' });
290
+ await handle.write('p.txt', 'a');
291
+ expect((await backend.list('alice', 'shard-A')).map(d => d.path)).toContain('p.txt');
292
+ tenant = 'bob';
293
+ await handle.write('q.txt', 'b');
294
+ expect((await backend.list('bob', 'shard-A')).map(d => d.path)).toContain('q.txt');
295
+ });
296
+ it('watchers resolve namespace at emit time, not subscribe time', async () => {
297
+ const { MemoryDocumentBackend } = await import('./backends');
298
+ const backend = new MemoryDocumentBackend();
299
+ let ns = 'shard-A';
300
+ const handle = createDocumentHandle('local', () => ns, backend, { format: 'text' });
301
+ const seen = [];
302
+ handle.watch((c) => seen.push(`${c.type}:${c.path}`));
303
+ await handle.write('a.txt', 'x');
304
+ ns = 'app-X';
305
+ await handle.write('b.txt', 'y');
306
+ expect(seen).toContain('create:a.txt');
307
+ expect(seen).toContain('create:b.txt');
308
+ });
309
+ it('still accepts string tenant + namespace for back-compat', async () => {
310
+ const { MemoryDocumentBackend } = await import('./backends');
311
+ const backend = new MemoryDocumentBackend();
312
+ const handle = createDocumentHandle('local', 'shard-A', backend, { format: 'text' });
313
+ await handle.write('foo.txt', 'hi');
314
+ expect((await backend.list('local', 'shard-A')).map(d => d.path)).toContain('foo.txt');
315
+ });
316
+ });
@@ -50,6 +50,12 @@ export interface DocumentHandleOptions {
50
50
  format: DocumentFormat;
51
51
  /** File extensions this handle operates on, e.g. ['.guml', '.md']. */
52
52
  extensions?: string[];
53
+ /**
54
+ * When set, this handle's namespace root becomes `{scope}/docs/{appId}/`
55
+ * instead of `{scope}/docs/{shardId}/`. Use in `onAppActivate` to get
56
+ * a handle scoped to the app's shared document namespace.
57
+ */
58
+ appId?: string;
53
59
  }
54
60
  /** Metadata about a stored document. */
55
61
  export interface DocumentMeta {
@@ -227,7 +227,7 @@
227
227
  {#if entry}
228
228
  <div class="tab-slot-wrapper">
229
229
  <SlotContainer
230
- node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }}
230
+ node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId, props: entry.props }}
231
231
  label={entry.label}
232
232
  meta={entry.meta}
233
233
  />
@@ -71,6 +71,7 @@
71
71
  node.viewId,
72
72
  label || node.viewId || currentSlotId,
73
73
  meta,
74
+ node.props,
74
75
  );
75
76
  wrapperEl.appendChild(host);
76
77