sh3-core 0.7.5 → 0.8.1

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 (156) hide show
  1. package/dist/api.d.ts +12 -2
  2. package/dist/api.js +13 -1
  3. package/dist/app/admin/SystemView.svelte +149 -11
  4. package/dist/app/store/StoreView.svelte +36 -7
  5. package/dist/app/store/storeShard.svelte.js +9 -3
  6. package/dist/app/store/verbs.js +8 -2
  7. package/dist/apps/lifecycle.d.ts +11 -0
  8. package/dist/apps/lifecycle.js +21 -1
  9. package/dist/apps/lifecycle.test.js +50 -1
  10. package/dist/apps/types.d.ts +7 -2
  11. package/dist/createShell.d.ts +2 -0
  12. package/dist/createShell.js +9 -7
  13. package/dist/documents/backends.d.ts +8 -0
  14. package/dist/documents/backends.js +87 -0
  15. package/dist/documents/backends.test.d.ts +1 -0
  16. package/dist/documents/backends.test.js +33 -0
  17. package/dist/documents/browse.d.ts +12 -0
  18. package/dist/documents/browse.js +19 -0
  19. package/dist/documents/browse.test.d.ts +1 -0
  20. package/dist/documents/browse.test.js +41 -0
  21. package/dist/documents/handle.js +5 -0
  22. package/dist/documents/http-backend.d.ts +4 -0
  23. package/dist/documents/http-backend.js +14 -0
  24. package/dist/documents/index.d.ts +1 -0
  25. package/dist/documents/index.js +1 -0
  26. package/dist/documents/journal-hook.d.ts +6 -0
  27. package/dist/documents/journal-hook.js +16 -0
  28. package/dist/documents/sync/activate-integration.test.d.ts +1 -0
  29. package/dist/documents/sync/activate-integration.test.js +37 -0
  30. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
  31. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
  32. package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
  33. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
  34. package/dist/documents/sync/conflicts.d.ts +30 -0
  35. package/dist/documents/sync/conflicts.js +77 -0
  36. package/dist/documents/sync/conflicts.test.d.ts +1 -0
  37. package/dist/documents/sync/conflicts.test.js +71 -0
  38. package/dist/documents/sync/engine.d.ts +19 -0
  39. package/dist/documents/sync/engine.js +188 -0
  40. package/dist/documents/sync/engine.test.d.ts +1 -0
  41. package/dist/documents/sync/engine.test.js +169 -0
  42. package/dist/documents/sync/handle.d.ts +11 -0
  43. package/dist/documents/sync/handle.js +79 -0
  44. package/dist/documents/sync/handle.test.d.ts +1 -0
  45. package/dist/documents/sync/handle.test.js +56 -0
  46. package/dist/documents/sync/hash.d.ts +1 -0
  47. package/dist/documents/sync/hash.js +13 -0
  48. package/dist/documents/sync/hash.test.d.ts +1 -0
  49. package/dist/documents/sync/hash.test.js +20 -0
  50. package/dist/documents/sync/index.d.ts +5 -0
  51. package/dist/documents/sync/index.js +10 -0
  52. package/dist/documents/sync/journal.d.ts +30 -0
  53. package/dist/documents/sync/journal.js +179 -0
  54. package/dist/documents/sync/journal.test.d.ts +1 -0
  55. package/dist/documents/sync/journal.test.js +87 -0
  56. package/dist/documents/sync/observer.d.ts +3 -0
  57. package/dist/documents/sync/observer.js +45 -0
  58. package/dist/documents/sync/registry.d.ts +13 -0
  59. package/dist/documents/sync/registry.js +73 -0
  60. package/dist/documents/sync/registry.test.d.ts +1 -0
  61. package/dist/documents/sync/registry.test.js +53 -0
  62. package/dist/documents/sync/serialization.d.ts +5 -0
  63. package/dist/documents/sync/serialization.js +24 -0
  64. package/dist/documents/sync/serialization.test.d.ts +1 -0
  65. package/dist/documents/sync/serialization.test.js +26 -0
  66. package/dist/documents/sync/singleton.d.ts +11 -0
  67. package/dist/documents/sync/singleton.js +26 -0
  68. package/dist/documents/sync/tombstones.d.ts +19 -0
  69. package/dist/documents/sync/tombstones.js +58 -0
  70. package/dist/documents/sync/tombstones.test.d.ts +1 -0
  71. package/dist/documents/sync/tombstones.test.js +37 -0
  72. package/dist/documents/sync/types.d.ts +116 -0
  73. package/dist/documents/sync/types.js +27 -0
  74. package/dist/documents/sync/write-hook.test.d.ts +1 -0
  75. package/dist/documents/sync/write-hook.test.js +36 -0
  76. package/dist/documents/types.d.ts +18 -0
  77. package/dist/documents/types.js +6 -1
  78. package/dist/env/client.d.ts +10 -5
  79. package/dist/env/client.js +12 -4
  80. package/dist/layout/inspection.d.ts +17 -0
  81. package/dist/layout/inspection.js +53 -0
  82. package/dist/registry/installer.d.ts +10 -7
  83. package/dist/registry/installer.js +39 -35
  84. package/dist/registry/register.d.ts +17 -0
  85. package/dist/registry/register.js +22 -0
  86. package/dist/registry/register.test.d.ts +1 -0
  87. package/dist/registry/register.test.js +28 -0
  88. package/dist/shards/activate-browse.test.d.ts +1 -0
  89. package/dist/shards/activate-browse.test.js +36 -0
  90. package/dist/shards/activate-sync-registry.test.d.ts +1 -0
  91. package/dist/shards/activate-sync-registry.test.js +42 -0
  92. package/dist/shards/activate-tenantid.test.d.ts +1 -0
  93. package/dist/shards/activate-tenantid.test.js +21 -0
  94. package/dist/shards/activate.svelte.d.ts +12 -0
  95. package/dist/shards/activate.svelte.js +53 -2
  96. package/dist/shards/types.d.ts +43 -1
  97. package/dist/shell-shard/Terminal.svelte +140 -33
  98. package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
  99. package/dist/shell-shard/auto-relocate.d.ts +12 -0
  100. package/dist/shell-shard/auto-relocate.js +20 -0
  101. package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
  102. package/dist/shell-shard/auto-relocate.test.js +35 -0
  103. package/dist/shell-shard/dispatch.d.ts +15 -0
  104. package/dist/shell-shard/dispatch.js +56 -0
  105. package/dist/shell-shard/manifest.js +1 -1
  106. package/dist/shell-shard/modes/builtin.d.ts +5 -0
  107. package/dist/shell-shard/modes/builtin.js +18 -0
  108. package/dist/shell-shard/modes/prefs.d.ts +5 -0
  109. package/dist/shell-shard/modes/prefs.js +31 -0
  110. package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
  111. package/dist/shell-shard/modes/prefs.test.js +46 -0
  112. package/dist/shell-shard/modes/registry.d.ts +7 -0
  113. package/dist/shell-shard/modes/registry.js +27 -0
  114. package/dist/shell-shard/modes/registry.test.d.ts +1 -0
  115. package/dist/shell-shard/modes/registry.test.js +35 -0
  116. package/dist/shell-shard/modes/types.d.ts +8 -0
  117. package/dist/shell-shard/modes/types.js +1 -0
  118. package/dist/shell-shard/protocol.d.ts +6 -0
  119. package/dist/shell-shard/shellShard.svelte.js +57 -5
  120. package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
  121. package/dist/shell-shard/tenant-fs-client.js +44 -0
  122. package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
  123. package/dist/shell-shard/tenant-fs-client.test.js +49 -0
  124. package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
  125. package/dist/shell-shard/terminal-dispatch.test.js +53 -0
  126. package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
  127. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
  128. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
  129. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
  130. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
  131. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
  132. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
  133. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
  134. package/dist/shell-shard/toolbar/slots.d.ts +17 -0
  135. package/dist/shell-shard/toolbar/slots.js +26 -0
  136. package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
  137. package/dist/shell-shard/toolbar/slots.test.js +28 -0
  138. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  139. package/dist/shell-shard/verbs/cat.js +34 -0
  140. package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
  141. package/dist/shell-shard/verbs/cd.test.js +56 -0
  142. package/dist/shell-shard/verbs/env.d.ts +2 -0
  143. package/dist/shell-shard/verbs/env.js +14 -0
  144. package/dist/shell-shard/verbs/index.js +9 -2
  145. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  146. package/dist/shell-shard/verbs/ls.js +29 -0
  147. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  148. package/dist/shell-shard/verbs/ls.test.js +49 -0
  149. package/dist/shell-shard/verbs/session.d.ts +0 -1
  150. package/dist/shell-shard/verbs/session.js +58 -26
  151. package/dist/shell-shard/verbs/views.d.ts +2 -0
  152. package/dist/shell-shard/verbs/views.js +103 -2
  153. package/dist/verbs/types.d.ts +21 -0
  154. package/dist/version.d.ts +1 -1
  155. package/dist/version.js +1 -1
  156. package/package.json +1 -1
