sh3-core 0.19.5 → 0.20.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 +1 -0
- package/dist/app/admin/AuthSettingsView.svelte +3 -9
- package/dist/app/admin/MountsView.svelte +276 -0
- package/dist/app/admin/MountsView.svelte.d.ts +3 -0
- package/dist/app/admin/SystemView.svelte +6 -6
- package/dist/app/admin/UsersView.svelte +103 -7
- package/dist/app/admin/adminApp.js +1 -0
- package/dist/app/admin/adminShard.svelte.js +10 -0
- package/dist/apps/lifecycle.js +1 -0
- package/dist/apps/types.d.ts +7 -0
- package/dist/assets/iconIds.generated.d.ts +1 -1
- package/dist/assets/iconIds.generated.js +1 -0
- package/dist/assets/icons.svg +5 -0
- package/dist/auth/admin-users.svelte.js +2 -1
- package/dist/auth/auth.svelte.d.ts +4 -5
- package/dist/auth/auth.svelte.js +5 -6
- package/dist/auth/types.d.ts +0 -2
- package/dist/chrome/CompactChrome.svelte +25 -6
- package/dist/chrome/FloatsSheet.svelte +7 -32
- package/dist/chrome/FloatsSheet.svelte.d.ts +1 -2
- package/dist/chrome/FloatsSheet.svelte.test.js +8 -14
- package/dist/chrome/MenuSheet.svelte +154 -148
- package/dist/chrome/MenuSheet.svelte.d.ts +1 -2
- package/dist/chrome/MenuSheet.svelte.test.js +24 -12
- package/dist/createShell.js +32 -21
- package/dist/createShell.remoteAuth.test.js +9 -3
- package/dist/documents/browse.d.ts +18 -1
- package/dist/documents/browse.js +40 -7
- package/dist/documents/browse.test.js +35 -35
- package/dist/documents/config.d.ts +4 -0
- package/dist/documents/config.js +15 -2
- package/dist/documents/handle.js +25 -17
- package/dist/documents/http-backend.js +10 -2
- package/dist/documents/index.d.ts +2 -2
- package/dist/documents/index.js +1 -1
- package/dist/documents/picker-api.d.ts +33 -0
- package/dist/documents/picker-api.js +1 -0
- package/dist/documents/picker-api.test.d.ts +1 -0
- package/dist/documents/picker-api.test.js +162 -0
- package/dist/documents/picker-primitive.d.ts +11 -0
- package/dist/documents/picker-primitive.js +56 -0
- package/dist/documents/types.d.ts +17 -5
- package/dist/documents/types.js +2 -0
- package/dist/layout/presets.test.js +4 -4
- package/dist/layout/types.d.ts +1 -1
- package/dist/layouts-shard/LayoutsSection.svelte +3 -16
- package/dist/primitives/widgets/DocumentFilePicker.svelte +4 -4
- package/dist/primitives/widgets/PickerList.svelte +1 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +7 -8
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +1 -0
- package/dist/projects-shard/DeleteProjectDialog.svelte +32 -1
- package/dist/projects-shard/ProjectManage.svelte +197 -28
- package/dist/projects-shard/ProjectManage.svelte.test.d.ts +1 -0
- package/dist/projects-shard/ProjectManage.svelte.test.js +320 -0
- package/dist/projects-shard/ProjectsSection.svelte +3 -16
- package/dist/projects-shard/projectsApi.js +2 -1
- package/dist/registry/permission-descriptions.js +4 -0
- package/dist/server-shard/types.d.ts +21 -0
- package/dist/sh3core-shard/HomeSection.svelte +107 -0
- package/dist/sh3core-shard/HomeSection.svelte.d.ts +10 -0
- package/dist/sh3core-shard/Sh3Home.svelte +9 -23
- package/dist/shards/activate.svelte.d.ts +4 -0
- package/dist/shards/activate.svelte.js +31 -14
- package/dist/shards/types.d.ts +15 -0
- package/dist/shell-shard/tenant-fs-client.js +2 -1
- package/dist/transport/apiFetch.js +12 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* DOM smoke for MenuSheet — verifies
|
|
3
|
-
* container/item resolution is exercised via
|
|
4
|
-
* MenuBar uses; their unit tests cover the
|
|
5
|
-
* this test only asserts the wrapper structure.
|
|
2
|
+
* DOM smoke for MenuSheet — verifies the push-navigation modal card
|
|
3
|
+
* renders correctly. The container/item resolution is exercised via
|
|
4
|
+
* the same model functions MenuBar uses; their unit tests cover the
|
|
5
|
+
* resolution semantics so this test only asserts the wrapper structure.
|
|
6
6
|
*/
|
|
7
7
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
8
8
|
import { mount, unmount, flushSync } from 'svelte';
|
|
@@ -21,26 +21,38 @@ afterEach(() => {
|
|
|
21
21
|
}
|
|
22
22
|
});
|
|
23
23
|
describe('MenuSheet (dom)', () => {
|
|
24
|
-
it('renders
|
|
24
|
+
it('renders the menu sheet region with a title "Menu" when no actions registered', () => {
|
|
25
25
|
host = document.createElement('div');
|
|
26
26
|
document.body.appendChild(host);
|
|
27
27
|
mounted = mount(MenuSheetAny, {
|
|
28
28
|
target: host,
|
|
29
|
-
props: {
|
|
29
|
+
props: { close: () => { } },
|
|
30
30
|
});
|
|
31
31
|
flushSync();
|
|
32
|
-
|
|
32
|
+
const sheet = host.querySelector('[data-sh3-region="menu-sheet"]');
|
|
33
|
+
expect(sheet).not.toBeNull();
|
|
34
|
+
expect(sheet.querySelector('.title').textContent).toContain('Menu');
|
|
33
35
|
});
|
|
34
|
-
it('
|
|
36
|
+
it('does not show back button at root level', () => {
|
|
35
37
|
host = document.createElement('div');
|
|
36
38
|
document.body.appendChild(host);
|
|
37
39
|
mounted = mount(MenuSheetAny, {
|
|
38
40
|
target: host,
|
|
39
|
-
props: {
|
|
41
|
+
props: { close: () => { } },
|
|
40
42
|
});
|
|
41
43
|
flushSync();
|
|
42
|
-
const
|
|
43
|
-
expect(
|
|
44
|
-
|
|
44
|
+
const backBtn = host.querySelector('button[aria-label="Back"]');
|
|
45
|
+
expect(backBtn).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
it('renders empty state when no items available', () => {
|
|
48
|
+
host = document.createElement('div');
|
|
49
|
+
document.body.appendChild(host);
|
|
50
|
+
mounted = mount(MenuSheetAny, {
|
|
51
|
+
target: host,
|
|
52
|
+
props: { close: () => { } },
|
|
53
|
+
});
|
|
54
|
+
flushSync();
|
|
55
|
+
const empty = host.querySelector('.empty');
|
|
56
|
+
expect(empty).not.toBeNull();
|
|
45
57
|
});
|
|
46
58
|
});
|
package/dist/createShell.js
CHANGED
|
@@ -13,7 +13,8 @@ import { resolvePlatform } from './platform/index';
|
|
|
13
13
|
import { apiFetch } from './transport/apiFetch';
|
|
14
14
|
import { hydrateTokenOverrides } from './theme';
|
|
15
15
|
import { __setEnvServerUrl, getEnvServerUrl } from './env/index';
|
|
16
|
-
import { __setActiveScope } from './documents/config';
|
|
16
|
+
import { __setActiveScope, __setScopeResolver } from './documents/config';
|
|
17
|
+
import { __setScopeResolver as __setShardScopeResolver } from './shards/activate.svelte';
|
|
17
18
|
import { initFromBoot } from './auth/index';
|
|
18
19
|
import SignInWall from './auth/SignInWall.svelte';
|
|
19
20
|
import { loadBundleModule } from './registry/loader';
|
|
@@ -21,6 +22,7 @@ import { registerLoadedBundle } from './registry/register';
|
|
|
21
22
|
import { attachGlobalListeners } from './actions/listeners';
|
|
22
23
|
import { detectSatelliteMode } from './boot/satelliteMode';
|
|
23
24
|
import { MemoryBackend } from './state/backends';
|
|
25
|
+
import { sessionState } from './projects/session-state.svelte';
|
|
24
26
|
import SatelliteShell from './satellite/SatelliteShell.svelte';
|
|
25
27
|
export async function createShell(config) {
|
|
26
28
|
var _a, _b;
|
|
@@ -31,9 +33,6 @@ export async function createShell(config) {
|
|
|
31
33
|
__setBackend('workspace', platform.backends.workspace);
|
|
32
34
|
__setBackend('user', platform.backends.user);
|
|
33
35
|
}
|
|
34
|
-
if (platform.localOwner) {
|
|
35
|
-
setLocalOwner();
|
|
36
|
-
}
|
|
37
36
|
__setEnvServerUrl(sUrl);
|
|
38
37
|
hydrateTokenOverrides();
|
|
39
38
|
// 2. Resolve mount target early (needed for both sign-in wall and sh3)
|
|
@@ -61,6 +60,8 @@ export async function createShell(config) {
|
|
|
61
60
|
// but pop-out is currently a Tauri-only POC so we don't fetch it.
|
|
62
61
|
if (platform.localOwner)
|
|
63
62
|
__setActiveScope('local');
|
|
63
|
+
__setScopeResolver(() => sessionState.activeProjectId);
|
|
64
|
+
__setShardScopeResolver(() => sessionState.activeProjectId ? 'project' : 'tenant');
|
|
64
65
|
if (config === null || config === void 0 ? void 0 : config.shards)
|
|
65
66
|
for (const shard of config.shards)
|
|
66
67
|
registerShard(shard);
|
|
@@ -84,33 +85,38 @@ export async function createShell(config) {
|
|
|
84
85
|
mount(SatelliteShell, { target, props: { payload: satellite.payload } });
|
|
85
86
|
return;
|
|
86
87
|
}
|
|
87
|
-
// 3. Fetch boot config
|
|
88
|
-
//
|
|
88
|
+
// 3. Fetch boot config. Always fetched — local-owner (Tauri sidecar)
|
|
89
|
+
// needs the real sh3s_ session token minted by /api/boot.
|
|
89
90
|
let bootConfig = null;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (res.ok) {
|
|
95
|
-
bootConfig = await res.json();
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
catch (_c) {
|
|
99
|
-
// Server unreachable — boot without auth (offline mode)
|
|
91
|
+
try {
|
|
92
|
+
const res = await apiFetch(`${sUrl}/api/boot`);
|
|
93
|
+
if (res.ok) {
|
|
94
|
+
bootConfig = await res.json();
|
|
100
95
|
}
|
|
101
96
|
}
|
|
97
|
+
catch (_c) {
|
|
98
|
+
// Server unreachable — boot without auth (offline mode)
|
|
99
|
+
}
|
|
102
100
|
// 4. Auth decision point
|
|
103
101
|
if (platform.localOwner && !(config === null || config === void 0 ? void 0 : config.remoteAuth)) {
|
|
104
|
-
// Local-owner (Tauri
|
|
105
|
-
//
|
|
106
|
-
|
|
102
|
+
// Local-owner (Tauri sidecar): boot minted a real sh3s_ session.
|
|
103
|
+
// initFromBoot stores the token; fall back to synthetic local if
|
|
104
|
+
// the server was unreachable (should never happen in sidecar mode).
|
|
105
|
+
if (bootConfig) {
|
|
106
|
+
initFromBoot(sUrl, bootConfig);
|
|
107
|
+
__setActiveScope(bootConfig.tenantId);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
setLocalOwner();
|
|
111
|
+
__setActiveScope('local');
|
|
112
|
+
}
|
|
107
113
|
}
|
|
108
114
|
else if (bootConfig) {
|
|
109
115
|
initFromBoot(sUrl, bootConfig);
|
|
110
116
|
__setActiveScope(bootConfig.tenantId);
|
|
111
117
|
const { auth, session } = bootConfig;
|
|
112
|
-
// Hard gate: no session
|
|
113
|
-
if (!session &&
|
|
118
|
+
// Hard gate: no session and no guest allowed → sign-in wall
|
|
119
|
+
if (!session && !auth.guestAllowed) {
|
|
114
120
|
await showSignInWall(target, bootConfig);
|
|
115
121
|
// After successful sign-in, re-fetch boot config
|
|
116
122
|
const res = await apiFetch(`${sUrl}/api/boot`);
|
|
@@ -137,6 +143,11 @@ export async function createShell(config) {
|
|
|
137
143
|
if (config === null || config === void 0 ? void 0 : config.excludeShards)
|
|
138
144
|
bootstrapConfig.excludeShards = config.excludeShards;
|
|
139
145
|
await bootstrap(bootstrapConfig);
|
|
146
|
+
// 7b. Wire the document zone's scope resolver to the active project.
|
|
147
|
+
// When the user enters a project, getActiveScopeId() returns the project
|
|
148
|
+
// id so all document operations use the project's virtual tenant.
|
|
149
|
+
__setScopeResolver(() => sessionState.activeProjectId);
|
|
150
|
+
__setShardScopeResolver(() => sessionState.activeProjectId ? 'project' : 'tenant');
|
|
140
151
|
// 8. Attach document-level keyboard / focus listeners
|
|
141
152
|
attachGlobalListeners();
|
|
142
153
|
// 9. Mount the sh3
|
|
@@ -48,11 +48,17 @@ describe('createShell remoteAuth flag', () => {
|
|
|
48
48
|
}
|
|
49
49
|
expect(calls.some(u => u === 'https://remote.example.com/api/boot')).toBe(true);
|
|
50
50
|
});
|
|
51
|
-
it('
|
|
51
|
+
it('fetches /api/boot in localOwner mode (sidecar needs the real sh3s_ token)', async () => {
|
|
52
52
|
const calls = [];
|
|
53
53
|
globalThis.fetch = vi.fn(async (input) => {
|
|
54
54
|
calls.push(String(input));
|
|
55
|
-
return new Response(
|
|
55
|
+
return new Response(JSON.stringify({
|
|
56
|
+
version: '0.20.0',
|
|
57
|
+
tenantId: 'local',
|
|
58
|
+
auth: { guestAllowed: false, selfRegistration: false },
|
|
59
|
+
session: { token: 'sh3s_abc', userId: 'local', role: 'admin', expiresAt: Number.MAX_SAFE_INTEGER },
|
|
60
|
+
user: { id: 'local', username: 'local', displayName: 'Local Owner', role: 'admin', createdAt: '', updatedAt: '' },
|
|
61
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
56
62
|
});
|
|
57
63
|
vi.doMock('./platform/index', () => ({
|
|
58
64
|
resolvePlatform: async () => ({ backends: null, localOwner: true }),
|
|
@@ -66,6 +72,6 @@ describe('createShell remoteAuth flag', () => {
|
|
|
66
72
|
catch (_a) {
|
|
67
73
|
// ignore — assertion below is the contract.
|
|
68
74
|
}
|
|
69
|
-
expect(calls.some(u => u.endsWith('/api/boot'))).toBe(
|
|
75
|
+
expect(calls.some(u => u.endsWith('/api/boot'))).toBe(true);
|
|
70
76
|
});
|
|
71
77
|
});
|
|
@@ -91,6 +91,23 @@ export interface BrowseCapability {
|
|
|
91
91
|
* `typeof ctx.browse.deleteFrom === 'function'`.
|
|
92
92
|
*/
|
|
93
93
|
deleteFrom?(shardId: string, path: string): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Copy or move a document from the active tenant to another scope.
|
|
96
|
+
* Available only when the caller declares both `documents:browse` and
|
|
97
|
+
* `documents:write`. Used for transferring documents between a user's
|
|
98
|
+
* personal scope and a project scope (or vice versa).
|
|
99
|
+
*
|
|
100
|
+
* When `opts.delete` is true, the source document is removed after
|
|
101
|
+
* copying (effectively a move/transfer). Default: false (copy only).
|
|
102
|
+
*
|
|
103
|
+
* Absent (undefined) on the capability object when `documents:write`
|
|
104
|
+
* is not declared; feature-detect with
|
|
105
|
+
* `typeof ctx.browse.transferToScope === 'function'`.
|
|
106
|
+
*/
|
|
107
|
+
transferToScope?(shardId: string, path: string, targetScope: string, opts?: {
|
|
108
|
+
targetShardId?: string;
|
|
109
|
+
delete?: boolean;
|
|
110
|
+
}): Promise<void>;
|
|
94
111
|
}
|
|
95
112
|
export interface BrowseCapabilityOptions {
|
|
96
113
|
/** When true, the returned capability exposes `readFrom`. */
|
|
@@ -98,4 +115,4 @@ export interface BrowseCapabilityOptions {
|
|
|
98
115
|
/** When true, the returned capability exposes `writeTo`. */
|
|
99
116
|
canWrite: boolean;
|
|
100
117
|
}
|
|
101
|
-
export declare function createBrowseCapability(
|
|
118
|
+
export declare function createBrowseCapability(getTenantId: () => string, backend: DocumentBackend, options?: BrowseCapabilityOptions): BrowseCapability;
|
package/dist/documents/browse.js
CHANGED
|
@@ -4,33 +4,39 @@
|
|
|
4
4
|
* Exposed on ShardContext as `ctx.browse` when the shard declares the
|
|
5
5
|
* 'documents:browse' permission. Read-only: writes still flow through
|
|
6
6
|
* the owning shard's own ctx.documents() handle.
|
|
7
|
+
*
|
|
8
|
+
* Tenant id is resolved lazily via a callback so that the browse
|
|
9
|
+
* capability automatically follows the active scope (personal tenant
|
|
10
|
+
* vs. active project). The callback is wired by the framework at
|
|
11
|
+
* shard activation time.
|
|
7
12
|
*/
|
|
8
13
|
import { documentChanges } from './notifications';
|
|
9
|
-
export function createBrowseCapability(
|
|
14
|
+
export function createBrowseCapability(getTenantId, backend, options = { canRead: false, canWrite: false }) {
|
|
10
15
|
const capability = {
|
|
11
|
-
listDocuments: () => backend.listAllDocuments(
|
|
12
|
-
listShards: () => backend.listAllShards(
|
|
16
|
+
listDocuments: () => backend.listAllDocuments(getTenantId()),
|
|
17
|
+
listShards: () => backend.listAllShards(getTenantId()),
|
|
13
18
|
watchDocuments: (callback) => documentChanges.subscribe((change) => {
|
|
14
|
-
if (change.tenantId !==
|
|
19
|
+
if (change.tenantId !== getTenantId())
|
|
15
20
|
return;
|
|
16
21
|
callback(change);
|
|
17
22
|
}),
|
|
18
23
|
};
|
|
19
24
|
if (options.canRead) {
|
|
20
|
-
capability.readFrom = (shardId, path) => backend.read(
|
|
25
|
+
capability.readFrom = (shardId, path) => backend.read(getTenantId(), shardId, path);
|
|
21
26
|
capability.statusFrom = async (shardId, path) => {
|
|
22
27
|
if (!backend.readMeta)
|
|
23
28
|
return null;
|
|
24
|
-
return backend.readMeta(
|
|
29
|
+
return backend.readMeta(getTenantId(), shardId, path);
|
|
25
30
|
};
|
|
26
31
|
capability.readBranchFrom = async (shardId, path, origin) => {
|
|
27
32
|
if (!backend.readBranch)
|
|
28
33
|
return null;
|
|
29
|
-
return backend.readBranch(
|
|
34
|
+
return backend.readBranch(getTenantId(), shardId, path, origin);
|
|
30
35
|
};
|
|
31
36
|
}
|
|
32
37
|
if (options.canWrite) {
|
|
33
38
|
capability.writeTo = async (shardId, path, content) => {
|
|
39
|
+
const tenantId = getTenantId();
|
|
34
40
|
const existed = await backend.exists(tenantId, shardId, path);
|
|
35
41
|
await backend.write(tenantId, shardId, path, content);
|
|
36
42
|
documentChanges.emit({
|
|
@@ -43,6 +49,7 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
|
|
|
43
49
|
capability.resolveConflictFrom = async (shardId, path, choice) => {
|
|
44
50
|
if (!backend.resolve)
|
|
45
51
|
throw new Error('Backend does not support resolveConflict');
|
|
52
|
+
const tenantId = getTenantId();
|
|
46
53
|
await backend.resolve(tenantId, shardId, path, choice);
|
|
47
54
|
documentChanges.emit({ type: 'update', path, tenantId, shardId });
|
|
48
55
|
};
|
|
@@ -50,6 +57,7 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
|
|
|
50
57
|
if ((opts === null || opts === void 0 ? void 0 : opts.newShardId) !== undefined) {
|
|
51
58
|
throw new Error('Cross-shard move is not yet supported (ADR-018 amendment 2026-04-27)');
|
|
52
59
|
}
|
|
60
|
+
const tenantId = getTenantId();
|
|
53
61
|
await backend.rename(tenantId, shardId, oldPath, newPath);
|
|
54
62
|
documentChanges.emit({
|
|
55
63
|
type: 'rename',
|
|
@@ -60,12 +68,37 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
|
|
|
60
68
|
});
|
|
61
69
|
};
|
|
62
70
|
capability.deleteFrom = async (shardId, path) => {
|
|
71
|
+
const tenantId = getTenantId();
|
|
63
72
|
const existed = await backend.exists(tenantId, shardId, path);
|
|
64
73
|
await backend.delete(tenantId, shardId, path);
|
|
65
74
|
if (existed) {
|
|
66
75
|
documentChanges.emit({ type: 'delete', path, tenantId, shardId });
|
|
67
76
|
}
|
|
68
77
|
};
|
|
78
|
+
capability.transferToScope = async (shardId, path, targetScope, opts) => {
|
|
79
|
+
var _a;
|
|
80
|
+
const tenantId = getTenantId();
|
|
81
|
+
if (targetScope === tenantId) {
|
|
82
|
+
throw new Error('targetScope must differ from the active tenant');
|
|
83
|
+
}
|
|
84
|
+
const targetShard = (_a = opts === null || opts === void 0 ? void 0 : opts.targetShardId) !== null && _a !== void 0 ? _a : shardId;
|
|
85
|
+
const content = await backend.read(tenantId, shardId, path);
|
|
86
|
+
if (content === null) {
|
|
87
|
+
throw new Error(`Document not found at ${shardId}/${path} in scope ${tenantId}`);
|
|
88
|
+
}
|
|
89
|
+
const existed = await backend.exists(targetScope, targetShard, path);
|
|
90
|
+
await backend.write(targetScope, targetShard, path, content);
|
|
91
|
+
documentChanges.emit({
|
|
92
|
+
type: existed ? 'update' : 'create',
|
|
93
|
+
path,
|
|
94
|
+
tenantId: targetScope,
|
|
95
|
+
shardId: targetShard,
|
|
96
|
+
});
|
|
97
|
+
if (opts === null || opts === void 0 ? void 0 : opts.delete) {
|
|
98
|
+
await backend.delete(tenantId, shardId, path);
|
|
99
|
+
documentChanges.emit({ type: 'delete', path, tenantId, shardId });
|
|
100
|
+
}
|
|
101
|
+
};
|
|
69
102
|
}
|
|
70
103
|
return capability;
|
|
71
104
|
}
|
|
@@ -7,7 +7,7 @@ describe('BrowseCapability', () => {
|
|
|
7
7
|
const be = new MemoryDocumentBackend();
|
|
8
8
|
await be.write('t1', 'a', 'x.txt', '1');
|
|
9
9
|
await be.write('t1', 'b', 'y.txt', '22');
|
|
10
|
-
const browse = createBrowseCapability('t1', be);
|
|
10
|
+
const browse = createBrowseCapability(() => 't1', be);
|
|
11
11
|
const docs = await browse.listDocuments();
|
|
12
12
|
expect(docs.map((d) => d.shardId).sort()).toEqual(['a', 'b']);
|
|
13
13
|
});
|
|
@@ -15,12 +15,12 @@ describe('BrowseCapability', () => {
|
|
|
15
15
|
const be = new MemoryDocumentBackend();
|
|
16
16
|
await be.write('t1', 'a', 'x.txt', '1');
|
|
17
17
|
await be.write('t1', 'b', 'y.txt', '2');
|
|
18
|
-
const browse = createBrowseCapability('t1', be);
|
|
18
|
+
const browse = createBrowseCapability(() => 't1', be);
|
|
19
19
|
expect((await browse.listShards()).sort()).toEqual(['a', 'b']);
|
|
20
20
|
});
|
|
21
21
|
it('watchDocuments fires with shardId on tenant-wide emits; filters other tenants', () => {
|
|
22
22
|
const be = new MemoryDocumentBackend();
|
|
23
|
-
const browse = createBrowseCapability('t1', be);
|
|
23
|
+
const browse = createBrowseCapability(() => 't1', be);
|
|
24
24
|
const cb = vi.fn();
|
|
25
25
|
const unsub = browse.watchDocuments(cb);
|
|
26
26
|
documentChanges.emit({ type: 'create', path: 'f.txt', tenantId: 't1', shardId: 's1' });
|
|
@@ -31,7 +31,7 @@ describe('BrowseCapability', () => {
|
|
|
31
31
|
});
|
|
32
32
|
it('watchDocuments unsubscribe stops callbacks', () => {
|
|
33
33
|
const be = new MemoryDocumentBackend();
|
|
34
|
-
const browse = createBrowseCapability('t1', be);
|
|
34
|
+
const browse = createBrowseCapability(() => 't1', be);
|
|
35
35
|
const cb = vi.fn();
|
|
36
36
|
const unsub = browse.watchDocuments(cb);
|
|
37
37
|
unsub();
|
|
@@ -41,46 +41,46 @@ describe('BrowseCapability', () => {
|
|
|
41
41
|
describe('readFrom (documents:read gate)', () => {
|
|
42
42
|
it('is absent when canRead is false', () => {
|
|
43
43
|
const be = new MemoryDocumentBackend();
|
|
44
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
|
|
44
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: false });
|
|
45
45
|
expect(browse.readFrom).toBeUndefined();
|
|
46
46
|
});
|
|
47
47
|
it('is present when canRead is true', () => {
|
|
48
48
|
const be = new MemoryDocumentBackend();
|
|
49
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
49
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
|
|
50
50
|
expect(typeof browse.readFrom).toBe('function');
|
|
51
51
|
});
|
|
52
52
|
it('reads content from a foreign shard namespace', async () => {
|
|
53
53
|
const be = new MemoryDocumentBackend();
|
|
54
54
|
await be.write('t1', 'other', 'notes/ideas.md', 'hello');
|
|
55
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
55
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
|
|
56
56
|
expect(await browse.readFrom('other', 'notes/ideas.md')).toBe('hello');
|
|
57
57
|
});
|
|
58
58
|
it('returns null when the foreign document does not exist', async () => {
|
|
59
59
|
const be = new MemoryDocumentBackend();
|
|
60
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
60
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
|
|
61
61
|
expect(await browse.readFrom('other', 'nope.txt')).toBeNull();
|
|
62
62
|
});
|
|
63
63
|
it('never crosses tenants: a t1 capability cannot read t2 docs', async () => {
|
|
64
64
|
const be = new MemoryDocumentBackend();
|
|
65
65
|
await be.write('t2', 's', 'secret.txt', 'hidden');
|
|
66
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
66
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
|
|
67
67
|
expect(await browse.readFrom('s', 'secret.txt')).toBeNull();
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
70
|
describe('writeTo (documents:write gate)', () => {
|
|
71
71
|
it('is absent when canWrite is false', () => {
|
|
72
72
|
const be = new MemoryDocumentBackend();
|
|
73
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
|
|
73
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: false });
|
|
74
74
|
expect(browse.writeTo).toBeUndefined();
|
|
75
75
|
});
|
|
76
76
|
it('is present when canWrite is true', () => {
|
|
77
77
|
const be = new MemoryDocumentBackend();
|
|
78
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
78
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
79
79
|
expect(typeof browse.writeTo).toBe('function');
|
|
80
80
|
});
|
|
81
81
|
it('writes into the target shard namespace and emits a create change', async () => {
|
|
82
82
|
const be = new MemoryDocumentBackend();
|
|
83
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
83
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
84
84
|
const events = [];
|
|
85
85
|
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
86
86
|
await browse.writeTo('target-shard', 'notes/ideas.md', 'hello');
|
|
@@ -93,7 +93,7 @@ describe('BrowseCapability', () => {
|
|
|
93
93
|
it('emits update (not create) when overwriting an existing document', async () => {
|
|
94
94
|
const be = new MemoryDocumentBackend();
|
|
95
95
|
await be.write('t1', 'target-shard', 'a.txt', 'v1');
|
|
96
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
96
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
97
97
|
const events = [];
|
|
98
98
|
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
99
99
|
await browse.writeTo('target-shard', 'a.txt', 'v2');
|
|
@@ -105,7 +105,7 @@ describe('BrowseCapability', () => {
|
|
|
105
105
|
});
|
|
106
106
|
it('never crosses tenants: cannot be tricked into writing into tenant b', async () => {
|
|
107
107
|
const be = new MemoryDocumentBackend();
|
|
108
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
108
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
109
109
|
await browse.writeTo('s', 'a.txt', 'x');
|
|
110
110
|
expect(await be.read('t1', 's', 'a.txt')).toBe('x');
|
|
111
111
|
expect(await be.read('t2', 's', 'a.txt')).toBeNull();
|
|
@@ -114,7 +114,7 @@ describe('BrowseCapability', () => {
|
|
|
114
114
|
describe('read + write combined', () => {
|
|
115
115
|
it('round-trips content through readFrom after writeTo', async () => {
|
|
116
116
|
const be = new MemoryDocumentBackend();
|
|
117
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: true });
|
|
117
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: true });
|
|
118
118
|
await browse.writeTo('shard-x', 'doc.txt', 'roundtrip');
|
|
119
119
|
expect(await browse.readFrom('shard-x', 'doc.txt')).toBe('roundtrip');
|
|
120
120
|
});
|
|
@@ -122,13 +122,13 @@ describe('BrowseCapability', () => {
|
|
|
122
122
|
describe('statusFrom / readBranchFrom (documents:read gate)', () => {
|
|
123
123
|
it('absent when canRead is false', () => {
|
|
124
124
|
const be = new MemoryDocumentBackend();
|
|
125
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
|
|
125
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: false });
|
|
126
126
|
expect(browse.statusFrom).toBeUndefined();
|
|
127
127
|
expect(browse.readBranchFrom).toBeUndefined();
|
|
128
128
|
});
|
|
129
129
|
it('present when canRead is true', () => {
|
|
130
130
|
const be = new MemoryDocumentBackend();
|
|
131
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
131
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
|
|
132
132
|
expect(typeof browse.statusFrom).toBe('function');
|
|
133
133
|
expect(typeof browse.readBranchFrom).toBe('function');
|
|
134
134
|
});
|
|
@@ -144,13 +144,13 @@ describe('BrowseCapability', () => {
|
|
|
144
144
|
listAllDocuments: vi.fn(async () => []),
|
|
145
145
|
readMeta: vi.fn(async (...args) => { calls.push(args); return null; }),
|
|
146
146
|
};
|
|
147
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
147
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
|
|
148
148
|
await browse.statusFrom('other', 'a.txt');
|
|
149
149
|
expect(calls[0]).toEqual(['t1', 'other', 'a.txt']);
|
|
150
150
|
});
|
|
151
151
|
it('statusFrom returns null when backend has no readMeta', async () => {
|
|
152
152
|
const be = new MemoryDocumentBackend();
|
|
153
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
153
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
|
|
154
154
|
expect(await browse.statusFrom('other', 'a.txt')).toBeNull();
|
|
155
155
|
});
|
|
156
156
|
it('readBranchFrom delegates to backend.readBranch with tenant baked in', async () => {
|
|
@@ -165,32 +165,32 @@ describe('BrowseCapability', () => {
|
|
|
165
165
|
listAllDocuments: vi.fn(async () => []),
|
|
166
166
|
readBranch: vi.fn(async (...args) => { calls.push(args); return 'x'; }),
|
|
167
167
|
};
|
|
168
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
168
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
|
|
169
169
|
const out = await browse.readBranchFrom('other', 'a.txt', 'peer-1');
|
|
170
170
|
expect(out).toBe('x');
|
|
171
171
|
expect(calls[0]).toEqual(['t1', 'other', 'a.txt', 'peer-1']);
|
|
172
172
|
});
|
|
173
173
|
it('readBranchFrom returns null when backend has no readBranch', async () => {
|
|
174
174
|
const be = new MemoryDocumentBackend();
|
|
175
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
175
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
|
|
176
176
|
expect(await browse.readBranchFrom('other', 'a.txt', 'peer-1')).toBeNull();
|
|
177
177
|
});
|
|
178
178
|
});
|
|
179
179
|
describe('renameFrom (documents:write gate)', () => {
|
|
180
180
|
it('absent when canWrite is false', () => {
|
|
181
181
|
const be = new MemoryDocumentBackend();
|
|
182
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
|
|
182
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: false });
|
|
183
183
|
expect(browse.renameFrom).toBeUndefined();
|
|
184
184
|
});
|
|
185
185
|
it('present when canWrite is true', () => {
|
|
186
186
|
const be = new MemoryDocumentBackend();
|
|
187
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
187
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
188
188
|
expect(typeof browse.renameFrom).toBe('function');
|
|
189
189
|
});
|
|
190
190
|
it('renames in the target shard namespace and emits a rename event', async () => {
|
|
191
191
|
const be = new MemoryDocumentBackend();
|
|
192
192
|
await be.write('t1', 'target-shard', 'old.txt', 'hello');
|
|
193
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
193
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
194
194
|
const events = [];
|
|
195
195
|
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
196
196
|
await browse.renameFrom('target-shard', 'old.txt', 'new.txt');
|
|
@@ -210,7 +210,7 @@ describe('BrowseCapability', () => {
|
|
|
210
210
|
it('never crosses tenants: cannot be tricked into renaming in tenant b', async () => {
|
|
211
211
|
const be = new MemoryDocumentBackend();
|
|
212
212
|
await be.write('t2', 's', 'old.txt', 'hidden');
|
|
213
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
213
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
214
214
|
await expect(browse.renameFrom('s', 'old.txt', 'new.txt'))
|
|
215
215
|
.rejects.toThrow(/not found/);
|
|
216
216
|
expect(await be.read('t2', 's', 'old.txt')).toBe('hidden');
|
|
@@ -218,7 +218,7 @@ describe('BrowseCapability', () => {
|
|
|
218
218
|
it('throws when opts.newShardId is provided (cross-shard move reserved)', async () => {
|
|
219
219
|
const be = new MemoryDocumentBackend();
|
|
220
220
|
await be.write('t1', 's1', 'old.txt', 'hello');
|
|
221
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
221
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
222
222
|
await expect(browse.renameFrom('s1', 'old.txt', 'new.txt', { newShardId: 's2' })).rejects.toThrow(/Cross-shard move is not yet supported/);
|
|
223
223
|
expect(await be.read('t1', 's1', 'old.txt')).toBe('hello');
|
|
224
224
|
});
|
|
@@ -226,12 +226,12 @@ describe('BrowseCapability', () => {
|
|
|
226
226
|
describe('resolveConflictFrom (documents:write gate)', () => {
|
|
227
227
|
it('absent when canWrite is false', () => {
|
|
228
228
|
const be = new MemoryDocumentBackend();
|
|
229
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
229
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
|
|
230
230
|
expect(browse.resolveConflictFrom).toBeUndefined();
|
|
231
231
|
});
|
|
232
232
|
it('present when canWrite is true', () => {
|
|
233
233
|
const be = new MemoryDocumentBackend();
|
|
234
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
234
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
235
235
|
expect(typeof browse.resolveConflictFrom).toBe('function');
|
|
236
236
|
});
|
|
237
237
|
it('delegates to backend.resolve with tenant baked in and emits an update', async () => {
|
|
@@ -246,7 +246,7 @@ describe('BrowseCapability', () => {
|
|
|
246
246
|
listAllDocuments: vi.fn(async () => []),
|
|
247
247
|
resolve: vi.fn(async (...args) => { calls.push(args); }),
|
|
248
248
|
};
|
|
249
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
249
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
250
250
|
const events = [];
|
|
251
251
|
const unsub = documentChanges.subscribe((e) => events.push(e));
|
|
252
252
|
await browse.resolveConflictFrom('other', 'a.txt', { origin: 'peer-1' });
|
|
@@ -258,7 +258,7 @@ describe('BrowseCapability', () => {
|
|
|
258
258
|
});
|
|
259
259
|
it('throws if backend does not support resolve', async () => {
|
|
260
260
|
const be = new MemoryDocumentBackend();
|
|
261
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
261
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
262
262
|
await expect(browse.resolveConflictFrom('other', 'a.txt', { origin: 'peer-1' }))
|
|
263
263
|
.rejects.toThrow(/does not support resolveConflict/);
|
|
264
264
|
});
|
|
@@ -266,18 +266,18 @@ describe('BrowseCapability', () => {
|
|
|
266
266
|
describe('deleteFrom (documents:write gate)', () => {
|
|
267
267
|
it('absent when canWrite is false', () => {
|
|
268
268
|
const be = new MemoryDocumentBackend();
|
|
269
|
-
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
269
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: true, canWrite: false });
|
|
270
270
|
expect(browse.deleteFrom).toBeUndefined();
|
|
271
271
|
});
|
|
272
272
|
it('present when canWrite is true', () => {
|
|
273
273
|
const be = new MemoryDocumentBackend();
|
|
274
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
274
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
275
275
|
expect(typeof browse.deleteFrom).toBe('function');
|
|
276
276
|
});
|
|
277
277
|
it('deletes from the target shard namespace and emits a delete event', async () => {
|
|
278
278
|
const be = new MemoryDocumentBackend();
|
|
279
279
|
await be.write('t1', 'target-shard', 'a.txt', 'hello');
|
|
280
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
280
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
281
281
|
const events = [];
|
|
282
282
|
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
283
283
|
await browse.deleteFrom('target-shard', 'a.txt');
|
|
@@ -289,7 +289,7 @@ describe('BrowseCapability', () => {
|
|
|
289
289
|
});
|
|
290
290
|
it('is idempotent on missing paths and emits no event', async () => {
|
|
291
291
|
const be = new MemoryDocumentBackend();
|
|
292
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
292
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
293
293
|
const events = [];
|
|
294
294
|
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
295
295
|
await expect(browse.deleteFrom('target-shard', 'nope.txt')).resolves.toBeUndefined();
|
|
@@ -299,7 +299,7 @@ describe('BrowseCapability', () => {
|
|
|
299
299
|
it('never crosses tenants: a t1 capability cannot delete t2 docs', async () => {
|
|
300
300
|
const be = new MemoryDocumentBackend();
|
|
301
301
|
await be.write('t2', 's', 'secret.txt', 'hidden');
|
|
302
|
-
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
302
|
+
const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
|
|
303
303
|
await browse.deleteFrom('s', 'secret.txt');
|
|
304
304
|
expect(await be.read('t2', 's', 'secret.txt')).toBe('hidden');
|
|
305
305
|
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { DocumentBackend } from './types';
|
|
2
|
+
/** Host-only. Register a callback that resolves the active project scope.
|
|
3
|
+
* When non-null and returns a string, that string overrides the static
|
|
4
|
+
* scopeId for all document operations. Wired by createShell after bootstrap. */
|
|
5
|
+
export declare function __setScopeResolver(resolver: (() => string | null) | null): void;
|
|
2
6
|
export declare function getActiveScopeId(): string;
|
|
3
7
|
/** @deprecated use getActiveScopeId — kept until callers migrate. */
|
|
4
8
|
export declare function getTenantId(): string;
|