sh3-core 0.19.6 → 0.20.2
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/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/backends.d.ts +12 -0
- package/dist/documents/backends.js +230 -3
- package/dist/documents/backends.test.js +147 -1
- 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 +6 -0
- package/dist/documents/config.js +18 -1
- package/dist/documents/handle.js +65 -17
- package/dist/documents/handle.test.js +88 -1
- package/dist/documents/http-backend.d.ts +6 -0
- package/dist/documents/http-backend.js +71 -2
- package/dist/documents/http-backend.test.js +51 -1
- package/dist/documents/index.d.ts +2 -2
- package/dist/documents/index.js +1 -1
- package/dist/documents/picker-api.d.ts +4 -2
- package/dist/documents/picker-api.test.d.ts +1 -1
- package/dist/documents/picker-api.test.js +89 -59
- package/dist/documents/picker-primitive.d.ts +4 -0
- package/dist/documents/picker-primitive.js +27 -29
- package/dist/documents/types.d.ts +93 -19
- package/dist/documents/types.js +6 -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.d.ts +6 -2
- package/dist/primitives/widgets/DocumentFilePicker.js +12 -5
- package/dist/primitives/widgets/DocumentFilePicker.svelte +27 -5
- package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +14 -0
- package/dist/primitives/widgets/DocumentFilePicker.test.d.ts +1 -0
- package/dist/primitives/widgets/DocumentFilePicker.test.js +33 -0
- package/dist/primitives/widgets/DocumentOpener.svelte +20 -0
- package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +14 -0
- package/dist/primitives/widgets/DocumentSaver.svelte +17 -0
- package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +13 -0
- package/dist/primitives/widgets/PickerList.svelte +1 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +419 -35
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +12 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.test.js +277 -0
- package/dist/primitives/widgets/_FolderConfirmDelete.svelte +57 -0
- package/dist/primitives/widgets/_FolderConfirmDelete.svelte.d.ts +12 -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/sh3Api/headless.js +10 -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 +11 -3
- package/dist/shards/types.d.ts +7 -0
- package/dist/shell-shard/Terminal.svelte +4 -1
- package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
- package/dist/shell-shard/dispatch.d.ts +2 -0
- package/dist/shell-shard/dispatch.js +2 -0
- package/dist/shell-shard/manifest.js +7 -1
- package/dist/shell-shard/shellShard.svelte.js +1 -1
- package/dist/shell-shard/tenant-fs-client.js +2 -1
- package/dist/shell-shard/verbs/cat.d.ts +2 -0
- package/dist/shell-shard/verbs/cat.js +35 -0
- package/dist/shell-shard/verbs/cat.test.d.ts +1 -0
- package/dist/shell-shard/verbs/cat.test.js +49 -0
- package/dist/shell-shard/verbs/index.js +12 -0
- package/dist/shell-shard/verbs/ls.d.ts +2 -0
- package/dist/shell-shard/verbs/ls.js +48 -0
- package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
- package/dist/shell-shard/verbs/ls.test.js +64 -0
- package/dist/shell-shard/verbs/mkdir.d.ts +2 -0
- package/dist/shell-shard/verbs/mkdir.js +30 -0
- package/dist/shell-shard/verbs/mkdir.test.d.ts +1 -0
- package/dist/shell-shard/verbs/mkdir.test.js +48 -0
- package/dist/shell-shard/verbs/mv.d.ts +2 -0
- package/dist/shell-shard/verbs/mv.js +33 -0
- package/dist/shell-shard/verbs/mv.test.d.ts +1 -0
- package/dist/shell-shard/verbs/mv.test.js +55 -0
- package/dist/shell-shard/verbs/rm.d.ts +2 -0
- package/dist/shell-shard/verbs/rm.js +28 -0
- package/dist/shell-shard/verbs/rm.test.d.ts +1 -0
- package/dist/shell-shard/verbs/rm.test.js +47 -0
- package/dist/shell-shard/verbs/scope-parse.d.ts +7 -0
- package/dist/shell-shard/verbs/scope-parse.js +33 -0
- package/dist/shell-shard/verbs/scope-parse.test.d.ts +1 -0
- package/dist/shell-shard/verbs/scope-parse.test.js +76 -0
- package/dist/shell-shard/verbs/xfer.d.ts +2 -0
- package/dist/shell-shard/verbs/xfer.js +101 -0
- package/dist/shell-shard/verbs/xfer.test.d.ts +1 -0
- package/dist/shell-shard/verbs/xfer.test.js +96 -0
- package/dist/transport/apiFetch.js +12 -5
- package/dist/verbs/types.d.ts +18 -0
- 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
|
});
|
|
@@ -13,6 +13,12 @@ export declare class MemoryDocumentBackend implements DocumentBackend {
|
|
|
13
13
|
shardId: string;
|
|
14
14
|
}>>;
|
|
15
15
|
readMeta(tenantId: string, shardId: string, path: string): Promise<DocStatus | null>;
|
|
16
|
+
mkdir(tenantId: string, shardId: string, path: string): Promise<void>;
|
|
17
|
+
rmdir(tenantId: string, shardId: string, path: string, opts: {
|
|
18
|
+
recursive: boolean;
|
|
19
|
+
}): Promise<void>;
|
|
20
|
+
renameFolder(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
|
|
21
|
+
listFolders(tenantId: string, shardId: string, prefix: string): Promise<string[]>;
|
|
16
22
|
}
|
|
17
23
|
export declare class IndexedDBDocumentBackend implements DocumentBackend {
|
|
18
24
|
#private;
|
|
@@ -26,4 +32,10 @@ export declare class IndexedDBDocumentBackend implements DocumentBackend {
|
|
|
26
32
|
listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
|
|
27
33
|
shardId: string;
|
|
28
34
|
}>>;
|
|
35
|
+
mkdir(tenantId: string, shardId: string, path: string): Promise<void>;
|
|
36
|
+
rmdir(tenantId: string, shardId: string, path: string, opts: {
|
|
37
|
+
recursive: boolean;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
renameFolder(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
|
|
40
|
+
listFolders(tenantId: string, shardId: string, prefix: string): Promise<string[]>;
|
|
29
41
|
}
|
|
@@ -16,7 +16,7 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
|
|
|
16
16
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
17
17
|
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
18
18
|
};
|
|
19
|
-
var _MemoryDocumentBackend_store, _IndexedDBDocumentBackend_instances, _IndexedDBDocumentBackend_dbPromise, _IndexedDBDocumentBackend_db, _IndexedDBDocumentBackend_tx;
|
|
19
|
+
var _MemoryDocumentBackend_store, _MemoryDocumentBackend_folders, _IndexedDBDocumentBackend_instances, _IndexedDBDocumentBackend_dbPromise, _IndexedDBDocumentBackend_db, _IndexedDBDocumentBackend_tx, _IndexedDBDocumentBackend_txOn;
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
// Helpers
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
@@ -29,6 +29,7 @@ function keyPrefix(tenantId, shardId) {
|
|
|
29
29
|
export class MemoryDocumentBackend {
|
|
30
30
|
constructor() {
|
|
31
31
|
_MemoryDocumentBackend_store.set(this, new Map());
|
|
32
|
+
_MemoryDocumentBackend_folders.set(this, new Set()); // composite keys: `${tenant}/${shard}/${path}`
|
|
32
33
|
}
|
|
33
34
|
async read(tenantId, shardId, path) {
|
|
34
35
|
const entry = __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").get(compositeKey(tenantId, shardId, path));
|
|
@@ -111,14 +112,111 @@ export class MemoryDocumentBackend {
|
|
|
111
112
|
return null;
|
|
112
113
|
return { exists: true, version: 1, syncMode: 'sync', syncState: 'synced' };
|
|
113
114
|
}
|
|
115
|
+
async mkdir(tenantId, shardId, path) {
|
|
116
|
+
if (__classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").has(compositeKey(tenantId, shardId, path))) {
|
|
117
|
+
throw new Error(`Cannot mkdir ${path}: a document occupies this path`);
|
|
118
|
+
}
|
|
119
|
+
__classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f").add(compositeKey(tenantId, shardId, path));
|
|
120
|
+
}
|
|
121
|
+
async rmdir(tenantId, shardId, path, opts) {
|
|
122
|
+
const folderKey = compositeKey(tenantId, shardId, path);
|
|
123
|
+
const docPrefix = folderKey + '/';
|
|
124
|
+
const docDescendants = [];
|
|
125
|
+
const folderDescendants = [];
|
|
126
|
+
for (const key of __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").keys()) {
|
|
127
|
+
if (key.startsWith(docPrefix))
|
|
128
|
+
docDescendants.push(key);
|
|
129
|
+
}
|
|
130
|
+
for (const key of __classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f")) {
|
|
131
|
+
if (key === folderKey)
|
|
132
|
+
continue;
|
|
133
|
+
if (key.startsWith(docPrefix))
|
|
134
|
+
folderDescendants.push(key);
|
|
135
|
+
}
|
|
136
|
+
if (!opts.recursive && (docDescendants.length > 0 || folderDescendants.length > 0)) {
|
|
137
|
+
throw new Error(`Cannot rmdir ${path}: folder is not empty`);
|
|
138
|
+
}
|
|
139
|
+
for (const k of docDescendants)
|
|
140
|
+
__classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").delete(k);
|
|
141
|
+
for (const k of folderDescendants)
|
|
142
|
+
__classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f").delete(k);
|
|
143
|
+
__classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f").delete(folderKey);
|
|
144
|
+
}
|
|
145
|
+
async renameFolder(tenantId, shardId, oldPath, newPath) {
|
|
146
|
+
const oldFolderKey = compositeKey(tenantId, shardId, oldPath);
|
|
147
|
+
const newFolderKey = compositeKey(tenantId, shardId, newPath);
|
|
148
|
+
const oldDocPrefix = oldFolderKey + '/';
|
|
149
|
+
const oldHasExplicit = __classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f").has(oldFolderKey);
|
|
150
|
+
const oldHasImplicit = [...__classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").keys()].some((k) => k.startsWith(oldDocPrefix)) ||
|
|
151
|
+
[...__classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f")].some((k) => k.startsWith(oldDocPrefix));
|
|
152
|
+
if (!oldHasExplicit && !oldHasImplicit) {
|
|
153
|
+
throw new Error(`Cannot rename folder ${oldPath}: does not exist`);
|
|
154
|
+
}
|
|
155
|
+
const newDocPrefix = newFolderKey + '/';
|
|
156
|
+
const newHasExplicit = __classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f").has(newFolderKey);
|
|
157
|
+
const newHasImplicit = [...__classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").keys()].some((k) => k.startsWith(newDocPrefix)) ||
|
|
158
|
+
[...__classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f")].some((k) => k.startsWith(newDocPrefix));
|
|
159
|
+
if (newHasExplicit || newHasImplicit) {
|
|
160
|
+
throw new Error(`Cannot rename folder to ${newPath}: already exists`);
|
|
161
|
+
}
|
|
162
|
+
// Rewrite docs
|
|
163
|
+
const docMoves = [];
|
|
164
|
+
for (const [key, entry] of __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f")) {
|
|
165
|
+
if (key.startsWith(oldDocPrefix)) {
|
|
166
|
+
const rewritten = newFolderKey + '/' + key.slice(oldDocPrefix.length);
|
|
167
|
+
docMoves.push([key, rewritten, entry]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const [oldKey, newKey, entry] of docMoves) {
|
|
171
|
+
__classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").delete(oldKey);
|
|
172
|
+
__classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").set(newKey, Object.assign(Object.assign({}, entry), { lastModified: Date.now() }));
|
|
173
|
+
}
|
|
174
|
+
// Rewrite folders
|
|
175
|
+
const folderMoves = [];
|
|
176
|
+
for (const key of __classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f")) {
|
|
177
|
+
if (key === oldFolderKey) {
|
|
178
|
+
folderMoves.push([key, newFolderKey]);
|
|
179
|
+
}
|
|
180
|
+
else if (key.startsWith(oldDocPrefix)) {
|
|
181
|
+
folderMoves.push([key, newFolderKey + '/' + key.slice(oldDocPrefix.length)]);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const [oldKey, newKey] of folderMoves) {
|
|
185
|
+
__classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f").delete(oldKey);
|
|
186
|
+
__classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f").add(newKey);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async listFolders(tenantId, shardId, prefix) {
|
|
190
|
+
const basePrefix = prefix
|
|
191
|
+
? compositeKey(tenantId, shardId, prefix) + '/'
|
|
192
|
+
: keyPrefix(tenantId, shardId);
|
|
193
|
+
const out = new Set();
|
|
194
|
+
for (const key of __classPrivateFieldGet(this, _MemoryDocumentBackend_folders, "f")) {
|
|
195
|
+
if (!key.startsWith(basePrefix))
|
|
196
|
+
continue;
|
|
197
|
+
const rest = key.slice(basePrefix.length);
|
|
198
|
+
const slash = rest.indexOf('/');
|
|
199
|
+
out.add(slash >= 0 ? rest.slice(0, slash) : rest);
|
|
200
|
+
}
|
|
201
|
+
for (const key of __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").keys()) {
|
|
202
|
+
if (!key.startsWith(basePrefix))
|
|
203
|
+
continue;
|
|
204
|
+
const rest = key.slice(basePrefix.length);
|
|
205
|
+
const slash = rest.indexOf('/');
|
|
206
|
+
if (slash >= 0)
|
|
207
|
+
out.add(rest.slice(0, slash));
|
|
208
|
+
}
|
|
209
|
+
return [...out].sort();
|
|
210
|
+
}
|
|
114
211
|
}
|
|
115
|
-
_MemoryDocumentBackend_store = new WeakMap();
|
|
212
|
+
_MemoryDocumentBackend_store = new WeakMap(), _MemoryDocumentBackend_folders = new WeakMap();
|
|
116
213
|
// ---------------------------------------------------------------------------
|
|
117
214
|
// IndexedDBDocumentBackend
|
|
118
215
|
// ---------------------------------------------------------------------------
|
|
119
216
|
const IDB_NAME = 'sh3-documents';
|
|
120
217
|
const IDB_STORE = 'docs';
|
|
121
|
-
const
|
|
218
|
+
const IDB_FOLDERS = 'folders';
|
|
219
|
+
const IDB_VERSION = 3;
|
|
122
220
|
export class IndexedDBDocumentBackend {
|
|
123
221
|
constructor() {
|
|
124
222
|
_IndexedDBDocumentBackend_instances.add(this);
|
|
@@ -274,6 +372,123 @@ export class IndexedDBDocumentBackend {
|
|
|
274
372
|
req.onerror = () => reject(req.error);
|
|
275
373
|
});
|
|
276
374
|
}
|
|
375
|
+
async mkdir(tenantId, shardId, path) {
|
|
376
|
+
const docKey = compositeKey(tenantId, shardId, path);
|
|
377
|
+
const exists = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_tx).call(this, 'readonly', (s) => s.getKey(docKey));
|
|
378
|
+
if (exists !== undefined) {
|
|
379
|
+
throw new Error(`Cannot mkdir ${path}: a document occupies this path`);
|
|
380
|
+
}
|
|
381
|
+
await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_txOn).call(this, IDB_FOLDERS, 'readwrite', (s) => s.put(1, docKey));
|
|
382
|
+
}
|
|
383
|
+
async rmdir(tenantId, shardId, path, opts) {
|
|
384
|
+
const folderKey = compositeKey(tenantId, shardId, path);
|
|
385
|
+
const docPrefix = folderKey + '/';
|
|
386
|
+
const upper = docPrefix + '';
|
|
387
|
+
const docKeys = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_tx).call(this, 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(docPrefix, upper, false, false)));
|
|
388
|
+
const folderKeys = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_txOn).call(this, IDB_FOLDERS, 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(docPrefix, upper, false, false)));
|
|
389
|
+
if (!opts.recursive && (docKeys.length > 0 || folderKeys.length > 0)) {
|
|
390
|
+
throw new Error(`Cannot rmdir ${path}: folder is not empty`);
|
|
391
|
+
}
|
|
392
|
+
const db = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_db).call(this);
|
|
393
|
+
await new Promise((resolve, reject) => {
|
|
394
|
+
const tx = db.transaction([IDB_STORE, IDB_FOLDERS], 'readwrite');
|
|
395
|
+
const docStore = tx.objectStore(IDB_STORE);
|
|
396
|
+
const folderStore = tx.objectStore(IDB_FOLDERS);
|
|
397
|
+
for (const k of docKeys)
|
|
398
|
+
docStore.delete(k);
|
|
399
|
+
for (const k of folderKeys)
|
|
400
|
+
folderStore.delete(k);
|
|
401
|
+
folderStore.delete(folderKey);
|
|
402
|
+
tx.oncomplete = () => resolve();
|
|
403
|
+
tx.onerror = () => reject(tx.error);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
async renameFolder(tenantId, shardId, oldPath, newPath) {
|
|
407
|
+
const oldFolderKey = compositeKey(tenantId, shardId, oldPath);
|
|
408
|
+
const newFolderKey = compositeKey(tenantId, shardId, newPath);
|
|
409
|
+
const oldPrefix = oldFolderKey + '/';
|
|
410
|
+
const newPrefix = newFolderKey + '/';
|
|
411
|
+
const upperOld = oldPrefix + '';
|
|
412
|
+
const upperNew = newPrefix + '';
|
|
413
|
+
const [oldDocs, oldFolders, oldExplicit, newDocs, newFolders, newExplicit] = await Promise.all([
|
|
414
|
+
__classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_tx).call(this, 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(oldPrefix, upperOld, false, false))),
|
|
415
|
+
__classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_txOn).call(this, IDB_FOLDERS, 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(oldPrefix, upperOld, false, false))),
|
|
416
|
+
__classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_txOn).call(this, IDB_FOLDERS, 'readonly', (s) => s.getKey(oldFolderKey)),
|
|
417
|
+
__classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_tx).call(this, 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(newPrefix, upperNew, false, false))),
|
|
418
|
+
__classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_txOn).call(this, IDB_FOLDERS, 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(newPrefix, upperNew, false, false))),
|
|
419
|
+
__classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_txOn).call(this, IDB_FOLDERS, 'readonly', (s) => s.getKey(newFolderKey)),
|
|
420
|
+
]);
|
|
421
|
+
if (oldDocs.length === 0 && oldFolders.length === 0 && oldExplicit === undefined) {
|
|
422
|
+
throw new Error(`Cannot rename folder ${oldPath}: does not exist`);
|
|
423
|
+
}
|
|
424
|
+
if (newDocs.length > 0 || newFolders.length > 0 || newExplicit !== undefined) {
|
|
425
|
+
throw new Error(`Cannot rename folder to ${newPath}: already exists`);
|
|
426
|
+
}
|
|
427
|
+
// Read doc entries with values via cursor
|
|
428
|
+
const docEntries = await new Promise((resolve, reject) => {
|
|
429
|
+
__classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_db).call(this).then((db) => {
|
|
430
|
+
const tx = db.transaction(IDB_STORE, 'readonly');
|
|
431
|
+
const store = tx.objectStore(IDB_STORE);
|
|
432
|
+
const req = store.openCursor(IDBKeyRange.bound(oldPrefix, upperOld, false, false));
|
|
433
|
+
const acc = [];
|
|
434
|
+
req.onsuccess = () => {
|
|
435
|
+
const cursor = req.result;
|
|
436
|
+
if (cursor) {
|
|
437
|
+
acc.push({ key: cursor.key, value: cursor.value });
|
|
438
|
+
cursor.continue();
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
resolve(acc);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
req.onerror = () => reject(req.error);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
const db = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_db).call(this);
|
|
448
|
+
await new Promise((resolve, reject) => {
|
|
449
|
+
const tx = db.transaction([IDB_STORE, IDB_FOLDERS], 'readwrite');
|
|
450
|
+
const docStore = tx.objectStore(IDB_STORE);
|
|
451
|
+
const folderStore = tx.objectStore(IDB_FOLDERS);
|
|
452
|
+
for (const { key, value } of docEntries) {
|
|
453
|
+
const rewritten = newFolderKey + '/' + key.slice(oldPrefix.length);
|
|
454
|
+
docStore.delete(key);
|
|
455
|
+
docStore.put(Object.assign(Object.assign({}, value), { lastModified: Date.now() }), rewritten);
|
|
456
|
+
}
|
|
457
|
+
if (oldExplicit !== undefined) {
|
|
458
|
+
folderStore.delete(oldFolderKey);
|
|
459
|
+
}
|
|
460
|
+
folderStore.put(1, newFolderKey);
|
|
461
|
+
for (const k of oldFolders) {
|
|
462
|
+
const oldKeyStr = k;
|
|
463
|
+
const rewritten = newFolderKey + '/' + oldKeyStr.slice(oldPrefix.length);
|
|
464
|
+
folderStore.delete(k);
|
|
465
|
+
folderStore.put(1, rewritten);
|
|
466
|
+
}
|
|
467
|
+
tx.oncomplete = () => resolve();
|
|
468
|
+
tx.onerror = () => reject(tx.error);
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
async listFolders(tenantId, shardId, prefix) {
|
|
472
|
+
const basePrefix = prefix
|
|
473
|
+
? compositeKey(tenantId, shardId, prefix) + '/'
|
|
474
|
+
: keyPrefix(tenantId, shardId);
|
|
475
|
+
const upper = basePrefix + '';
|
|
476
|
+
const out = new Set();
|
|
477
|
+
const folderKeys = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_txOn).call(this, IDB_FOLDERS, 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(basePrefix, upper, false, false)));
|
|
478
|
+
for (const k of folderKeys) {
|
|
479
|
+
const rest = k.slice(basePrefix.length);
|
|
480
|
+
const slash = rest.indexOf('/');
|
|
481
|
+
out.add(slash >= 0 ? rest.slice(0, slash) : rest);
|
|
482
|
+
}
|
|
483
|
+
const docKeys = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_tx).call(this, 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(basePrefix, upper, false, false)));
|
|
484
|
+
for (const k of docKeys) {
|
|
485
|
+
const rest = k.slice(basePrefix.length);
|
|
486
|
+
const slash = rest.indexOf('/');
|
|
487
|
+
if (slash >= 0)
|
|
488
|
+
out.add(rest.slice(0, slash));
|
|
489
|
+
}
|
|
490
|
+
return [...out].sort();
|
|
491
|
+
}
|
|
277
492
|
}
|
|
278
493
|
_IndexedDBDocumentBackend_dbPromise = new WeakMap(), _IndexedDBDocumentBackend_instances = new WeakSet(), _IndexedDBDocumentBackend_db = function _IndexedDBDocumentBackend_db() {
|
|
279
494
|
if (!__classPrivateFieldGet(this, _IndexedDBDocumentBackend_dbPromise, "f")) {
|
|
@@ -284,6 +499,9 @@ _IndexedDBDocumentBackend_dbPromise = new WeakMap(), _IndexedDBDocumentBackend_i
|
|
|
284
499
|
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
|
285
500
|
db.createObjectStore(IDB_STORE);
|
|
286
501
|
}
|
|
502
|
+
if (!db.objectStoreNames.contains(IDB_FOLDERS)) {
|
|
503
|
+
db.createObjectStore(IDB_FOLDERS);
|
|
504
|
+
}
|
|
287
505
|
};
|
|
288
506
|
req.onsuccess = () => resolve(req.result);
|
|
289
507
|
req.onerror = () => reject(req.error);
|
|
@@ -301,4 +519,13 @@ async function _IndexedDBDocumentBackend_tx(mode, fn) {
|
|
|
301
519
|
req.onsuccess = () => resolve(req.result);
|
|
302
520
|
req.onerror = () => reject(req.error);
|
|
303
521
|
});
|
|
522
|
+
}, _IndexedDBDocumentBackend_txOn = async function _IndexedDBDocumentBackend_txOn(storeName, mode, fn) {
|
|
523
|
+
const db = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_db).call(this);
|
|
524
|
+
return new Promise((resolve, reject) => {
|
|
525
|
+
const tx = db.transaction(storeName, mode);
|
|
526
|
+
const store = tx.objectStore(storeName);
|
|
527
|
+
const req = fn(store);
|
|
528
|
+
req.onsuccess = () => resolve(req.result);
|
|
529
|
+
req.onerror = () => reject(req.error);
|
|
530
|
+
});
|
|
304
531
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import 'fake-indexeddb/auto';
|
|
2
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
3
|
import { MemoryDocumentBackend, IndexedDBDocumentBackend } from './backends';
|
|
4
4
|
describe('DocumentBackend tenant-wide primitives', () => {
|
|
5
5
|
it('listAllShards returns every shard that has content for a tenant', async () => {
|
|
@@ -99,3 +99,149 @@ describe('IndexedDBDocumentBackend.rename', () => {
|
|
|
99
99
|
.rejects.toThrow(/not found/);
|
|
100
100
|
});
|
|
101
101
|
});
|
|
102
|
+
describe('MemoryDocumentBackend folder ops', () => {
|
|
103
|
+
let backend;
|
|
104
|
+
beforeEach(() => { backend = new MemoryDocumentBackend(); });
|
|
105
|
+
it('mkdir creates an empty folder visible via listFolders', async () => {
|
|
106
|
+
await backend.mkdir('t', 's', 'a');
|
|
107
|
+
expect(await backend.listFolders('t', 's', '')).toEqual(['a']);
|
|
108
|
+
});
|
|
109
|
+
it('mkdir is a no-op if the folder already exists', async () => {
|
|
110
|
+
await backend.mkdir('t', 's', 'a');
|
|
111
|
+
await backend.mkdir('t', 's', 'a');
|
|
112
|
+
expect(await backend.listFolders('t', 's', '')).toEqual(['a']);
|
|
113
|
+
});
|
|
114
|
+
it('mkdir throws if a document occupies the path', async () => {
|
|
115
|
+
await backend.write('t', 's', 'a', 'content');
|
|
116
|
+
await expect(backend.mkdir('t', 's', 'a')).rejects.toThrow();
|
|
117
|
+
});
|
|
118
|
+
it('listFolders surfaces folders implied by document paths', async () => {
|
|
119
|
+
await backend.write('t', 's', 'sub/a.md', 'x');
|
|
120
|
+
expect(await backend.listFolders('t', 's', '')).toEqual(['sub']);
|
|
121
|
+
});
|
|
122
|
+
it('listFolders merges explicit and implicit folders without duplicates', async () => {
|
|
123
|
+
await backend.mkdir('t', 's', 'sub');
|
|
124
|
+
await backend.write('t', 's', 'sub/a.md', 'x');
|
|
125
|
+
expect(await backend.listFolders('t', 's', '')).toEqual(['sub']);
|
|
126
|
+
});
|
|
127
|
+
it('listFolders with prefix returns immediate children', async () => {
|
|
128
|
+
await backend.mkdir('t', 's', 'a/b');
|
|
129
|
+
await backend.mkdir('t', 's', 'a/c');
|
|
130
|
+
await backend.write('t', 's', 'a/d/x.md', 'x');
|
|
131
|
+
const children = await backend.listFolders('t', 's', 'a');
|
|
132
|
+
expect(children.sort()).toEqual(['b', 'c', 'd']);
|
|
133
|
+
});
|
|
134
|
+
it('rmdir on non-empty folder without recursive throws', async () => {
|
|
135
|
+
await backend.write('t', 's', 'a/x.md', 'x');
|
|
136
|
+
await expect(backend.rmdir('t', 's', 'a', { recursive: false })).rejects.toThrow();
|
|
137
|
+
});
|
|
138
|
+
it('rmdir on empty folder without recursive succeeds', async () => {
|
|
139
|
+
await backend.mkdir('t', 's', 'a');
|
|
140
|
+
await backend.rmdir('t', 's', 'a', { recursive: false });
|
|
141
|
+
expect(await backend.listFolders('t', 's', '')).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
it('rmdir recursive removes folder and all descendants', async () => {
|
|
144
|
+
await backend.write('t', 's', 'a/x.md', 'x');
|
|
145
|
+
await backend.write('t', 's', 'a/b/y.md', 'y');
|
|
146
|
+
await backend.mkdir('t', 's', 'a/empty');
|
|
147
|
+
await backend.rmdir('t', 's', 'a', { recursive: true });
|
|
148
|
+
expect(await backend.listFolders('t', 's', '')).toEqual([]);
|
|
149
|
+
expect(await backend.list('t', 's')).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
it('renameFolder rewrites all descendant doc paths', async () => {
|
|
152
|
+
await backend.write('t', 's', 'old/x.md', 'x');
|
|
153
|
+
await backend.write('t', 's', 'old/sub/y.md', 'y');
|
|
154
|
+
await backend.renameFolder('t', 's', 'old', 'new');
|
|
155
|
+
const docs = (await backend.list('t', 's')).map((d) => d.path).sort();
|
|
156
|
+
expect(docs).toEqual(['new/sub/y.md', 'new/x.md']);
|
|
157
|
+
});
|
|
158
|
+
it('renameFolder rewrites empty subfolders too', async () => {
|
|
159
|
+
await backend.mkdir('t', 's', 'old/empty');
|
|
160
|
+
await backend.renameFolder('t', 's', 'old', 'new');
|
|
161
|
+
expect((await backend.listFolders('t', 's', 'new')).sort()).toEqual(['empty']);
|
|
162
|
+
});
|
|
163
|
+
it('renameFolder throws if newPath already exists', async () => {
|
|
164
|
+
await backend.mkdir('t', 's', 'a');
|
|
165
|
+
await backend.mkdir('t', 's', 'b');
|
|
166
|
+
await expect(backend.renameFolder('t', 's', 'a', 'b')).rejects.toThrow();
|
|
167
|
+
});
|
|
168
|
+
it('renameFolder throws if oldPath does not exist', async () => {
|
|
169
|
+
await expect(backend.renameFolder('t', 's', 'missing', 'b')).rejects.toThrow();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe('IndexedDBDocumentBackend folder ops', () => {
|
|
173
|
+
let backend;
|
|
174
|
+
let t;
|
|
175
|
+
let s;
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
backend = new IndexedDBDocumentBackend();
|
|
178
|
+
t = 'tenant_' + Math.random().toString(36).slice(2, 10);
|
|
179
|
+
s = 'shard_' + Math.random().toString(36).slice(2, 10);
|
|
180
|
+
});
|
|
181
|
+
it('mkdir creates an empty folder visible via listFolders', async () => {
|
|
182
|
+
await backend.mkdir(t, s, 'a');
|
|
183
|
+
expect(await backend.listFolders(t, s, '')).toEqual(['a']);
|
|
184
|
+
});
|
|
185
|
+
it('mkdir is a no-op if the folder already exists', async () => {
|
|
186
|
+
await backend.mkdir(t, s, 'a');
|
|
187
|
+
await backend.mkdir(t, s, 'a');
|
|
188
|
+
expect(await backend.listFolders(t, s, '')).toEqual(['a']);
|
|
189
|
+
});
|
|
190
|
+
it('mkdir throws if a document occupies the path', async () => {
|
|
191
|
+
await backend.write(t, s, 'a', 'content');
|
|
192
|
+
await expect(backend.mkdir(t, s, 'a')).rejects.toThrow();
|
|
193
|
+
});
|
|
194
|
+
it('listFolders surfaces folders implied by document paths', async () => {
|
|
195
|
+
await backend.write(t, s, 'sub/a.md', 'x');
|
|
196
|
+
expect(await backend.listFolders(t, s, '')).toEqual(['sub']);
|
|
197
|
+
});
|
|
198
|
+
it('listFolders merges explicit and implicit folders without duplicates', async () => {
|
|
199
|
+
await backend.mkdir(t, s, 'sub');
|
|
200
|
+
await backend.write(t, s, 'sub/a.md', 'x');
|
|
201
|
+
expect(await backend.listFolders(t, s, '')).toEqual(['sub']);
|
|
202
|
+
});
|
|
203
|
+
it('listFolders with prefix returns immediate children', async () => {
|
|
204
|
+
await backend.mkdir(t, s, 'a/b');
|
|
205
|
+
await backend.mkdir(t, s, 'a/c');
|
|
206
|
+
await backend.write(t, s, 'a/d/x.md', 'x');
|
|
207
|
+
const children = await backend.listFolders(t, s, 'a');
|
|
208
|
+
expect(children.sort()).toEqual(['b', 'c', 'd']);
|
|
209
|
+
});
|
|
210
|
+
it('rmdir on non-empty folder without recursive throws', async () => {
|
|
211
|
+
await backend.write(t, s, 'a/x.md', 'x');
|
|
212
|
+
await expect(backend.rmdir(t, s, 'a', { recursive: false })).rejects.toThrow();
|
|
213
|
+
});
|
|
214
|
+
it('rmdir on empty folder without recursive succeeds', async () => {
|
|
215
|
+
await backend.mkdir(t, s, 'a');
|
|
216
|
+
await backend.rmdir(t, s, 'a', { recursive: false });
|
|
217
|
+
expect(await backend.listFolders(t, s, '')).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
it('rmdir recursive removes folder and all descendants', async () => {
|
|
220
|
+
await backend.write(t, s, 'a/x.md', 'x');
|
|
221
|
+
await backend.write(t, s, 'a/b/y.md', 'y');
|
|
222
|
+
await backend.mkdir(t, s, 'a/empty');
|
|
223
|
+
await backend.rmdir(t, s, 'a', { recursive: true });
|
|
224
|
+
expect(await backend.listFolders(t, s, '')).toEqual([]);
|
|
225
|
+
expect(await backend.list(t, s)).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
it('renameFolder rewrites all descendant doc paths', async () => {
|
|
228
|
+
await backend.write(t, s, 'old/x.md', 'x');
|
|
229
|
+
await backend.write(t, s, 'old/sub/y.md', 'y');
|
|
230
|
+
await backend.renameFolder(t, s, 'old', 'new');
|
|
231
|
+
const docs = (await backend.list(t, s)).map((d) => d.path).sort();
|
|
232
|
+
expect(docs).toEqual(['new/sub/y.md', 'new/x.md']);
|
|
233
|
+
});
|
|
234
|
+
it('renameFolder rewrites empty subfolders too', async () => {
|
|
235
|
+
await backend.mkdir(t, s, 'old/empty');
|
|
236
|
+
await backend.renameFolder(t, s, 'old', 'new');
|
|
237
|
+
expect((await backend.listFolders(t, s, 'new')).sort()).toEqual(['empty']);
|
|
238
|
+
});
|
|
239
|
+
it('renameFolder throws if newPath already exists', async () => {
|
|
240
|
+
await backend.mkdir(t, s, 'a');
|
|
241
|
+
await backend.mkdir(t, s, 'b');
|
|
242
|
+
await expect(backend.renameFolder(t, s, 'a', 'b')).rejects.toThrow();
|
|
243
|
+
});
|
|
244
|
+
it('renameFolder throws if oldPath does not exist', async () => {
|
|
245
|
+
await expect(backend.renameFolder(t, s, 'missing', 'b')).rejects.toThrow();
|
|
246
|
+
});
|
|
247
|
+
});
|