@@ -0,0 +1,58 @@
1
+ /*
2
+ * Tombstone store — records deletion metadata so sync connectors can
3
+ * distinguish "never existed" from "was deleted." Backed by JSON docs
4
+ * under the reserved sync shardId. GC is driven by the engine, not here.
5
+ */
6
+ import { readJson, writeJson, deletePath, listJsonPaths } from './serialization';
7
+ const PREFIX = 'tombstones/';
8
+ function key(shardId, path) {
9
+ return `${PREFIX}${shardId}__${encodeURIComponent(path)}.json`;
10
+ }
11
+ export class TombstoneStore {
12
+ constructor(backend, tenantId) {
13
+ this.backend = backend;
14
+ this.tenantId = tenantId;
15
+ }
16
+ async record(shardId, path, lastHash, deletedAt) {
17
+ await writeJson(this.backend, this.tenantId, key(shardId, path), {
18
+ deletedAt,
19
+ lastHash,
20
+ });
21
+ }
22
+ async get(shardId, path) {
23
+ return readJson(this.backend, this.tenantId, key(shardId, path));
24
+ }
25
+ async clear(shardId, path) {
26
+ await deletePath(this.backend, this.tenantId, key(shardId, path));
27
+ }
28
+ async listByShard(shardId) {
29
+ const prefix = `${PREFIX}${shardId}__`;
30
+ const paths = await listJsonPaths(this.backend, this.tenantId, prefix);
31
+ const out = [];
32
+ for (const p of paths) {
33
+ const rec = await readJson(this.backend, this.tenantId, p);
34
+ if (!rec)
35
+ continue;
36
+ const originalPath = decodeURIComponent(p.slice(prefix.length, -'.json'.length));
37
+ out.push(Object.assign({ shardId, path: originalPath }, rec));
38
+ }
39
+ return out;
40
+ }
41
+ async listAll() {
42
+ const paths = await listJsonPaths(this.backend, this.tenantId, PREFIX);
43
+ const out = [];
44
+ for (const p of paths) {
45
+ const rec = await readJson(this.backend, this.tenantId, p);
46
+ if (!rec)
47
+ continue;
48
+ const rest = p.slice(PREFIX.length, -'.json'.length);
49
+ const sep = rest.indexOf('__');
50
+ if (sep < 0)
51
+ continue;
52
+ const shardId = rest.slice(0, sep);
53
+ const path = decodeURIComponent(rest.slice(sep + 2));
54
+ out.push(Object.assign({ shardId, path }, rec));
55
+ }
56
+ return out;
57
+ }
58
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MemoryDocumentBackend } from '../backends';
3
+ import { TombstoneStore } from './tombstones';
4
+ describe('TombstoneStore', () => {
5
+ let backend;
6
+ let store;
7
+ beforeEach(() => {
8
+ backend = new MemoryDocumentBackend();
9
+ store = new TombstoneStore(backend, 'tenant-a');
10
+ });
11
+ it('records and retrieves a tombstone', async () => {
12
+ await store.record('shard-x', 'dir/file.md', 'hash123', 1000);
13
+ const t = await store.get('shard-x', 'dir/file.md');
14
+ expect(t).toEqual({ deletedAt: 1000, lastHash: 'hash123' });
15
+ });
16
+ it('returns null for non-tombstoned paths', async () => {
17
+ expect(await store.get('shard-x', 'absent.md')).toBeNull();
18
+ });
19
+ it('clears a tombstone (on upsert of same path)', async () => {
20
+ await store.record('shard-x', 'a.md', 'h', 1);
21
+ await store.clear('shard-x', 'a.md');
22
+ expect(await store.get('shard-x', 'a.md')).toBeNull();
23
+ });
24
+ it('lists tombstones within a shard', async () => {
25
+ await store.record('shard-x', 'a.md', 'h1', 1);
26
+ await store.record('shard-x', 'b.md', 'h2', 2);
27
+ await store.record('shard-y', 'c.md', 'h3', 3);
28
+ const list = await store.listByShard('shard-x');
29
+ expect(list.map((t) => t.path).sort()).toEqual(['a.md', 'b.md']);
30
+ });
31
+ it('lists all tombstones across shards', async () => {
32
+ await store.record('shard-x', 'a.md', 'h1', 1);
33
+ await store.record('shard-y', 'c.md', 'h3', 3);
34
+ const all = await store.listAll();
35
+ expect(all.length).toBe(2);
36
+ });
37
+ });
@@ -0,0 +1,116 @@
1
+ /** Permission string required in a shard manifest to obtain ctx.sync(). */
2
+ export declare const PERMISSION_DOCUMENTS_SYNC = "documents:sync";
3
+ /** Reserved shardId used to persist sync metadata (journal, tombstones, cursors, grants). */
4
+ export declare const SYNC_RESERVED_SHARD_ID = "__sync__";
5
+ export type SyncScope = {
6
+ kind: 'shard';
7
+ shardId: string;
8
+ } | {
9
+ kind: 'tenant';
10
+ } | {
11
+ kind: 'path';
12
+ shardId: string;
13
+ prefix: string;
14
+ };
15
+ export interface ManifestEntry {
16
+ path: string;
17
+ shardId: string;
18
+ hash: string;
19
+ size: number;
20
+ lastModified: number;
21
+ tombstone?: {
22
+ deletedAt: number;
23
+ };
24
+ }
25
+ export interface ApplyEntry {
26
+ path: string;
27
+ shardId: string;
28
+ op: 'upsert' | 'delete';
29
+ content?: string | ArrayBuffer;
30
+ remoteHash: string;
31
+ remoteMtime: number;
32
+ }
33
+ export interface ApplyOpts {
34
+ onConflict?: ConflictPolicy;
35
+ expectedLocalHash?: string;
36
+ }
37
+ export type ApplyOutcome = {
38
+ status: 'applied';
39
+ newHash: string;
40
+ } | {
41
+ status: 'skipped-identical';
42
+ } | {
43
+ status: 'conflict';
44
+ resolution: ConflictResolution;
45
+ };
46
+ export interface ApplyBatchResult {
47
+ applied: Array<{
48
+ path: string;
49
+ shardId: string;
50
+ newHash: string;
51
+ }>;
52
+ skipped: Array<{
53
+ path: string;
54
+ shardId: string;
55
+ reason: 'identical';
56
+ }>;
57
+ conflicts: ConflictResolution[];
58
+ }
59
+ export interface ConflictResolution {
60
+ path: string;
61
+ shardId: string;
62
+ localHash: string;
63
+ remoteHash: string;
64
+ conflictArtifactPath: string;
65
+ base?: {
66
+ hash: string;
67
+ };
68
+ }
69
+ export interface ConflictContext {
70
+ path: string;
71
+ shardId: string;
72
+ localHash: string;
73
+ remoteHash: string;
74
+ baseHash?: string;
75
+ }
76
+ export type ConflictPolicy = 'default' | 'remote-wins' | 'local-wins' | 'keep-both' | ((ctx: ConflictContext) => Promise<'remote-wins' | 'local-wins' | 'keep-both'>);
77
+ export interface JournalEntry {
78
+ seq: number;
79
+ ts: number;
80
+ shardId: string;
81
+ path: string;
82
+ op: 'upsert' | 'delete';
83
+ hash: string | null;
84
+ }
85
+ export interface ChangePage {
86
+ entries: JournalEntry[];
87
+ nextCursor: string;
88
+ hasMore: boolean;
89
+ truncated?: boolean;
90
+ }
91
+ export interface GrantRecord {
92
+ connectorId: string;
93
+ scope: SyncScope;
94
+ grantedAt: number;
95
+ }
96
+ export interface SyncHandle {
97
+ readonly connectorId: string;
98
+ grantedScopes(): Promise<SyncScope[]>;
99
+ getManifest(scope: SyncScope): Promise<ManifestEntry[]>;
100
+ changesSince(scope: SyncScope, cursor?: string): Promise<ChangePage>;
101
+ ack(scope: SyncScope, cursor: string): Promise<void>;
102
+ apply(scope: SyncScope, entry: ApplyEntry, opts?: ApplyOpts): Promise<ApplyOutcome>;
103
+ applyBatch(scope: SyncScope, manifest: ApplyEntry[], opts?: ApplyOpts): Promise<ApplyBatchResult>;
104
+ forget(scope: SyncScope, path: string): Promise<void>;
105
+ }
106
+ export declare class ScopeNotGrantedError extends Error {
107
+ readonly scope: SyncScope;
108
+ constructor(scope: SyncScope);
109
+ }
110
+ export declare class ScopeRevokedError extends Error {
111
+ readonly scope: SyncScope;
112
+ constructor(scope: SyncScope);
113
+ }
114
+ export declare class TenantMismatchError extends Error {
115
+ constructor();
116
+ }
@@ -0,0 +1,27 @@
1
+ /*
2
+ * Document Sync API types. See docs/superpowers/specs/2026-04-14-document-sync-api-design.md.
3
+ */
4
+ /** Permission string required in a shard manifest to obtain ctx.sync(). */
5
+ export const PERMISSION_DOCUMENTS_SYNC = 'documents:sync';
6
+ /** Reserved shardId used to persist sync metadata (journal, tombstones, cursors, grants). */
7
+ export const SYNC_RESERVED_SHARD_ID = '__sync__';
8
+ export class ScopeNotGrantedError extends Error {
9
+ constructor(scope) {
10
+ super(`Scope not granted: ${JSON.stringify(scope)}`);
11
+ this.scope = scope;
12
+ this.name = 'ScopeNotGrantedError';
13
+ }
14
+ }
15
+ export class ScopeRevokedError extends Error {
16
+ constructor(scope) {
17
+ super(`Scope revoked during operation: ${JSON.stringify(scope)}`);
18
+ this.scope = scope;
19
+ this.name = 'ScopeRevokedError';
20
+ }
21
+ }
22
+ export class TenantMismatchError extends Error {
23
+ constructor() {
24
+ super('Sync handle tenantId does not match current session');
25
+ this.name = 'TenantMismatchError';
26
+ }
27
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ // packages/sh3-core/src/documents/sync/write-hook.test.ts
2
+ import { describe, it, expect, beforeEach } from 'vitest';
3
+ import { MemoryDocumentBackend } from '../backends';
4
+ import { __setDocumentBackend, __setTenantId } from '../config';
5
+ import { createDocumentHandle } from '../handle';
6
+ import { SyncEngine } from './engine';
7
+ import { setJournalAppender, clearJournalAppender } from '../journal-hook';
8
+ describe('regular document writes feed the sync journal', () => {
9
+ let backend;
10
+ let engine;
11
+ beforeEach(async () => {
12
+ backend = new MemoryDocumentBackend();
13
+ __setDocumentBackend(backend);
14
+ __setTenantId('tenant-a');
15
+ engine = new SyncEngine(backend, 'tenant-a');
16
+ await engine.init();
17
+ setJournalAppender(async (e) => { await engine.journal.append(e); });
18
+ });
19
+ it('appends upsert to journal when shard writes via ctx.documents()', async () => {
20
+ const h = createDocumentHandle('tenant-a', 'shard-x', backend, { format: 'text' });
21
+ await h.write('a.md', 'hello');
22
+ const page = await engine.journal.changesSince({ kind: 'tenant' });
23
+ expect(page.entries.map((e) => `${e.shardId}:${e.path}:${e.op}`)).toEqual([
24
+ 'shard-x:a.md:upsert',
25
+ ]);
26
+ clearJournalAppender();
27
+ });
28
+ it('appends delete to journal', async () => {
29
+ const h = createDocumentHandle('tenant-a', 'shard-x', backend, { format: 'text' });
30
+ await h.write('a.md', 'hello');
31
+ await h.delete('a.md');
32
+ const page = await engine.journal.changesSince({ kind: 'tenant' });
33
+ expect(page.entries.map((e) => e.op)).toEqual(['upsert', 'delete']);
34
+ clearJournalAppender();
35
+ });
36
+ });
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Manifest permission string: grants tenant-wide document observation
3
+ * (`ctx.browse`) and sync registry visibility (`ctx.syncRegistry`).
4
+ * Read-only; writes still flow through the shard's own `ctx.documents()`.
5
+ */
6
+ export declare const PERMISSION_DOCUMENTS_BROWSE = "documents:browse";
1
7
  /**
2
8
  * Format hint for document content. Determines whether reads return a string
3
9
  * (`text`) or an `ArrayBuffer` (`binary`).
@@ -50,6 +56,18 @@ export interface DocumentBackend {
50
56
  list(tenantId: string, shardId: string): Promise<DocumentMeta[]>;
51
57
  /** Return true if the document at `path` exists. */
52
58
  exists(tenantId: string, shardId: string, path: string): Promise<boolean>;
59
+ /**
60
+ * List every shard id that currently has at least one document stored
61
+ * for this tenant. Tenant-wide observation primitive.
62
+ */
63
+ listAllShards(tenantId: string): Promise<string[]>;
64
+ /**
65
+ * List every document in the tenant across all shards. Each entry
66
+ * carries the owning `shardId`. Tenant-wide observation primitive.
67
+ */
68
+ listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
69
+ shardId: string;
70
+ }>>;
53
71
  }
