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.
- package/dist/__test__/reset.js +2 -0
- package/dist/apps/lifecycle.js +49 -2
- package/dist/apps/lifecycle.test.js +234 -0
- package/dist/artifact.d.ts +2 -0
- package/dist/build.js +1 -1
- package/dist/documents/handle.d.ts +1 -1
- package/dist/documents/handle.js +37 -24
- package/dist/documents/handle.test.js +63 -0
- package/dist/documents/types.d.ts +6 -0
- package/dist/layout/LayoutRenderer.svelte +1 -1
- package/dist/layout/SlotContainer.svelte +1 -0
- package/dist/layout/inspection.js +19 -14
- package/dist/layout/inspection.svelte.test.js +136 -1
- package/dist/layout/slotHostPool.svelte.d.ts +2 -1
- package/dist/layout/slotHostPool.svelte.js +6 -3
- package/dist/layout/slotHostPool.test.js +17 -0
- package/dist/layout/store.projectScope.test.d.ts +1 -0
- package/dist/layout/store.projectScope.test.js +76 -0
- package/dist/layout/store.svelte.d.ts +6 -0
- package/dist/layout/store.svelte.js +43 -13
- package/dist/layout/tree-walk.d.ts +8 -1
- package/dist/layout/tree-walk.js +11 -1
- package/dist/layout/tree-walk.test.js +53 -1
- package/dist/layout/types.d.ts +27 -0
- package/dist/layout/types.test.js +28 -0
- package/dist/overlays/FloatFrame.svelte +4 -1
- package/dist/overlays/float.d.ts +7 -1
- package/dist/overlays/float.js +4 -0
- package/dist/projects-shard/ProjectsSection.svelte +1 -5
- package/dist/shards/activate-runtime.test.js +45 -0
- package/dist/shards/activate.svelte.js +5 -1
- package/dist/shards/app-binding.svelte.d.ts +8 -0
- package/dist/shards/app-binding.svelte.js +30 -0
- package/dist/shards/app-binding.test.d.ts +1 -0
- package/dist/shards/app-binding.test.js +25 -0
- package/dist/shards/types.d.ts +77 -10
- package/dist/shell-shard/shellShard.svelte.js +10 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/__test__/reset.js
CHANGED
|
@@ -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();
|
package/dist/apps/lifecycle.js
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|
package/dist/artifact.d.ts
CHANGED
|
@@ -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,
|
|
6
|
+
export declare function createDocumentHandle(tenantId: string | (() => string), shardOrNamespace: string | (() => string), backend: DocumentBackend, options: DocumentHandleOptions): DocumentHandle;
|
package/dist/documents/handle.js
CHANGED
|
@@ -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,
|
|
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 :
|
|
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,
|
|
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),
|
|
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
|
|
60
|
-
await backend.
|
|
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
|
|
66
|
-
await backend.
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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),
|
|
140
|
+
return backend.listFolders(resolveTenant(opts), resolveNamespace(), prefix !== null && prefix !== void 0 ? prefix : '');
|
|
128
141
|
},
|
|
129
142
|
async exists(path) {
|
|
130
|
-
return backend.exists(
|
|
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(
|
|
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(
|
|
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(
|
|
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 !==
|
|
166
|
+
if (change.tenantId !== resolveBoundTenant())
|
|
154
167
|
return;
|
|
155
|
-
if (change.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
|
/>
|