sh3-core 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/api.d.ts +11 -2
  2. package/dist/api.js +13 -1
  3. package/dist/app/store/StoreView.svelte +36 -7
  4. package/dist/app/store/storeShard.svelte.js +9 -3
  5. package/dist/app/store/verbs.js +8 -2
  6. package/dist/apps/lifecycle.d.ts +11 -0
  7. package/dist/apps/lifecycle.js +21 -1
  8. package/dist/apps/lifecycle.test.js +50 -1
  9. package/dist/apps/types.d.ts +7 -2
  10. package/dist/createShell.d.ts +2 -0
  11. package/dist/createShell.js +9 -7
  12. package/dist/documents/handle.js +5 -0
  13. package/dist/documents/index.d.ts +1 -0
  14. package/dist/documents/index.js +1 -0
  15. package/dist/documents/journal-hook.d.ts +6 -0
  16. package/dist/documents/journal-hook.js +16 -0
  17. package/dist/documents/sync/activate-integration.test.d.ts +1 -0
  18. package/dist/documents/sync/activate-integration.test.js +37 -0
  19. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
  20. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
  21. package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
  22. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
  23. package/dist/documents/sync/conflicts.d.ts +30 -0
  24. package/dist/documents/sync/conflicts.js +77 -0
  25. package/dist/documents/sync/conflicts.test.d.ts +1 -0
  26. package/dist/documents/sync/conflicts.test.js +71 -0
  27. package/dist/documents/sync/engine.d.ts +19 -0
  28. package/dist/documents/sync/engine.js +188 -0
  29. package/dist/documents/sync/engine.test.d.ts +1 -0
  30. package/dist/documents/sync/engine.test.js +169 -0
  31. package/dist/documents/sync/handle.d.ts +11 -0
  32. package/dist/documents/sync/handle.js +79 -0
  33. package/dist/documents/sync/handle.test.d.ts +1 -0
  34. package/dist/documents/sync/handle.test.js +56 -0
  35. package/dist/documents/sync/hash.d.ts +1 -0
  36. package/dist/documents/sync/hash.js +13 -0
  37. package/dist/documents/sync/hash.test.d.ts +1 -0
  38. package/dist/documents/sync/hash.test.js +20 -0
  39. package/dist/documents/sync/index.d.ts +6 -0
  40. package/dist/documents/sync/index.js +12 -0
  41. package/dist/documents/sync/journal.d.ts +30 -0
  42. package/dist/documents/sync/journal.js +179 -0
  43. package/dist/documents/sync/journal.test.d.ts +1 -0
  44. package/dist/documents/sync/journal.test.js +87 -0
  45. package/dist/documents/sync/registry.d.ts +10 -0
  46. package/dist/documents/sync/registry.js +66 -0
  47. package/dist/documents/sync/registry.test.d.ts +1 -0
  48. package/dist/documents/sync/registry.test.js +42 -0
  49. package/dist/documents/sync/serialization.d.ts +5 -0
  50. package/dist/documents/sync/serialization.js +24 -0
  51. package/dist/documents/sync/serialization.test.d.ts +1 -0
  52. package/dist/documents/sync/serialization.test.js +26 -0
  53. package/dist/documents/sync/singleton.d.ts +11 -0
  54. package/dist/documents/sync/singleton.js +26 -0
  55. package/dist/documents/sync/tombstones.d.ts +19 -0
  56. package/dist/documents/sync/tombstones.js +58 -0
  57. package/dist/documents/sync/tombstones.test.d.ts +1 -0
  58. package/dist/documents/sync/tombstones.test.js +37 -0
  59. package/dist/documents/sync/types.d.ts +116 -0
  60. package/dist/documents/sync/types.js +27 -0
  61. package/dist/documents/sync/write-hook.test.d.ts +1 -0
  62. package/dist/documents/sync/write-hook.test.js +36 -0
  63. package/dist/env/client.d.ts +10 -5
  64. package/dist/env/client.js +12 -4
  65. package/dist/registry/installer.d.ts +10 -7
  66. package/dist/registry/installer.js +39 -35
  67. package/dist/registry/register.d.ts +17 -0
  68. package/dist/registry/register.js +22 -0
  69. package/dist/registry/register.test.d.ts +1 -0
  70. package/dist/registry/register.test.js +28 -0
  71. package/dist/shards/activate.svelte.js +23 -2
  72. package/dist/shards/types.d.ts +10 -1
  73. package/dist/shell-shard/Terminal.svelte +140 -33
  74. package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
  75. package/dist/shell-shard/auto-relocate.d.ts +12 -0
  76. package/dist/shell-shard/auto-relocate.js +20 -0
  77. package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
  78. package/dist/shell-shard/auto-relocate.test.js +35 -0
  79. package/dist/shell-shard/dispatch.d.ts +15 -0
  80. package/dist/shell-shard/dispatch.js +56 -0
  81. package/dist/shell-shard/modes/builtin.d.ts +5 -0
  82. package/dist/shell-shard/modes/builtin.js +18 -0
  83. package/dist/shell-shard/modes/prefs.d.ts +5 -0
  84. package/dist/shell-shard/modes/prefs.js +31 -0
  85. package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
  86. package/dist/shell-shard/modes/prefs.test.js +46 -0
  87. package/dist/shell-shard/modes/registry.d.ts +7 -0
  88. package/dist/shell-shard/modes/registry.js +27 -0
  89. package/dist/shell-shard/modes/registry.test.d.ts +1 -0
  90. package/dist/shell-shard/modes/registry.test.js +35 -0
  91. package/dist/shell-shard/modes/types.d.ts +8 -0
  92. package/dist/shell-shard/modes/types.js +1 -0
  93. package/dist/shell-shard/protocol.d.ts +6 -0
  94. package/dist/shell-shard/shellShard.svelte.js +5 -1
  95. package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
  96. package/dist/shell-shard/tenant-fs-client.js +44 -0
  97. package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
  98. package/dist/shell-shard/tenant-fs-client.test.js +49 -0
  99. package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
  100. package/dist/shell-shard/terminal-dispatch.test.js +53 -0
  101. package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
  102. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
  103. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
  104. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
  105. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
  106. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
  107. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
  108. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
  109. package/dist/shell-shard/toolbar/slots.d.ts +17 -0
  110. package/dist/shell-shard/toolbar/slots.js +26 -0
  111. package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
  112. package/dist/shell-shard/toolbar/slots.test.js +28 -0
  113. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  114. package/dist/shell-shard/verbs/cat.js +34 -0
  115. package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
  116. package/dist/shell-shard/verbs/cd.test.js +56 -0
  117. package/dist/shell-shard/verbs/env.d.ts +2 -0
  118. package/dist/shell-shard/verbs/env.js +14 -0
  119. package/dist/shell-shard/verbs/index.js +6 -1
  120. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  121. package/dist/shell-shard/verbs/ls.js +29 -0
  122. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  123. package/dist/shell-shard/verbs/ls.test.js +49 -0
  124. package/dist/shell-shard/verbs/session.d.ts +0 -1
  125. package/dist/shell-shard/verbs/session.js +58 -26
  126. package/dist/verbs/types.d.ts +2 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/package.json +1 -1