54
72
  /**
55
73
  * Shard-facing document handle returned by `ctx.documents()`. Binds
@@ -9,4 +9,9 @@
9
9
  * The document zone is a parallel subsystem — it does not extend
10
10
  * ZoneName or ZoneSchema. Shards access it via `ctx.documents()`.
11
11
  */
12
- export {};
12
+ /**
13
+ * Manifest permission string: grants tenant-wide document observation
14
+ * (`ctx.browse`) and sync registry visibility (`ctx.syncRegistry`).
15
+ * Read-only; writes still flow through the shard's own `ctx.documents()`.
16
+ */
17
+ export const PERMISSION_DOCUMENTS_BROWSE = 'documents:browse';
@@ -16,6 +16,15 @@ export declare function fetchEnvState(shardId: string): Promise<Record<string, u
16
16
  * Throws if not admin or if the server rejects the write.
17
17
  */
18
18
  export declare function putEnvState(shardId: string, state: Record<string, unknown>): Promise<void>;
19
+ /** Result shape returned by {@link serverInstallPackage}. */
20
+ export interface ServerInstallResult {
21
+ ok: boolean;
22
+ error?: string;
23
+ code?: string;
24
+ missing?: Array<{
25
+ id: string;
26
+ }>;
27
+ }
19
28
  /**
20
29
  * Install a package on the server via multipart upload.
21
30
  * The client has already fetched and integrity-verified the client bundle
@@ -28,11 +37,7 @@ export declare function putEnvState(shardId: string, state: Record<string, unkno
28
37
  * shard's routes. If the mount fails, the entire install is rolled
29
38
  * back server-side.
30
39
  */
