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.
- package/dist/api.d.ts +12 -2
- package/dist/api.js +13 -1
- package/dist/app/admin/SystemView.svelte +149 -11
- package/dist/app/store/StoreView.svelte +36 -7
- package/dist/app/store/storeShard.svelte.js +9 -3
- package/dist/app/store/verbs.js +8 -2
- package/dist/apps/lifecycle.d.ts +11 -0
- package/dist/apps/lifecycle.js +21 -1
- package/dist/apps/lifecycle.test.js +50 -1
- package/dist/apps/types.d.ts +7 -2
- package/dist/createShell.d.ts +2 -0
- package/dist/createShell.js +9 -7
- package/dist/documents/backends.d.ts +8 -0
- package/dist/documents/backends.js +87 -0
- package/dist/documents/backends.test.d.ts +1 -0
- package/dist/documents/backends.test.js +33 -0
- package/dist/documents/browse.d.ts +12 -0
- package/dist/documents/browse.js +19 -0
- package/dist/documents/browse.test.d.ts +1 -0
- package/dist/documents/browse.test.js +41 -0
- package/dist/documents/handle.js +5 -0
- package/dist/documents/http-backend.d.ts +4 -0
- package/dist/documents/http-backend.js +14 -0
- package/dist/documents/index.d.ts +1 -0
- package/dist/documents/index.js +1 -0
- package/dist/documents/journal-hook.d.ts +6 -0
- package/dist/documents/journal-hook.js +16 -0
- package/dist/documents/sync/activate-integration.test.d.ts +1 -0
- package/dist/documents/sync/activate-integration.test.js +37 -0
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
- package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
- package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
- package/dist/documents/sync/conflicts.d.ts +30 -0
- package/dist/documents/sync/conflicts.js +77 -0
- package/dist/documents/sync/conflicts.test.d.ts +1 -0
- package/dist/documents/sync/conflicts.test.js +71 -0
- package/dist/documents/sync/engine.d.ts +19 -0
- package/dist/documents/sync/engine.js +188 -0
- package/dist/documents/sync/engine.test.d.ts +1 -0
- package/dist/documents/sync/engine.test.js +169 -0
- package/dist/documents/sync/handle.d.ts +11 -0
- package/dist/documents/sync/handle.js +79 -0
- package/dist/documents/sync/handle.test.d.ts +1 -0
- package/dist/documents/sync/handle.test.js +56 -0
- package/dist/documents/sync/hash.d.ts +1 -0
- package/dist/documents/sync/hash.js +13 -0
- package/dist/documents/sync/hash.test.d.ts +1 -0
- package/dist/documents/sync/hash.test.js +20 -0
- package/dist/documents/sync/index.d.ts +5 -0
- package/dist/documents/sync/index.js +10 -0
- package/dist/documents/sync/journal.d.ts +30 -0
- package/dist/documents/sync/journal.js +179 -0
- package/dist/documents/sync/journal.test.d.ts +1 -0
- package/dist/documents/sync/journal.test.js +87 -0
- package/dist/documents/sync/observer.d.ts +3 -0
- package/dist/documents/sync/observer.js +45 -0
- package/dist/documents/sync/registry.d.ts +13 -0
- package/dist/documents/sync/registry.js +73 -0
- package/dist/documents/sync/registry.test.d.ts +1 -0
- package/dist/documents/sync/registry.test.js +53 -0
- package/dist/documents/sync/serialization.d.ts +5 -0
- package/dist/documents/sync/serialization.js +24 -0
- package/dist/documents/sync/serialization.test.d.ts +1 -0
- package/dist/documents/sync/serialization.test.js +26 -0
- package/dist/documents/sync/singleton.d.ts +11 -0
- package/dist/documents/sync/singleton.js +26 -0
- package/dist/documents/sync/tombstones.d.ts +19 -0
- package/dist/documents/sync/tombstones.js +58 -0
- package/dist/documents/sync/tombstones.test.d.ts +1 -0
- package/dist/documents/sync/tombstones.test.js +37 -0
- package/dist/documents/sync/types.d.ts +116 -0
- package/dist/documents/sync/types.js +27 -0
- package/dist/documents/sync/write-hook.test.d.ts +1 -0
- package/dist/documents/sync/write-hook.test.js +36 -0
- package/dist/documents/types.d.ts +18 -0
- package/dist/documents/types.js +6 -1
- package/dist/env/client.d.ts +10 -5
- package/dist/env/client.js +12 -4
- package/dist/layout/inspection.d.ts +17 -0
- package/dist/layout/inspection.js +53 -0
- package/dist/registry/installer.d.ts +10 -7
- package/dist/registry/installer.js +39 -35
- package/dist/registry/register.d.ts +17 -0
- package/dist/registry/register.js +22 -0
- package/dist/registry/register.test.d.ts +1 -0
- package/dist/registry/register.test.js +28 -0
- package/dist/shards/activate-browse.test.d.ts +1 -0
- package/dist/shards/activate-browse.test.js +36 -0
- package/dist/shards/activate-sync-registry.test.d.ts +1 -0
- package/dist/shards/activate-sync-registry.test.js +42 -0
- package/dist/shards/activate-tenantid.test.d.ts +1 -0
- package/dist/shards/activate-tenantid.test.js +21 -0
- package/dist/shards/activate.svelte.d.ts +12 -0
- package/dist/shards/activate.svelte.js +53 -2
- package/dist/shards/types.d.ts +43 -1
- package/dist/shell-shard/Terminal.svelte +140 -33
- package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
- package/dist/shell-shard/auto-relocate.d.ts +12 -0
- package/dist/shell-shard/auto-relocate.js +20 -0
- package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
- package/dist/shell-shard/auto-relocate.test.js +35 -0
- package/dist/shell-shard/dispatch.d.ts +15 -0
- package/dist/shell-shard/dispatch.js +56 -0
- package/dist/shell-shard/manifest.js +1 -1
- package/dist/shell-shard/modes/builtin.d.ts +5 -0
- package/dist/shell-shard/modes/builtin.js +18 -0
- package/dist/shell-shard/modes/prefs.d.ts +5 -0
- package/dist/shell-shard/modes/prefs.js +31 -0
- package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
- package/dist/shell-shard/modes/prefs.test.js +46 -0
- package/dist/shell-shard/modes/registry.d.ts +7 -0
- package/dist/shell-shard/modes/registry.js +27 -0
- package/dist/shell-shard/modes/registry.test.d.ts +1 -0
- package/dist/shell-shard/modes/registry.test.js +35 -0
- package/dist/shell-shard/modes/types.d.ts +8 -0
- package/dist/shell-shard/modes/types.js +1 -0
- package/dist/shell-shard/protocol.d.ts +6 -0
- package/dist/shell-shard/shellShard.svelte.js +57 -5
- package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
- package/dist/shell-shard/tenant-fs-client.js +44 -0
- package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
- package/dist/shell-shard/tenant-fs-client.test.js +49 -0
- package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
- package/dist/shell-shard/terminal-dispatch.test.js +53 -0
- package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
- package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
- package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
- package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
- package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
- package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
- package/dist/shell-shard/toolbar/slots.d.ts +17 -0
- package/dist/shell-shard/toolbar/slots.js +26 -0
- package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
- package/dist/shell-shard/toolbar/slots.test.js +28 -0
- package/dist/shell-shard/verbs/cat.d.ts +2 -0
- package/dist/shell-shard/verbs/cat.js +34 -0
- package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
- package/dist/shell-shard/verbs/cd.test.js +56 -0
- package/dist/shell-shard/verbs/env.d.ts +2 -0
- package/dist/shell-shard/verbs/env.js +14 -0
- package/dist/shell-shard/verbs/index.js +9 -2
- package/dist/shell-shard/verbs/ls.d.ts +2 -0
- package/dist/shell-shard/verbs/ls.js +29 -0
- package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
- package/dist/shell-shard/verbs/ls.test.js +49 -0
- package/dist/shell-shard/verbs/session.d.ts +0 -1
- package/dist/shell-shard/verbs/session.js +58 -26
- package/dist/shell-shard/verbs/views.d.ts +2 -0
- package/dist/shell-shard/verbs/views.js +103 -2
- package/dist/verbs/types.d.ts +21 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
package/dist/documents/types.js
CHANGED
|
@@ -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
|
-
|
|
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';
|
package/dist/env/client.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/env/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
11
|
-
*
|
|
12
|
-
* 2. `uninstallPackage(id)` -- deactivate if active,
|
|
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,
|
|
22
|
-
*
|
|
23
|
-
* glob-discovered), the package is still
|
|
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
|
-
*
|
|
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,
|
|
11
|
-
*
|
|
12
|
-
* 2. `uninstallPackage(id)` -- deactivate if active,
|
|
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 {
|
|
20
|
-
import {
|
|
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,
|
|
26
|
-
*
|
|
27
|
-
* glob-discovered), the package is still
|
|
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.
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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;
|