@@ -0,0 +1,24 @@
1
+ /*
2
+ * JSON storage helpers on top of DocumentBackend. All sync metadata
3
+ * lives under the reserved shardId SYNC_RESERVED_SHARD_ID, scoped per
4
+ * tenant by the backend. Callers pass tenantId explicitly; tenant
5
+ * scoping is enforced at the SyncHandle/engine layer.
6
+ */
7
+ import { SYNC_RESERVED_SHARD_ID } from './types';
8
+ export async function readJson(backend, tenantId, path) {
9
+ const raw = await backend.read(tenantId, SYNC_RESERVED_SHARD_ID, path);
10
+ if (raw === null)
11
+ return null;
12
+ const str = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
13
+ return JSON.parse(str);
14
+ }
15
+ export async function writeJson(backend, tenantId, path, value) {
16
+ await backend.write(tenantId, SYNC_RESERVED_SHARD_ID, path, JSON.stringify(value));
17
+ }
18
+ export async function deletePath(backend, tenantId, path) {
19
+ await backend.delete(tenantId, SYNC_RESERVED_SHARD_ID, path);
20
+ }
21
+ export async function listJsonPaths(backend, tenantId, prefix) {
22
+ const all = await backend.list(tenantId, SYNC_RESERVED_SHARD_ID);
23
+ return all.map((m) => m.path).filter((p) => p.startsWith(prefix));
24
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MemoryDocumentBackend } from '../backends';
3
+ import { readJson, writeJson, listJsonPaths } from './serialization';
4
+ import { SYNC_RESERVED_SHARD_ID } from './types';
5
+ describe('sync serialization', () => {
6
+ let backend;
7
+ beforeEach(() => { backend = new MemoryDocumentBackend(); });
8
+ it('writes and reads JSON round-trip', async () => {
9
+ await writeJson(backend, 'tenant-a', 'foo/bar.json', { hello: 'world', n: 7 });
10
+ const out = await readJson(backend, 'tenant-a', 'foo/bar.json');
11
+ expect(out).toEqual({ hello: 'world', n: 7 });
12
+ });
13
+ it('returns null when a path is absent', async () => {
14
+ expect(await readJson(backend, 'tenant-a', 'missing.json')).toBeNull();
15
+ });
16
+ it('lists JSON paths under a prefix', async () => {
17
+ await writeJson(backend, 't', 'grants/a.json', { a: 1 });
18
+ await writeJson(backend, 't', 'grants/b.json', { b: 2 });
19
+ await writeJson(backend, 't', 'other/c.json', { c: 3 });
20
+ const paths = await listJsonPaths(backend, 't', 'grants/');
21
+ expect(paths.sort()).toEqual(['grants/a.json', 'grants/b.json']);
22
+ });
23
+ it('uses SYNC_RESERVED_SHARD_ID', () => {
24
+ expect(SYNC_RESERVED_SHARD_ID).toBe('__sync__');
25
+ });
26
+ });
@@ -0,0 +1,11 @@
1
+ import type { DocumentBackend } from '../types';
2
+ import { SyncEngine } from './engine';
3
+ import { type SyncRegistry } from './registry';
4
+ interface Bundle {
5
+ engine: SyncEngine;
6
+ registry: SyncRegistry;
7
+ }
8
+ export declare function getSyncBundle(backend: DocumentBackend, tenantId: string): Promise<Bundle>;
9
+ /** Test-only reset. */
10
+ export declare function __resetSyncBundlesForTest(): void;
11
+ export {};
@@ -0,0 +1,26 @@
1
+ /*
2
+ * Per-tenant SyncEngine singleton. Lazily initialised on first
3
+ * ctx.sync() call. The journal appender hook is registered once
4
+ * when the engine is first built so regular shard writes feed
5
+ * the journal for all future connectors.
6
+ */
7
+ import { setJournalAppender } from '../journal-hook';
8
+ import { SyncEngine } from './engine';
9
+ import { createSyncRegistry } from './registry';
10
+ const bundles = new Map();
11
+ export async function getSyncBundle(backend, tenantId) {
12
+ const existing = bundles.get(tenantId);
13
+ if (existing)
14
+ return existing;
15
+ const engine = new SyncEngine(backend, tenantId);
16
+ await engine.init();
17
+ const registry = createSyncRegistry(backend, tenantId);
18
+ setJournalAppender(async (e) => { await engine.journal.append(e); });
19
+ const bundle = { engine, registry };
20
+ bundles.set(tenantId, bundle);
21
+ return bundle;
22
+ }
23
+ /** Test-only reset. */
24
+ export function __resetSyncBundlesForTest() {
25
+ bundles.clear();
26
+ }
@@ -0,0 +1,19 @@
1
+ import type { DocumentBackend } from '../types';
2
+ export interface TombstoneRecord {
3
+ deletedAt: number;
4
+ lastHash: string;
5
+ }
6
+ export interface TombstoneEntry extends TombstoneRecord {
7
+ shardId: string;
8
+ path: string;
9
+ }
10
+ export declare class TombstoneStore {
11
+ private backend;
12
+ private tenantId;
13
+ constructor(backend: DocumentBackend, tenantId: string);
14
+ record(shardId: string, path: string, lastHash: string, deletedAt: number): Promise<void>;
15
+ get(shardId: string, path: string): Promise<TombstoneRecord | null>;
16
+ clear(shardId: string, path: string): Promise<void>;
17
+ listByShard(shardId: string): Promise<TombstoneEntry[]>;
18
+ listAll(): Promise<TombstoneEntry[]>;
19
+ }
@@ -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
+ });
@@ -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.
@@ -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;
@@ -0,0 +1,22 @@
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 { registerShard } from '../shards/activate.svelte';
12
+ import { registerApp } from '../apps/registry.svelte';
13
+ export function registerLoadedBundle(loaded, meta) {
14
+ for (const shard of loaded.shards) {
15
+ shard.manifest = Object.assign(Object.assign({}, shard.manifest), { version: meta.version });
16
+ registerShard(shard);
17
+ }
18
+ for (const app of loaded.apps) {
19
+ app.manifest = Object.assign(Object.assign({}, app.manifest), { version: meta.version });
20
+ registerApp(app);
21
+ }
22
+ }