31
- export declare function serverInstallPackage(manifest: Record<string, unknown>, clientBundle: ArrayBuffer, serverBundle?: ArrayBuffer): Promise<{
32
- ok: boolean;
33
- id: string;
34
- error?: string;
35
- }>;
40
+ export declare function serverInstallPackage(manifest: Record<string, unknown>, clientBundle: ArrayBuffer, serverBundle?: ArrayBuffer): Promise<ServerInstallResult>;
36
41
  /**
37
42
  * Uninstall a package from the server.
38
43
  */
@@ -61,7 +61,6 @@ export async function putEnvState(shardId, state) {
61
61
  * back server-side.
62
62
  */
63
63
  export async function serverInstallPackage(manifest, clientBundle, serverBundle) {
64
- var _a;
65
64
  if (!isAdmin())
66
65
  throw new Error('Cannot install: not elevated to admin');
67
66
  const auth = getAuthHeader();
@@ -79,11 +78,20 @@ export async function serverInstallPackage(manifest, clientBundle, serverBundle)
79
78
  headers,
80
79
  body: form,
81
80
  });
82
- const body = await res.json();
83
81
  if (!res.ok) {
84
- return { ok: false, id: String(manifest.id), error: (_a = body.error) !== null && _a !== void 0 ? _a : `HTTP ${res.status}` };
82
+ let body = {};
83
+ try {
84
+ body = await res.json();
85
+ }
86
+ catch ( /* non-JSON */_a) { /* non-JSON */ }
87
+ return {
88
+ ok: false,
89
+ error: typeof body.error === 'string' ? body.error : `HTTP ${res.status}`,
90
+ code: typeof body.code === 'string' ? body.code : undefined,
91
+ missing: Array.isArray(body.missing) ? body.missing : undefined,
92
+ };
85
93
  }
