sh3-core 0.23.2 → 0.25.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/BrandSlot.svelte +62 -3
- package/dist/BrandSlot.test.js +52 -0
- package/dist/Sh3.svelte +4 -4
- package/dist/actions/listActive.js +1 -0
- package/dist/actions/listActive.test.js +13 -0
- package/dist/actions/types.d.ts +12 -0
- package/dist/api.d.ts +2 -0
- package/dist/api.js +2 -0
- package/dist/app/store/StoreView.svelte +1 -1
- package/dist/apps/types.d.ts +8 -0
- package/dist/chrome/MenuSheet.svelte +19 -6
- package/dist/contributions/contextSource.d.ts +48 -0
- package/dist/contributions/contextSource.js +21 -0
- package/dist/documents/picker-primitive.d.ts +0 -9
- package/dist/documents/picker-primitive.js +0 -9
- package/dist/layout/store.svelte.js +1 -1
- package/dist/overlays/presets.d.ts +17 -2
- package/dist/overlays/presets.js +28 -2
- package/dist/overlays/presets.test.js +29 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte +9 -7
- package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +44 -27
- package/dist/primitives/widgets/_DocumentBrowser.svelte +4 -4
- package/dist/registry/installer.js +50 -10
- package/dist/registry/installer.test.d.ts +1 -0
- package/dist/registry/installer.test.js +146 -0
- package/dist/registry/types.d.ts +19 -0
- package/dist/runtime/runVerb.test.js +87 -0
- package/dist/sh3core-shard/Sh3Home.svelte +0 -1
- package/dist/shards/lifecycle.svelte.d.ts +8 -0
- package/dist/shards/lifecycle.svelte.js +17 -0
- package/dist/shell-shard/verbs/xfer.js +66 -4
- package/dist/shell-shard/verbs/xfer.test.js +74 -0
- package/dist/transport/apiFetch.js +21 -3
- package/dist/transport/apiFetch.test.js +63 -0
- package/dist/verbs/types.d.ts +49 -12
- package/dist/verbs/types.test.d.ts +1 -0
- package/dist/verbs/types.test.js +43 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
<script lang="ts">
|
|
1
|
+
<script lang="ts" generics="M extends 'open' | 'save'">
|
|
2
2
|
import type { CommitOnlyEvents } from './_contract';
|
|
3
3
|
import { sh3 } from '../../sh3Runtime.svelte';
|
|
4
4
|
import DocumentBrowser from './_DocumentBrowser.svelte';
|
|
5
5
|
import type { DocumentMeta } from '../../documents/types';
|
|
6
6
|
import type { DocEntry, OpenerValue, SaverValue } from './DocumentFilePicker';
|
|
7
7
|
|
|
8
|
+
type ValueFor<Mode extends 'open' | 'save'> = Mode extends 'open' ? OpenerValue : SaverValue;
|
|
9
|
+
|
|
8
10
|
type DocListFn = () => Promise<Array<DocumentMeta & { shardId: string }>>;
|
|
9
11
|
type FolderListFn = (shardId: string, prefix: string) => Promise<string[]>;
|
|
10
12
|
type HandleFn = {
|
|
@@ -17,7 +19,7 @@
|
|
|
17
19
|
|
|
18
20
|
let {
|
|
19
21
|
mode,
|
|
20
|
-
value = $bindable<
|
|
22
|
+
value = $bindable<ValueFor<M>>(null as ValueFor<M>),
|
|
21
23
|
listDocuments,
|
|
22
24
|
listFolders,
|
|
23
25
|
handle,
|
|
@@ -29,8 +31,8 @@
|
|
|
29
31
|
selectable = 'file',
|
|
30
32
|
onchange,
|
|
31
33
|
}: {
|
|
32
|
-
mode:
|
|
33
|
-
value?:
|
|
34
|
+
mode: M;
|
|
35
|
+
value?: ValueFor<M>;
|
|
34
36
|
listDocuments: DocListFn;
|
|
35
37
|
listFolders?: FolderListFn;
|
|
36
38
|
handle?: HandleFn;
|
|
@@ -40,7 +42,7 @@
|
|
|
40
42
|
size?: 'sm' | 'md';
|
|
41
43
|
buttonLabel?: string;
|
|
42
44
|
selectable?: 'file' | 'folder' | 'both';
|
|
43
|
-
} & CommitOnlyEvents<
|
|
45
|
+
} & CommitOnlyEvents<ValueFor<M>> = $props();
|
|
44
46
|
|
|
45
47
|
let trigger = $state<HTMLButtonElement | undefined>(undefined);
|
|
46
48
|
let openFlag = $state(false);
|
|
@@ -56,8 +58,8 @@
|
|
|
56
58
|
);
|
|
57
59
|
|
|
58
60
|
function handleCommit(result: OpenerValue | SaverValue) {
|
|
59
|
-
value = result
|
|
60
|
-
onchange?.(result);
|
|
61
|
+
value = result as ValueFor<M>;
|
|
62
|
+
onchange?.(result as ValueFor<M>);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
function onOpenClosed() {
|
|
@@ -1,32 +1,49 @@
|
|
|
1
1
|
import type { CommitOnlyEvents } from './_contract';
|
|
2
2
|
import type { DocumentMeta } from '../../documents/types';
|
|
3
3
|
import type { OpenerValue, SaverValue } from './DocumentFilePicker';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
4
|
+
declare function $$render<M extends 'open' | 'save'>(): {
|
|
5
|
+
props: {
|
|
6
|
+
mode: M;
|
|
7
|
+
value?: M extends "open" ? OpenerValue : SaverValue;
|
|
8
|
+
listDocuments: () => Promise<Array<DocumentMeta & {
|
|
9
|
+
shardId: string;
|
|
10
|
+
}>>;
|
|
11
|
+
listFolders?: (shardId: string, prefix: string) => Promise<string[]>;
|
|
12
|
+
handle?: {
|
|
13
|
+
mkdir: (shardId: string, path: string) => Promise<void>;
|
|
14
|
+
rmdir: (shardId: string, path: string, opts: {
|
|
15
|
+
recursive: boolean;
|
|
16
|
+
}) => Promise<void>;
|
|
17
|
+
renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
|
|
18
|
+
rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
|
|
19
|
+
delete: (shardId: string, path: string) => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
readOnlyShard?: (shardId: string) => boolean;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
invalid?: boolean;
|
|
24
|
+
size?: "sm" | "md";
|
|
25
|
+
buttonLabel?: string;
|
|
26
|
+
selectable?: "file" | "folder" | "both";
|
|
27
|
+
} & CommitOnlyEvents<M extends "open" ? OpenerValue : SaverValue>;
|
|
28
|
+
exports: {};
|
|
29
|
+
bindings: "value";
|
|
30
|
+
slots: {};
|
|
31
|
+
events: {};
|
|
16
32
|
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
declare class __sveltets_Render<M extends 'open' | 'save'> {
|
|
34
|
+
props(): ReturnType<typeof $$render<M>>['props'];
|
|
35
|
+
events(): ReturnType<typeof $$render<M>>['events'];
|
|
36
|
+
slots(): ReturnType<typeof $$render<M>>['slots'];
|
|
37
|
+
bindings(): "value";
|
|
38
|
+
exports(): {};
|
|
39
|
+
}
|
|
40
|
+
interface $$IsomorphicComponent {
|
|
41
|
+
new <M extends 'open' | 'save'>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<M>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<M>['props']>, ReturnType<__sveltets_Render<M>['events']>, ReturnType<__sveltets_Render<M>['slots']>> & {
|
|
42
|
+
$$bindings?: ReturnType<__sveltets_Render<M>['bindings']>;
|
|
43
|
+
} & ReturnType<__sveltets_Render<M>['exports']>;
|
|
44
|
+
<M extends 'open' | 'save'>(internal: unknown, props: ReturnType<__sveltets_Render<M>['props']> & {}): ReturnType<__sveltets_Render<M>['exports']>;
|
|
45
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
46
|
+
}
|
|
47
|
+
declare const DocumentFilePicker: $$IsomorphicComponent;
|
|
48
|
+
type DocumentFilePicker<M extends 'open' | 'save'> = InstanceType<typeof DocumentFilePicker<M>>;
|
|
32
49
|
export default DocumentFilePicker;
|
|
@@ -59,7 +59,6 @@
|
|
|
59
59
|
let selected = $state<Selected>(null);
|
|
60
60
|
let filename = $state(untrack(() => suggestedName));
|
|
61
61
|
let activeIdx = $state(0);
|
|
62
|
-
let listEl = $state<HTMLElement | undefined>(undefined);
|
|
63
62
|
|
|
64
63
|
// Folder state loaded via listFolders
|
|
65
64
|
let folders = $state<string[]>([]);
|
|
@@ -459,11 +458,12 @@
|
|
|
459
458
|
</div>
|
|
460
459
|
{/if}
|
|
461
460
|
|
|
462
|
-
<div class="sh3-doc-browser__list"
|
|
461
|
+
<div class="sh3-doc-browser__list">
|
|
463
462
|
{#if confirmDelete}
|
|
464
|
-
{@const
|
|
463
|
+
{@const folderPath = confirmDelete.kind === 'folder' ? confirmDelete.fullPath : null}
|
|
464
|
+
{@const childCount = folderPath
|
|
465
465
|
? docs.filter((d) =>
|
|
466
|
-
d.shardId === shardId && d.path.startsWith(
|
|
466
|
+
d.shardId === shardId && d.path.startsWith(folderPath + '/'),
|
|
467
467
|
).length
|
|
468
468
|
: 0}
|
|
469
469
|
<div class="sh3-doc-browser__confirm-overlay">
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* packages from IndexedDB and registers them.
|
|
16
16
|
*/
|
|
17
17
|
import { loadBundleModule } from './loader';
|
|
18
|
-
import { savePackage, loadBundle, listInstalled, removePackage } from './storage';
|
|
19
|
-
import { deactivateShard } from '../shards/lifecycle.svelte';
|
|
18
|
+
import { savePackage, loadBundle, loadMeta, listInstalled, removePackage } from './storage';
|
|
19
|
+
import { deactivateShard, unregisterShard } from '../shards/lifecycle.svelte';
|
|
20
20
|
import { unregisterApp } from '../apps/lifecycle';
|
|
21
21
|
import { registerLoadedBundle } from './register';
|
|
22
22
|
import { extractBundlePermissions } from './permission-descriptions';
|
|
@@ -35,6 +35,7 @@ import { fetchServerPackages } from '../env/client';
|
|
|
35
35
|
* @returns Result object indicating success/failure and hot-load status.
|
|
36
36
|
*/
|
|
37
37
|
export async function installPackage(bundle, meta, options) {
|
|
38
|
+
var _a, _b;
|
|
38
39
|
// 1. Load the module from bytes (or reuse the caller's copy).
|
|
39
40
|
// Archive integrity is verified upstream in fetchArchive() before extraction.
|
|
40
41
|
let loaded;
|
|
@@ -68,7 +69,33 @@ export async function installPackage(bundle, meta, options) {
|
|
|
68
69
|
error: `Package "${meta.id}" declared type "app" but bundle contains no valid app`,
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
|
-
// 4.
|
|
72
|
+
// 4. Compute the new bundle's contributed ids. Used both to populate the
|
|
73
|
+
// new InstalledPackage record AND to diff against the previous record
|
|
74
|
+
// so we can drop shards/apps the new bundle no longer ships.
|
|
75
|
+
const contributedShards = loaded.shards.map((s) => s.manifest.id);
|
|
76
|
+
const contributedApps = loaded.apps.map((a) => a.manifest.id);
|
|
77
|
+
// 5. Diff against the previous record (if any) and unregister anything the
|
|
78
|
+
// new bundle no longer ships. Without this, a combo update that drops
|
|
79
|
+
// an app leaves the dropped app sitting in `registeredApps` until the
|
|
80
|
+
// user clears the webview cache (Bug A).
|
|
81
|
+
//
|
|
82
|
+
// Legacy records written before `contributedShards`/`contributedApps`
|
|
83
|
+
// existed treat the missing field as the empty set — no diff is
|
|
84
|
+
// possible for that one upgrade, and the fields populate on this save.
|
|
85
|
+
const prior = await loadMeta(meta.id).catch(() => null);
|
|
86
|
+
if (prior) {
|
|
87
|
+
const newShardSet = new Set(contributedShards);
|
|
88
|
+
const newAppSet = new Set(contributedApps);
|
|
89
|
+
for (const id of (_a = prior.contributedShards) !== null && _a !== void 0 ? _a : []) {
|
|
90
|
+
if (!newShardSet.has(id))
|
|
91
|
+
unregisterShard(id);
|
|
92
|
+
}
|
|
93
|
+
for (const id of (_b = prior.contributedApps) !== null && _b !== void 0 ? _b : []) {
|
|
94
|
+
if (!newAppSet.has(id))
|
|
95
|
+
unregisterApp(id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 6. Persist to IndexedDB. Permissions captured from manifest(s).
|
|
72
99
|
const record = {
|
|
73
100
|
id: meta.id,
|
|
74
101
|
type: meta.type,
|
|
@@ -77,6 +104,8 @@ export async function installPackage(bundle, meta, options) {
|
|
|
77
104
|
contractVersion: meta.contractVersion,
|
|
78
105
|
installedAt: new Date().toISOString(),
|
|
79
106
|
permissions: extractBundlePermissions(loaded),
|
|
107
|
+
contributedShards,
|
|
108
|
+
contributedApps,
|
|
80
109
|
};
|
|
81
110
|
try {
|
|
82
111
|
await savePackage(meta.id, bundle, record);
|
|
@@ -88,15 +117,16 @@ export async function installPackage(bundle, meta, options) {
|
|
|
88
117
|
error: `Failed to persist package: ${err instanceof Error ? err.message : String(err)}`,
|
|
89
118
|
};
|
|
90
119
|
}
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
120
|
+
// 7. Evict any existing registration for the package id itself before
|
|
121
|
+
// re-registering. Step 5's diff covers ids the new bundle drops; this
|
|
122
|
+
// handles the case where the package id IS one of the new bundle's
|
|
123
|
+
// shard/app ids (single-shard or single-app packages) and we need to
|
|
124
|
+
// tear down its active state before re-registering the new module.
|
|
95
125
|
if (meta.type === 'shard' || meta.type === 'combo') {
|
|
96
126
|
try {
|
|
97
127
|
deactivateShard(meta.id);
|
|
98
128
|
}
|
|
99
|
-
catch ( /* not active or not a shard */
|
|
129
|
+
catch ( /* not active or not a shard */_c) { /* not active or not a shard */ }
|
|
100
130
|
}
|
|
101
131
|
if (meta.type === 'app' || meta.type === 'combo') {
|
|
102
132
|
unregisterApp(meta.id);
|
|
@@ -175,11 +205,21 @@ export async function loadInstalledPackages() {
|
|
|
175
205
|
await removePackage(pkg.id).catch(() => { });
|
|
176
206
|
}
|
|
177
207
|
}
|
|
178
|
-
// Load packages from server — use IndexedDB cache if available
|
|
208
|
+
// Load packages from server — use IndexedDB cache if available AND the
|
|
209
|
+
// cached version matches the server's. The server is the source of truth
|
|
210
|
+
// for installed versions: if another device updated the package, this
|
|
211
|
+
// device's cache holds a stale bundle and must refetch (Bug B). Without
|
|
212
|
+
// this, device B opens the env, sees `id` is in local cache, loads the
|
|
213
|
+
// old bytes, and never notices it's behind.
|
|
179
214
|
for (const serverPkg of serverPackages) {
|
|
180
215
|
if (localIds.has(serverPkg.id)) {
|
|
181
216
|
const localPkg = localPackages.find(p => p.id === serverPkg.id);
|
|
182
|
-
|
|
217
|
+
if (localPkg.version !== serverPkg.version) {
|
|
218
|
+
await _fetchAndCacheFromServer(serverPkg);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
await _loadFromIndexedDB(localPkg);
|
|
222
|
+
}
|
|
183
223
|
}
|
|
184
224
|
else {
|
|
185
225
|
await _fetchAndCacheFromServer(serverPkg);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import 'fake-indexeddb/auto';
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import 'fake-indexeddb/auto';
|
|
2
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
3
|
+
import { resetFramework } from '../__test__/reset';
|
|
4
|
+
import { makeApp, makeAppManifest, makeShard, makeShardManifest } from '../__test__/fixtures';
|
|
5
|
+
import { installPackage, loadInstalledPackages } from './installer';
|
|
6
|
+
import { savePackage } from './storage';
|
|
7
|
+
import { registeredApps } from '../apps/registry.svelte';
|
|
8
|
+
import { registeredShards } from '../shards/lifecycle.svelte';
|
|
9
|
+
// Mock the network for loadInstalledPackages tests.
|
|
10
|
+
vi.mock('../env/client', () => ({
|
|
11
|
+
fetchServerPackages: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
import { fetchServerPackages } from '../env/client';
|
|
14
|
+
async function wipeIndexedDB() {
|
|
15
|
+
await new Promise((resolve) => {
|
|
16
|
+
const req = indexedDB.deleteDatabase('sh3-packages');
|
|
17
|
+
req.onsuccess = () => resolve();
|
|
18
|
+
req.onerror = () => resolve();
|
|
19
|
+
req.onblocked = () => resolve();
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function makeLoaded(parts = {}) {
|
|
23
|
+
var _a, _b;
|
|
24
|
+
return {
|
|
25
|
+
shards: ((_a = parts.shards) !== null && _a !== void 0 ? _a : []).map((id) => makeShard({ manifest: makeShardManifest({ id }) })),
|
|
26
|
+
apps: ((_b = parts.apps) !== null && _b !== void 0 ? _b : []).map((id) => makeApp({ manifest: makeAppManifest({ id }) })),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function meta(id, type, version) {
|
|
30
|
+
return { id, type, version, sourceRegistry: '', contractVersion: '1' };
|
|
31
|
+
}
|
|
32
|
+
function installedRecord(id, type, version) {
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
type,
|
|
36
|
+
version,
|
|
37
|
+
sourceRegistry: '',
|
|
38
|
+
contractVersion: '1',
|
|
39
|
+
installedAt: '2026-01-01T00:00:00.000Z',
|
|
40
|
+
permissions: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Bug A — installPackage must drop apps/shards the new bundle no longer ships
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
describe('installPackage — diff-based unregistration (Bug A)', () => {
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
resetFramework();
|
|
49
|
+
await wipeIndexedDB();
|
|
50
|
+
});
|
|
51
|
+
it('unregisters apps from the previous bundle that the new bundle no longer ships', async () => {
|
|
52
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.0.0'), {
|
|
53
|
+
loaded: makeLoaded({ apps: ['bar', 'baz'] }),
|
|
54
|
+
});
|
|
55
|
+
expect(registeredApps.has('bar')).toBe(true);
|
|
56
|
+
expect(registeredApps.has('baz')).toBe(true);
|
|
57
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.1.0'), {
|
|
58
|
+
loaded: makeLoaded({ apps: ['bar'] }),
|
|
59
|
+
});
|
|
60
|
+
expect(registeredApps.has('bar')).toBe(true);
|
|
61
|
+
expect(registeredApps.has('baz')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
it('unregisters shards from the previous bundle that the new bundle no longer ships', async () => {
|
|
64
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.0.0'), {
|
|
65
|
+
loaded: makeLoaded({ shards: ['shard-a', 'shard-b'] }),
|
|
66
|
+
});
|
|
67
|
+
expect(registeredShards.has('shard-a')).toBe(true);
|
|
68
|
+
expect(registeredShards.has('shard-b')).toBe(true);
|
|
69
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.1.0'), {
|
|
70
|
+
loaded: makeLoaded({ shards: ['shard-a'] }),
|
|
71
|
+
});
|
|
72
|
+
expect(registeredShards.has('shard-a')).toBe(true);
|
|
73
|
+
expect(registeredShards.has('shard-b')).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
it('preserves entries present in both old and new bundles', async () => {
|
|
76
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.0.0'), {
|
|
77
|
+
loaded: makeLoaded({ shards: ['s1'], apps: ['a1'] }),
|
|
78
|
+
});
|
|
79
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.1.0'), {
|
|
80
|
+
loaded: makeLoaded({ shards: ['s1'], apps: ['a1'] }),
|
|
81
|
+
});
|
|
82
|
+
expect(registeredShards.has('s1')).toBe(true);
|
|
83
|
+
expect(registeredApps.has('a1')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it('does not touch unrelated packages on first install of a new package', async () => {
|
|
86
|
+
await installPackage(new ArrayBuffer(0), meta('pkg-other', 'app', '1.0.0'), {
|
|
87
|
+
loaded: makeLoaded({ apps: ['other-app'] }),
|
|
88
|
+
});
|
|
89
|
+
await installPackage(new ArrayBuffer(0), meta('pkg-new', 'app', '1.0.0'), {
|
|
90
|
+
loaded: makeLoaded({ apps: ['new-app'] }),
|
|
91
|
+
});
|
|
92
|
+
expect(registeredApps.has('other-app')).toBe(true);
|
|
93
|
+
expect(registeredApps.has('new-app')).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Bug B — loadInstalledPackages must refetch when local version != server
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
describe('loadInstalledPackages — version reconciliation (Bug B)', () => {
|
|
100
|
+
beforeEach(async () => {
|
|
101
|
+
resetFramework();
|
|
102
|
+
await wipeIndexedDB();
|
|
103
|
+
vi.resetAllMocks();
|
|
104
|
+
// Global fetch — used by _fetchAndCacheFromServer.
|
|
105
|
+
globalThis.fetch = vi.fn();
|
|
106
|
+
// Silence expected warnings: empty-byte bundles can't be dynamic-imported
|
|
107
|
+
// in node, so loadBundleModule throws and the installer warns. The
|
|
108
|
+
// routing decision under test happens before that, so the warnings are
|
|
109
|
+
// noise we don't want polluting test output.
|
|
110
|
+
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
111
|
+
});
|
|
112
|
+
it('refetches from server when local cached version differs from server version', async () => {
|
|
113
|
+
await savePackage('foo', new ArrayBuffer(0), installedRecord('foo', 'app', '1.0.0'));
|
|
114
|
+
fetchServerPackages.mockResolvedValue([
|
|
115
|
+
{
|
|
116
|
+
id: 'foo',
|
|
117
|
+
type: 'app',
|
|
118
|
+
version: '1.1.0',
|
|
119
|
+
bundleUrl: 'http://server.test/packages/foo/client.js',
|
|
120
|
+
sourceRegistry: '',
|
|
121
|
+
contractVersion: '1',
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
globalThis.fetch.mockResolvedValue({
|
|
125
|
+
ok: true,
|
|
126
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
127
|
+
});
|
|
128
|
+
await loadInstalledPackages();
|
|
129
|
+
expect(globalThis.fetch).toHaveBeenCalledWith('http://server.test/packages/foo/client.js');
|
|
130
|
+
});
|
|
131
|
+
it('uses local cache when local version matches server version', async () => {
|
|
132
|
+
await savePackage('foo', new ArrayBuffer(0), installedRecord('foo', 'app', '1.0.0'));
|
|
133
|
+
fetchServerPackages.mockResolvedValue([
|
|
134
|
+
{
|
|
135
|
+
id: 'foo',
|
|
136
|
+
type: 'app',
|
|
137
|
+
version: '1.0.0',
|
|
138
|
+
bundleUrl: 'http://server.test/packages/foo/client.js',
|
|
139
|
+
sourceRegistry: '',
|
|
140
|
+
contractVersion: '1',
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
await loadInstalledPackages();
|
|
144
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
});
|
package/dist/registry/types.d.ts
CHANGED
|
@@ -183,6 +183,25 @@ export interface InstalledPackage {
|
|
|
183
183
|
* must treat a missing value as `[]`.
|
|
184
184
|
*/
|
|
185
185
|
permissions: string[];
|
|
186
|
+
/**
|
|
187
|
+
* Shard ids this package contributed at install time. Populated from
|
|
188
|
+
* `LoadedBundle.shards[].manifest.id`. Used at reinstall to diff against
|
|
189
|
+
* the new bundle and unregister shards the new version no longer ships.
|
|
190
|
+
*
|
|
191
|
+
* Optional for backwards compatibility with records written before this
|
|
192
|
+
* field existed. Treat a missing value as "unknown" — diffing is skipped
|
|
193
|
+
* for that record and the field is populated on the next install.
|
|
194
|
+
*/
|
|
195
|
+
contributedShards?: string[];
|
|
196
|
+
/**
|
|
197
|
+
* App ids this package contributed at install time. Populated from
|
|
198
|
+
* `LoadedBundle.apps[].manifest.id`. Same diff-and-unregister role as
|
|
199
|
+
* `contributedShards`.
|
|
200
|
+
*
|
|
201
|
+
* Optional for backwards compatibility with records written before this
|
|
202
|
+
* field existed.
|
|
203
|
+
*/
|
|
204
|
+
contributedApps?: string[];
|
|
186
205
|
}
|
|
187
206
|
/**
|
|
188
207
|
* Result of an install operation.
|
|
@@ -175,4 +175,91 @@ describe('runVerbProgrammatic', () => {
|
|
|
175
175
|
await runVerbProgrammatic('docs-probe-2', 'docs-probe-2:peek', []);
|
|
176
176
|
expect(seenDocs).toBeUndefined();
|
|
177
177
|
});
|
|
178
|
+
it('surfaces the verb return value as result', async () => {
|
|
179
|
+
registerShard({
|
|
180
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
181
|
+
register(ctx) {
|
|
182
|
+
ctx.registerVerb({
|
|
183
|
+
name: 'returnObj',
|
|
184
|
+
summary: 'returns an object',
|
|
185
|
+
programmatic: true,
|
|
186
|
+
async run() {
|
|
187
|
+
return { answer: 'ok', count: 3 };
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
await activateShard('tester');
|
|
193
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnObj', []);
|
|
194
|
+
expect(out.result).toEqual({ answer: 'ok', count: 3 });
|
|
195
|
+
});
|
|
196
|
+
it('surfaces a primitive verb return value as result', async () => {
|
|
197
|
+
registerShard({
|
|
198
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
199
|
+
register(ctx) {
|
|
200
|
+
ctx.registerVerb({
|
|
201
|
+
name: 'returnNumber',
|
|
202
|
+
summary: 'returns 42',
|
|
203
|
+
programmatic: true,
|
|
204
|
+
schema: {
|
|
205
|
+
input: { type: 'object' },
|
|
206
|
+
output: { type: 'integer' },
|
|
207
|
+
},
|
|
208
|
+
async run() {
|
|
209
|
+
return 42;
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
await activateShard('tester');
|
|
215
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnNumber', []);
|
|
216
|
+
expect(out.result).toBe(42);
|
|
217
|
+
});
|
|
218
|
+
it('surfaces undefined as result for a verb that returns nothing', async () => {
|
|
219
|
+
registerShard({
|
|
220
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
221
|
+
register(ctx) {
|
|
222
|
+
ctx.registerVerb({
|
|
223
|
+
name: 'returnVoid',
|
|
224
|
+
summary: 'returns nothing',
|
|
225
|
+
programmatic: true,
|
|
226
|
+
async run() {
|
|
227
|
+
// no return
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
await activateShard('tester');
|
|
233
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnVoid', []);
|
|
234
|
+
expect(out.result).toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
it('round-trips an sh3-document handle through result', async () => {
|
|
237
|
+
registerShard({
|
|
238
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
239
|
+
register(ctx) {
|
|
240
|
+
ctx.registerVerb({
|
|
241
|
+
name: 'returnDoc',
|
|
242
|
+
summary: 'returns a document handle',
|
|
243
|
+
programmatic: true,
|
|
244
|
+
schema: {
|
|
245
|
+
input: { type: 'object' },
|
|
246
|
+
output: {
|
|
247
|
+
type: 'object',
|
|
248
|
+
format: 'sh3-document',
|
|
249
|
+
properties: {
|
|
250
|
+
shardId: { type: 'string' },
|
|
251
|
+
path: { type: 'string' },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
async run() {
|
|
256
|
+
return { shardId: 'notes', path: 'inbox/today.md' };
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
await activateShard('tester');
|
|
262
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnDoc', []);
|
|
263
|
+
expect(out.result).toEqual({ shardId: 'notes', path: 'inbox/today.md' });
|
|
264
|
+
});
|
|
178
265
|
});
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
import { makeSelectionApi } from '../actions/selection.svelte';
|
|
19
19
|
import { getAppearance } from '../app-appearance';
|
|
20
20
|
import iconsUrl from '../assets/icons.svg';
|
|
21
|
-
import { manifest } from '../shell-shard/manifest';
|
|
22
21
|
|
|
23
22
|
const homeSelection = makeSelectionApi('__sh3core__');
|
|
24
23
|
|
|
@@ -112,3 +112,11 @@ export declare function activateShard(id: string): Promise<void>;
|
|
|
112
112
|
* that explicitly want to verify cleanup paths).
|
|
113
113
|
*/
|
|
114
114
|
export declare function deactivateShard(id: string): void;
|
|
115
|
+
/**
|
|
116
|
+
* Remove a shard from the registry entirely. Deactivates it first if active,
|
|
117
|
+
* then drops it from `registeredShards` and clears any error record.
|
|
118
|
+
*
|
|
119
|
+
* Called by the package installer when a bundle update no longer ships a
|
|
120
|
+
* shard that the previous version contributed.
|
|
121
|
+
*/
|
|
122
|
+
export declare function unregisterShard(id: string): void;
|
|
@@ -607,3 +607,20 @@ export function deactivateShard(id) {
|
|
|
607
607
|
shardEntries.delete(id);
|
|
608
608
|
activeShards.delete(id);
|
|
609
609
|
}
|
|
610
|
+
/**
|
|
611
|
+
* Remove a shard from the registry entirely. Deactivates it first if active,
|
|
612
|
+
* then drops it from `registeredShards` and clears any error record.
|
|
613
|
+
*
|
|
614
|
+
* Called by the package installer when a bundle update no longer ships a
|
|
615
|
+
* shard that the previous version contributed.
|
|
616
|
+
*/
|
|
617
|
+
export function unregisterShard(id) {
|
|
618
|
+
if (!registeredShards.has(id))
|
|
619
|
+
return;
|
|
620
|
+
try {
|
|
621
|
+
deactivateShard(id);
|
|
622
|
+
}
|
|
623
|
+
catch ( /* not active */_a) { /* not active */ }
|
|
624
|
+
registeredShards.delete(id);
|
|
625
|
+
erroredShards.delete(id);
|
|
626
|
+
}
|