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.
Files changed (123) hide show
  1. package/dist/app/admin/AuthSettingsView.svelte +3 -9
  2. package/dist/app/admin/MountsView.svelte +276 -0
  3. package/dist/app/admin/MountsView.svelte.d.ts +3 -0
  4. package/dist/app/admin/SystemView.svelte +6 -6
  5. package/dist/app/admin/UsersView.svelte +103 -7
  6. package/dist/app/admin/adminApp.js +1 -0
  7. package/dist/app/admin/adminShard.svelte.js +10 -0
  8. package/dist/apps/lifecycle.js +1 -0
  9. package/dist/apps/types.d.ts +7 -0
  10. package/dist/assets/iconIds.generated.d.ts +1 -1
  11. package/dist/assets/iconIds.generated.js +1 -0
  12. package/dist/assets/icons.svg +5 -0
  13. package/dist/auth/admin-users.svelte.js +2 -1
  14. package/dist/auth/auth.svelte.d.ts +4 -5
  15. package/dist/auth/auth.svelte.js +5 -6
  16. package/dist/auth/types.d.ts +0 -2
  17. package/dist/chrome/CompactChrome.svelte +25 -6
  18. package/dist/chrome/FloatsSheet.svelte +7 -32
  19. package/dist/chrome/FloatsSheet.svelte.d.ts +1 -2
  20. package/dist/chrome/FloatsSheet.svelte.test.js +8 -14
  21. package/dist/chrome/MenuSheet.svelte +154 -148
  22. package/dist/chrome/MenuSheet.svelte.d.ts +1 -2
  23. package/dist/chrome/MenuSheet.svelte.test.js +24 -12
  24. package/dist/createShell.js +32 -21
  25. package/dist/createShell.remoteAuth.test.js +9 -3
  26. package/dist/documents/backends.d.ts +12 -0
  27. package/dist/documents/backends.js +230 -3
  28. package/dist/documents/backends.test.js +147 -1
  29. package/dist/documents/browse.d.ts +18 -1
  30. package/dist/documents/browse.js +40 -7
  31. package/dist/documents/browse.test.js +35 -35
  32. package/dist/documents/config.d.ts +6 -0
  33. package/dist/documents/config.js +18 -1
  34. package/dist/documents/handle.js +65 -17
  35. package/dist/documents/handle.test.js +88 -1
  36. package/dist/documents/http-backend.d.ts +6 -0
  37. package/dist/documents/http-backend.js +71 -2
  38. package/dist/documents/http-backend.test.js +51 -1
  39. package/dist/documents/index.d.ts +2 -2
  40. package/dist/documents/index.js +1 -1
  41. package/dist/documents/picker-api.d.ts +4 -2
  42. package/dist/documents/picker-api.test.d.ts +1 -1
  43. package/dist/documents/picker-api.test.js +89 -59
  44. package/dist/documents/picker-primitive.d.ts +4 -0
  45. package/dist/documents/picker-primitive.js +27 -29
  46. package/dist/documents/types.d.ts +93 -19
  47. package/dist/documents/types.js +6 -0
  48. package/dist/layout/presets.test.js +4 -4
  49. package/dist/layout/types.d.ts +1 -1
  50. package/dist/layouts-shard/LayoutsSection.svelte +3 -16
  51. package/dist/primitives/widgets/DocumentFilePicker.d.ts +6 -2
  52. package/dist/primitives/widgets/DocumentFilePicker.js +12 -5
  53. package/dist/primitives/widgets/DocumentFilePicker.svelte +27 -5
  54. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +14 -0
  55. package/dist/primitives/widgets/DocumentFilePicker.test.d.ts +1 -0
  56. package/dist/primitives/widgets/DocumentFilePicker.test.js +33 -0
  57. package/dist/primitives/widgets/DocumentOpener.svelte +20 -0
  58. package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +14 -0
  59. package/dist/primitives/widgets/DocumentSaver.svelte +17 -0
  60. package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +13 -0
  61. package/dist/primitives/widgets/PickerList.svelte +1 -0
  62. package/dist/primitives/widgets/_DocumentBrowser.svelte +419 -35
  63. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +12 -0
  64. package/dist/primitives/widgets/_DocumentBrowser.svelte.test.d.ts +1 -0
  65. package/dist/primitives/widgets/_DocumentBrowser.svelte.test.js +277 -0
  66. package/dist/primitives/widgets/_FolderConfirmDelete.svelte +57 -0
  67. package/dist/primitives/widgets/_FolderConfirmDelete.svelte.d.ts +12 -0
  68. package/dist/projects-shard/DeleteProjectDialog.svelte +32 -1
  69. package/dist/projects-shard/ProjectManage.svelte +197 -28
  70. package/dist/projects-shard/ProjectManage.svelte.test.d.ts +1 -0
  71. package/dist/projects-shard/ProjectManage.svelte.test.js +320 -0
  72. package/dist/projects-shard/ProjectsSection.svelte +3 -16
  73. package/dist/projects-shard/projectsApi.js +2 -1
  74. package/dist/registry/permission-descriptions.js +4 -0
  75. package/dist/server-shard/types.d.ts +21 -0
  76. package/dist/sh3Api/headless.js +10 -0
  77. package/dist/sh3core-shard/HomeSection.svelte +107 -0
  78. package/dist/sh3core-shard/HomeSection.svelte.d.ts +10 -0
  79. package/dist/sh3core-shard/Sh3Home.svelte +9 -23
  80. package/dist/shards/activate.svelte.d.ts +4 -0
  81. package/dist/shards/activate.svelte.js +11 -3
  82. package/dist/shards/types.d.ts +7 -0
  83. package/dist/shell-shard/Terminal.svelte +4 -1
  84. package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
  85. package/dist/shell-shard/dispatch.d.ts +2 -0
  86. package/dist/shell-shard/dispatch.js +2 -0
  87. package/dist/shell-shard/manifest.js +7 -1
  88. package/dist/shell-shard/shellShard.svelte.js +1 -1
  89. package/dist/shell-shard/tenant-fs-client.js +2 -1
  90. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  91. package/dist/shell-shard/verbs/cat.js +35 -0
  92. package/dist/shell-shard/verbs/cat.test.d.ts +1 -0
  93. package/dist/shell-shard/verbs/cat.test.js +49 -0
  94. package/dist/shell-shard/verbs/index.js +12 -0
  95. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  96. package/dist/shell-shard/verbs/ls.js +48 -0
  97. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  98. package/dist/shell-shard/verbs/ls.test.js +64 -0
  99. package/dist/shell-shard/verbs/mkdir.d.ts +2 -0
  100. package/dist/shell-shard/verbs/mkdir.js +30 -0
  101. package/dist/shell-shard/verbs/mkdir.test.d.ts +1 -0
  102. package/dist/shell-shard/verbs/mkdir.test.js +48 -0
  103. package/dist/shell-shard/verbs/mv.d.ts +2 -0
  104. package/dist/shell-shard/verbs/mv.js +33 -0
  105. package/dist/shell-shard/verbs/mv.test.d.ts +1 -0
  106. package/dist/shell-shard/verbs/mv.test.js +55 -0
  107. package/dist/shell-shard/verbs/rm.d.ts +2 -0
  108. package/dist/shell-shard/verbs/rm.js +28 -0
  109. package/dist/shell-shard/verbs/rm.test.d.ts +1 -0
  110. package/dist/shell-shard/verbs/rm.test.js +47 -0
  111. package/dist/shell-shard/verbs/scope-parse.d.ts +7 -0
  112. package/dist/shell-shard/verbs/scope-parse.js +33 -0
  113. package/dist/shell-shard/verbs/scope-parse.test.d.ts +1 -0
  114. package/dist/shell-shard/verbs/scope-parse.test.js +76 -0
  115. package/dist/shell-shard/verbs/xfer.d.ts +2 -0
  116. package/dist/shell-shard/verbs/xfer.js +101 -0
  117. package/dist/shell-shard/verbs/xfer.test.d.ts +1 -0
  118. package/dist/shell-shard/verbs/xfer.test.js +96 -0
  119. package/dist/transport/apiFetch.js +12 -5
  120. package/dist/verbs/types.d.ts +18 -0
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  /*
2
- * DOM smoke for MenuSheet — verifies open/closed rendering. The
3
- * container/item resolution is exercised via the same model functions
4
- * MenuBar uses; their unit tests cover the resolution semantics so
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 nothing when closed', () => {
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: { open: false, onClose: () => { } },
29
+ props: { close: () => { } },
30
30
  });
31
31
  flushSync();
32
- expect(host.querySelector('[data-sh3-region="menu-sheet"]')).toBeNull();
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('renders a sheet with a Cancel button when open', () => {
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: { open: true, onClose: () => { } },
41
+ props: { close: () => { } },
40
42
  });
41
43
  flushSync();
42
- const sheet = host.querySelector('[data-sh3-region="menu-sheet"]');
43
- expect(sheet).not.toBeNull();
44
- expect(sheet.querySelector('.cancel').textContent).toContain('Cancel');
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
  });
@@ -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 (skip for purely-local owners; remoteAuth
88
- // forces it for cross-origin Tauri clients).
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
- const useServerAuth = !platform.localOwner || (config === null || config === void 0 ? void 0 : config.remoteAuth) === true;
91
- if (useServerAuth) {
92
- try {
93
- const res = await apiFetch(`${sUrl}/api/boot`);
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/dev): no auth, no sign-in, scope is 'local'.
105
- // setLocalOwner() already called above admin is assumed.
106
- __setActiveScope('local');
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, auth required, no guest allowed → sign-in wall
113
- if (!session && auth.required && !auth.guestAllowed) {
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('keeps the legacy localOwner short-circuit when remoteAuth is absent', async () => {
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('ok');
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(false);
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 IDB_VERSION = 2;
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
+ });