86
- return { ok: true, id: String(manifest.id) };
94
+ return { ok: true };
87
95
  }
88
96
  /**
89
97
  * Uninstall a package from the server.
@@ -50,6 +50,23 @@ export declare function expandChild(splitPath: number[], childIndex: number): bo
50
50
  * the sole authority on tree mutations.
51
51
  */
52
52
  export declare function closeTab(slotId: string): Promise<boolean>;
53
+ /**
54
+ * Pop a docked tab out into a new float. Locates the tab by `slotId` in
55
+ * the currently-rendered docked tree, removes it (preserving viewId +
56
+ * meta), and opens a float with the same view. Returns the new floatId
57
+ * on success, or null if the slot wasn't found in the docked tree.
58
+ *
59
+ * Guarded canClose() is NOT consulted — popout is not a close. The slot
60
+ * is recreated in the float with a fresh slotId, so the view is remounted.
61
+ */
62
+ export declare function popoutView(slotId: string): string | null;
63
+ /**
64
+ * Dock a float back into the currently-rendered layout. The float's
65
+ * active tab is appended to the first tabs group (same policy as
66
+ * `dockIntoActiveLayout`). Returns true on success. The float is closed
67
+ * after its content is transferred.
68
+ */
69
+ export declare function dockFloat(floatId: string): boolean;
53
70
  /**
54
71
  * Dock a view into the currently-rendered layout without caring which
55
72
  * root it is. Used by the Ctrl+` shell hotkey and other "just put it
@@ -194,6 +194,59 @@ async function closeFloatTab(tree, slotId) {
194
194
  }
195
195
  return false;
196
196
  }
197
+ /**
198
+ * Pop a docked tab out into a new float. Locates the tab by `slotId` in
199
+ * the currently-rendered docked tree, removes it (preserving viewId +
200
+ * meta), and opens a float with the same view. Returns the new floatId
201
+ * on success, or null if the slot wasn't found in the docked tree.
202
+ *
203
+ * Guarded canClose() is NOT consulted — popout is not a close. The slot
204
+ * is recreated in the float with a fresh slotId, so the view is remounted.
205
+ */
206
+ export function popoutView(slotId) {
207
+ const tree = activeLayout();
208
+ const located = findTabBySlotId(tree.docked, slotId);
209
+ if (!located)
210
+ return null;
211
+ const entry = located.entry;
212
+ const viewId = entry.viewId;
213
+ if (!viewId)
214
+ return null;
215
+ const title = entry.label;
216
+ const meta = entry.meta;
217
+ removeTabBySlotId(tree.docked, slotId);
218
+ cleanupTree(tree.docked);
219
+ return floatManager.open(viewId, { title, meta });
220
+ }
221
+ /**
222
+ * Dock a float back into the currently-rendered layout. The float's
223
+ * active tab is appended to the first tabs group (same policy as
224
+ * `dockIntoActiveLayout`). Returns true on success. The float is closed
225
+ * after its content is transferred.
226
+ */
227
+ export function dockFloat(floatId) {
228
+ var _a, _b;
229
+ const tree = activeLayout();
230
+ const floatEntry = tree.floats.find((f) => f.id === floatId);
231
+ if (!floatEntry)
232
+ return false;
233
+ const content = floatEntry.content;
234
+ const tabs = content.type === 'tabs' ? content : null;
235
+ if (!tabs || tabs.tabs.length === 0) {
236
+ floatManager.close(floatId);
237
+ return false;
238
+ }
239
+ const entry = (_b = tabs.tabs[(_a = tabs.activeTab) !== null && _a !== void 0 ? _a : 0]) !== null && _b !== void 0 ? _b : tabs.tabs[0];
240
+ const ok = dockIntoActiveLayout({
241
+ slotId: entry.slotId,
242
+ viewId: entry.viewId,
243
+ label: entry.label,
244
+ meta: entry.meta,
245
+ });
246
+ if (ok)
247
+ floatManager.close(floatId);
248
+ return ok;
249
+ }
197
250
  function findFirstTabsNode(node) {
198
251
  if (node.type === 'tabs')
199
252
  return node;
@@ -7,9 +7,10 @@
7
7
  *
8
8
  * Flow:
9
9
  * 1. `installPackage(bundle, meta)` -- load module from bytes, verify
10
- * declared type matches actual type, persist to IndexedDB, register
11
- * with framework.
12
- * 2. `uninstallPackage(id)` -- deactivate if active, remove from storage.
10
+ * declared type matches actual type, persist to IndexedDB, evict any
11
+ * existing registration, then register via the shared helper.
12
+ * 2. `uninstallPackage(id)` -- deactivate if active, unregister app, remove
13
+ * from storage.
13
14
  * 3. `loadInstalledPackages()` -- called at boot, re-loads all installed
14
15
  * packages from IndexedDB and registers them.
15
16
  */
@@ -18,9 +19,10 @@ import type { InstalledPackage, InstallResult, PackageMeta } from './types';
18
19
  * Install a package from raw bundle bytes and metadata.
19
20
  *
20
21
  * Loads the ESM module, verifies the declared type matches the actual module
21
- * shape, persists to IndexedDB, and registers with the framework. If
22
- * registration fails (e.g. duplicate id from a shard that was already
23
- * glob-discovered), the package is still persisted but `hotLoaded` is false.
22
+ * shape, persists to IndexedDB, evicts any existing registration for the same
23
+ * id, and registers with the framework via the shared helper. If registration
24
+ * fails (e.g. a shard that was already glob-discovered), the package is still
25
+ * persisted but `hotLoaded` is false.
24
26
  *
25
27
  * @param bundle - Raw verified ESM bundle bytes.
26
28
  * @param meta - Provenance metadata for the install record.
@@ -31,7 +33,8 @@ export declare function installPackage(bundle: ArrayBuffer, meta: PackageMeta):
31
33
  * Uninstall a package by id.
32
34
  *
33
35
  * If the package is a shard and currently active, deactivates it first.
34
- * Removes both the bundle and metadata from IndexedDB.
36
+ * Unregisters any app entry for the id, then removes both the bundle and
37
+ * metadata from IndexedDB.
35
38
  *
36
39
  * @param id - The package id to uninstall.
37
40
  */
@@ -7,24 +7,27 @@
7
7
  *
8
8
  * Flow:
9
9
  * 1. `installPackage(bundle, meta)` -- load module from bytes, verify
10
- * declared type matches actual type, persist to IndexedDB, register
11
- * with framework.
12
- * 2. `uninstallPackage(id)` -- deactivate if active, remove from storage.
10
+ * declared type matches actual type, persist to IndexedDB, evict any
11
+ * existing registration, then register via the shared helper.
12
+ * 2. `uninstallPackage(id)` -- deactivate if active, unregister app, remove
13
+ * from storage.
13
14
  * 3. `loadInstalledPackages()` -- called at boot, re-loads all installed
14
15
  * packages from IndexedDB and registers them.
15
16
  */
16
17
  import { loadBundleModule } from './loader';
17
18
  import { savePackage, loadBundle, listInstalled, removePackage } from './storage';
18
19
  import { verifyIntegrity } from './integrity';
19
- import { registerShard, deactivateShard } from '../shards/activate.svelte';
20
- import { registerApp } from '../apps/registry.svelte';
20
+ import { deactivateShard } from '../shards/activate.svelte';
21
+ import { unregisterApp } from '../apps/lifecycle';
22
+ import { registerLoadedBundle } from './register';
21
23
  /**
22
24
  * Install a package from raw bundle bytes and metadata.
23
25
  *
24
26
  * Loads the ESM module, verifies the declared type matches the actual module
25
- * shape, persists to IndexedDB, and registers with the framework. If
26
- * registration fails (e.g. duplicate id from a shard that was already
27
- * glob-discovered), the package is still persisted but `hotLoaded` is false.
27
+ * shape, persists to IndexedDB, evicts any existing registration for the same
28
+ * id, and registers with the framework via the shared helper. If registration
29
+ * fails (e.g. a shard that was already glob-discovered), the package is still
30
+ * persisted but `hotLoaded` is false.
28
31
  *
29
32
  * @param bundle - Raw verified ESM bundle bytes.
30
33
  * @param meta - Provenance metadata for the install record.
@@ -88,20 +91,27 @@ export async function installPackage(bundle, meta) {
88
91
  error: `Failed to persist package: ${err instanceof Error ? err.message : String(err)}`,
89
92
  };
90
93
  }
91
- // 5. Stamp loader-assigned version (ADR-013) then register all shards
92
- // and apps from the bundle. External package authors omit `version`
93
- // from their source manifests; the authoritative value is the
94
- // registry entry's `PackageVersion.version`, carried on `meta.version`.
94
+ // 5. Evict any existing registration for this id before re-registering.
95
+ // Without this, reinstall at the same version leaks activation state
96
+ // (shards stay active) or app entries (apps silently replace but the
97
+ // old module instance's module-scope state is never torn down).
98
+ if (meta.type === 'shard' || meta.type === 'combo') {
99
+ try {
100
+ deactivateShard(meta.id);
101
+ }
102
+ catch ( /* not active or not a shard */_a) { /* not active or not a shard */ }
103
+ }
104
+ if (meta.type === 'app' || meta.type === 'combo') {
105
+ unregisterApp(meta.id);
106
+ }
107
+ // 6. Register all shards and apps from the bundle via the shared helper.
95
108
  let hotLoaded = true;
96
109
  try {
97
- for (const shard of loaded.shards) {
98
- shard.manifest = Object.assign(Object.assign({}, shard.manifest), { version: meta.version });
99
- registerShard(shard);
100
- }
101
- for (const app of loaded.apps) {
102
- app.manifest = Object.assign(Object.assign({}, app.manifest), { version: meta.version });
103
- registerApp(app);
104
- }
110
+ registerLoadedBundle(loaded, {
111
+ version: meta.version,
112
+ sourceRegistry: meta.sourceRegistry,
113
+ contractVersion: meta.contractVersion,
114
+ });
105
115
  }
106
116
  catch (err) {
107
117
  console.warn(`[sh3] Package "${meta.id}" installed but registration failed (will retry on next boot):`, err instanceof Error ? err.message : err);
@@ -113,18 +123,17 @@ export async function installPackage(bundle, meta) {
113
123
  * Uninstall a package by id.
114
124
  *
115
125
  * If the package is a shard and currently active, deactivates it first.
116
- * Removes both the bundle and metadata from IndexedDB.
126
+ * Unregisters any app entry for the id, then removes both the bundle and
127
+ * metadata from IndexedDB.
117
128
  *
118
129
  * @param id - The package id to uninstall.
119
130
  */
120
131
  export async function uninstallPackage(id) {
121
- // Attempt deactivation (no-op if not active or not a shard).
122
132
  try {
123
133
  deactivateShard(id);
124
134
  }
125
- catch (_a) {
126
- // Ignore -- may not be a shard, or may not be active.
127
- }
135
+ catch ( /* no-op */_a) { /* no-op */ }
136
+ unregisterApp(id);
128
137
  await removePackage(id);
129
138
  }
130
139
  /**
@@ -160,16 +169,11 @@ export async function loadInstalledPackages() {
160
169
  continue;
161
170
  }
162
171
  const loaded = await loadBundleModule(bytes);
163
- // Stamp loader-assigned version (ADR-013) from the persisted
164
- // InstalledPackage record before registration.
165
- for (const shard of loaded.shards) {
166
- shard.manifest = Object.assign(Object.assign({}, shard.manifest), { version: pkg.version });
167
- registerShard(shard);
168
- }
169
- for (const app of loaded.apps) {
170
- app.manifest = Object.assign(Object.assign({}, app.manifest), { version: pkg.version });
171
- registerApp(app);
172
- }
172
+ registerLoadedBundle(loaded, {
173
+ version: pkg.version,
174
+ sourceRegistry: pkg.sourceRegistry,
175
+ contractVersion: pkg.contractVersion,
176
+ });
173
177
  if (loaded.shards.length === 0 && loaded.apps.length === 0) {
174
178
  console.warn(`[sh3] Package "${pkg.id}" contains no valid shards or apps, skipping`);
175
179
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Unified bundle registration — stamps loader-assigned metadata onto every
3
+ * shard/app manifest from a loaded bundle, then registers each. Shared by
4
+ * `installer.installPackage`, `installer.loadInstalledPackages`, and
5
+ * `createShell` discoveredPackages so the three boot paths stay in sync.
6
+ *
7
+ * Per ADR-013, external package authors omit `version` from their source
8
+ * manifests; the authoritative value comes from the persisted/server
9
+ * metadata and must be stamped here before any consumer reads the manifest.
10
+ */
11
+ import type { LoadedBundle } from './loader';
12
+ export interface BundleStampMeta {
13
+ version: string;
14
+ sourceRegistry: string;
15
+ contractVersion: string;
16
+ }
17
+ export declare function registerLoadedBundle(loaded: LoadedBundle, meta: BundleStampMeta): void;