sh3-core 0.20.1 → 0.20.3

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 (103) hide show
  1. package/dist/BrandSlot.svelte +2 -2
  2. package/dist/actions/ctx-actions.svelte.test.js +2 -2
  3. package/dist/artifact.d.ts +2 -0
  4. package/dist/boot/satellitePayload.d.ts +2 -0
  5. package/dist/boot/satellitePayload.test.js +19 -0
  6. package/dist/build.d.ts +7 -1
  7. package/dist/build.js +22 -3
  8. package/dist/build.test.js +27 -1
  9. package/dist/createShell.js +34 -9
  10. package/dist/documents/backends.d.ts +12 -0
  11. package/dist/documents/backends.js +230 -3
  12. package/dist/documents/backends.test.js +147 -1
  13. package/dist/documents/browse.d.ts +20 -0
  14. package/dist/documents/browse.js +35 -0
  15. package/dist/documents/browse.test.js +125 -0
  16. package/dist/documents/config.d.ts +2 -4
  17. package/dist/documents/config.js +3 -7
  18. package/dist/documents/handle.js +40 -0
  19. package/dist/documents/handle.test.js +88 -1
  20. package/dist/documents/http-backend.d.ts +11 -0
  21. package/dist/documents/http-backend.js +86 -0
  22. package/dist/documents/http-backend.test.js +117 -1
  23. package/dist/documents/index.d.ts +1 -1
  24. package/dist/documents/index.js +1 -1
  25. package/dist/documents/picker-api.test.js +2 -2
  26. package/dist/documents/types.d.ts +87 -14
  27. package/dist/documents/types.js +4 -0
  28. package/dist/host-entry.d.ts +1 -1
  29. package/dist/host-entry.js +1 -1
  30. package/dist/host.d.ts +1 -1
  31. package/dist/host.js +1 -1
  32. package/dist/layout/slotHostPool.svelte.js +2 -2
  33. package/dist/overlays/FloatFrame.svelte +1 -0
  34. package/dist/primitives/widgets/DocumentFilePicker.d.ts +6 -2
  35. package/dist/primitives/widgets/DocumentFilePicker.js +12 -5
  36. package/dist/primitives/widgets/DocumentFilePicker.svelte +23 -1
  37. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +14 -0
  38. package/dist/primitives/widgets/DocumentFilePicker.test.d.ts +1 -0
  39. package/dist/primitives/widgets/DocumentFilePicker.test.js +33 -0
  40. package/dist/primitives/widgets/DocumentOpener.svelte +20 -0
  41. package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +14 -0
  42. package/dist/primitives/widgets/DocumentSaver.svelte +17 -0
  43. package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +13 -0
  44. package/dist/primitives/widgets/_DocumentBrowser.svelte +414 -27
  45. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +12 -0
  46. package/dist/primitives/widgets/_DocumentBrowser.svelte.test.d.ts +1 -0
  47. package/dist/primitives/widgets/_DocumentBrowser.svelte.test.js +277 -0
  48. package/dist/primitives/widgets/_FolderConfirmDelete.svelte +57 -0
  49. package/dist/primitives/widgets/_FolderConfirmDelete.svelte.d.ts +12 -0
  50. package/dist/projects/session-state.svelte.d.ts +3 -0
  51. package/dist/projects/session-state.svelte.js +25 -0
  52. package/dist/projects/session-state.test.js +43 -2
  53. package/dist/projects-shard/ProjectsSection.svelte +14 -18
  54. package/dist/runtime/runVerb-shell.test.js +2 -2
  55. package/dist/runtime/runVerb.test.js +2 -2
  56. package/dist/sh3Api/headless.js +10 -0
  57. package/dist/sh3core-shard/appActions.js +5 -2
  58. package/dist/shards/activate-browse.test.js +2 -2
  59. package/dist/shards/activate-contributions.test.js +2 -2
  60. package/dist/shards/activate-error-isolation.test.js +3 -3
  61. package/dist/shards/activate-on-key-revoked.test.js +2 -2
  62. package/dist/shards/activate-runtime.test.js +2 -2
  63. package/dist/shards/activate.svelte.js +5 -5
  64. package/dist/shards/ctx-fetch.test.js +4 -4
  65. package/dist/shell-shard/Terminal.svelte +4 -1
  66. package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
  67. package/dist/shell-shard/dispatch.d.ts +2 -0
  68. package/dist/shell-shard/dispatch.js +2 -0
  69. package/dist/shell-shard/manifest.js +7 -1
  70. package/dist/shell-shard/shellShard.svelte.js +1 -1
  71. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  72. package/dist/shell-shard/verbs/cat.js +35 -0
  73. package/dist/shell-shard/verbs/cat.test.d.ts +1 -0
  74. package/dist/shell-shard/verbs/cat.test.js +49 -0
  75. package/dist/shell-shard/verbs/index.js +12 -0
  76. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  77. package/dist/shell-shard/verbs/ls.js +48 -0
  78. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  79. package/dist/shell-shard/verbs/ls.test.js +64 -0
  80. package/dist/shell-shard/verbs/mkdir.d.ts +2 -0
  81. package/dist/shell-shard/verbs/mkdir.js +30 -0
  82. package/dist/shell-shard/verbs/mkdir.test.d.ts +1 -0
  83. package/dist/shell-shard/verbs/mkdir.test.js +48 -0
  84. package/dist/shell-shard/verbs/mv.d.ts +2 -0
  85. package/dist/shell-shard/verbs/mv.js +33 -0
  86. package/dist/shell-shard/verbs/mv.test.d.ts +1 -0
  87. package/dist/shell-shard/verbs/mv.test.js +55 -0
  88. package/dist/shell-shard/verbs/rm.d.ts +2 -0
  89. package/dist/shell-shard/verbs/rm.js +28 -0
  90. package/dist/shell-shard/verbs/rm.test.d.ts +1 -0
  91. package/dist/shell-shard/verbs/rm.test.js +47 -0
  92. package/dist/shell-shard/verbs/scope-parse.d.ts +7 -0
  93. package/dist/shell-shard/verbs/scope-parse.js +33 -0
  94. package/dist/shell-shard/verbs/scope-parse.test.d.ts +1 -0
  95. package/dist/shell-shard/verbs/scope-parse.test.js +76 -0
  96. package/dist/shell-shard/verbs/xfer.d.ts +2 -0
  97. package/dist/shell-shard/verbs/xfer.js +87 -0
  98. package/dist/shell-shard/verbs/xfer.test.d.ts +1 -0
  99. package/dist/shell-shard/verbs/xfer.test.js +107 -0
  100. package/dist/verbs/types.d.ts +18 -0
  101. package/dist/version.d.ts +1 -1
  102. package/dist/version.js +1 -1
  103. package/package.json +1 -1
@@ -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
+ });
@@ -108,6 +108,26 @@ export interface BrowseCapability {
108
108
  targetShardId?: string;
109
109
  delete?: boolean;
110
110
  }): Promise<void>;
111
+ /**
112
+ * List all documents in an arbitrary tenant. Write-gated — cross-tenant
113
+ * enumeration is a privileged operation used by xfer -R for cross-scope recursion.
114
+ *
115
+ * Absent (undefined) when `documents:write` is not declared.
116
+ */
117
+ listDocumentsIn?(tenantId: string): Promise<Array<DocumentMeta & {
118
+ shardId: string;
119
+ }>>;
120
+ /**
121
+ * Copy or move a document between any two tenants. Neither tenant needs to
122
+ * be the active one. Emits documentChanges for both source (delete if
123
+ * opts.delete is true) and destination (create/update). Throws when src
124
+ * and dst are identical.
125
+ *
126
+ * Absent (undefined) when `documents:write` is not declared.
127
+ */
128
+ transferBetweenScopes?(srcTenant: string, srcShardId: string, srcPath: string, dstTenant: string, dstShardId: string, dstPath: string, opts?: {
129
+ delete?: boolean;
130
+ }): Promise<void>;
111
131
  }
112
132
  export interface BrowseCapabilityOptions {
113
133
  /** When true, the returned capability exposes `readFrom`. */
@@ -99,6 +99,41 @@ export function createBrowseCapability(getTenantId, backend, options = { canRead
99
99
  documentChanges.emit({ type: 'delete', path, tenantId, shardId });
100
100
  }
101
101
  };
102
+ capability.listDocumentsIn = (tenantId) => backend.listAllDocuments(tenantId);
103
+ capability.transferBetweenScopes = async (srcTenant, srcShard, srcPath, dstTenant, dstShard, dstPath, opts) => {
104
+ if (srcTenant === dstTenant && srcShard === dstShard && srcPath === dstPath) {
105
+ throw new Error('transferBetweenScopes: source and destination are identical');
106
+ }
107
+ if (backend.xfer) {
108
+ const { existed } = await backend.xfer(srcTenant, `${srcShard}/${srcPath}`, dstTenant, `${dstShard}/${dstPath}`, { move: opts === null || opts === void 0 ? void 0 : opts.delete });
109
+ documentChanges.emit({
110
+ type: existed ? 'update' : 'create',
111
+ path: dstPath,
112
+ tenantId: dstTenant,
113
+ shardId: dstShard,
114
+ });
115
+ if (opts === null || opts === void 0 ? void 0 : opts.delete) {
116
+ documentChanges.emit({ type: 'delete', path: srcPath, tenantId: srcTenant, shardId: srcShard });
117
+ }
118
+ return;
119
+ }
120
+ const content = await backend.read(srcTenant, srcShard, srcPath);
121
+ if (content === null) {
122
+ throw new Error(`Document not found at ${srcShard}/${srcPath} in scope ${srcTenant}`);
123
+ }
124
+ const existed = await backend.exists(dstTenant, dstShard, dstPath);
125
+ await backend.write(dstTenant, dstShard, dstPath, content);
126
+ documentChanges.emit({
127
+ type: existed ? 'update' : 'create',
128
+ path: dstPath,
129
+ tenantId: dstTenant,
130
+ shardId: dstShard,
131
+ });
132
+ if (opts === null || opts === void 0 ? void 0 : opts.delete) {
133
+ await backend.delete(srcTenant, srcShard, srcPath);
134
+ documentChanges.emit({ type: 'delete', path: srcPath, tenantId: srcTenant, shardId: srcShard });
135
+ }
136
+ };
102
137
  }
103
138
  return capability;
104
139
  }
@@ -304,4 +304,129 @@ describe('BrowseCapability', () => {
304
304
  expect(await be.read('t2', 's', 'secret.txt')).toBe('hidden');
305
305
  });
306
306
  });
307
+ describe('listDocumentsIn (documents:write gate)', () => {
308
+ it('is absent when canWrite is false', () => {
309
+ const be = new MemoryDocumentBackend();
310
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: false });
311
+ expect(browse.listDocumentsIn).toBeUndefined();
312
+ });
313
+ it('lists documents from an arbitrary tenant, not the active one', async () => {
314
+ const be = new MemoryDocumentBackend();
315
+ await be.write('t2', 'notes', 'a.md', 'hello');
316
+ await be.write('t2', 'notes', 'b.md', 'world');
317
+ await be.write('t1', 'notes', 'c.md', 'active');
318
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
319
+ const docs = await browse.listDocumentsIn('t2');
320
+ expect(docs.map((d) => d.path).sort()).toEqual(['a.md', 'b.md']);
321
+ });
322
+ });
323
+ describe('transferBetweenScopes (documents:write gate)', () => {
324
+ it('is absent when canWrite is false', () => {
325
+ const be = new MemoryDocumentBackend();
326
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: false });
327
+ expect(browse.transferBetweenScopes).toBeUndefined();
328
+ });
329
+ it('copies a document from one tenant to another and emits create', async () => {
330
+ const be = new MemoryDocumentBackend();
331
+ await be.write('t1', 'notes', 'draft.md', 'content');
332
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
333
+ const events = [];
334
+ const unsub = documentChanges.subscribe((c) => events.push(c));
335
+ await browse.transferBetweenScopes('t1', 'notes', 'draft.md', 't2', 'notes', 'draft.md');
336
+ expect(await be.read('t2', 'notes', 'draft.md')).toBe('content');
337
+ expect(await be.read('t1', 'notes', 'draft.md')).toBe('content'); // source intact
338
+ expect(events).toEqual([
339
+ { type: 'create', path: 'draft.md', tenantId: 't2', shardId: 'notes' },
340
+ ]);
341
+ unsub();
342
+ });
343
+ it('deletes source and emits delete when opts.delete is true', async () => {
344
+ const be = new MemoryDocumentBackend();
345
+ await be.write('t1', 'notes', 'draft.md', 'content');
346
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
347
+ const events = [];
348
+ const unsub = documentChanges.subscribe((c) => events.push(c));
349
+ await browse.transferBetweenScopes('t1', 'notes', 'draft.md', 't2', 'notes', 'draft.md', { delete: true });
350
+ expect(await be.read('t2', 'notes', 'draft.md')).toBe('content');
351
+ expect(await be.read('t1', 'notes', 'draft.md')).toBeNull();
352
+ expect(events).toEqual([
353
+ { type: 'create', path: 'draft.md', tenantId: 't2', shardId: 'notes' },
354
+ { type: 'delete', path: 'draft.md', tenantId: 't1', shardId: 'notes' },
355
+ ]);
356
+ unsub();
357
+ });
358
+ it('emits update (not create) when destination already exists', async () => {
359
+ const be = new MemoryDocumentBackend();
360
+ await be.write('t1', 'notes', 'draft.md', 'v1');
361
+ await be.write('t2', 'notes', 'draft.md', 'old');
362
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
363
+ const events = [];
364
+ const unsub = documentChanges.subscribe((c) => events.push(c));
365
+ await browse.transferBetweenScopes('t1', 'notes', 'draft.md', 't2', 'notes', 'draft.md');
366
+ expect(events[0].type).toBe('update');
367
+ unsub();
368
+ });
369
+ it('throws when source and destination are identical', async () => {
370
+ const be = new MemoryDocumentBackend();
371
+ await be.write('t1', 'notes', 'draft.md', 'x');
372
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
373
+ await expect(browse.transferBetweenScopes('t1', 'notes', 'draft.md', 't1', 'notes', 'draft.md')).rejects.toThrow('identical');
374
+ });
375
+ it('throws when source document does not exist', async () => {
376
+ const be = new MemoryDocumentBackend();
377
+ const browse = createBrowseCapability(() => 't1', be, { canRead: false, canWrite: true });
378
+ await expect(browse.transferBetweenScopes('t1', 'notes', 'missing.md', 't2', 'notes', 'missing.md')).rejects.toThrow('not found');
379
+ });
380
+ });
381
+ });
382
+ describe('transferBetweenScopes', () => {
383
+ it('delegates to backend.xfer when the method is present', async () => {
384
+ const xfer = vi.fn(async () => ({ existed: false }));
385
+ const be = new MemoryDocumentBackend();
386
+ be.xfer = xfer;
387
+ const browse = createBrowseCapability(() => 'alice', be, { canRead: true, canWrite: true });
388
+ await browse.transferBetweenScopes('alice', 'notes', 'draft.md', 'proj-1', 'notes', 'draft.md', { delete: true });
389
+ expect(xfer).toHaveBeenCalledWith('alice', 'notes/draft.md', 'proj-1', 'notes/draft.md', { move: true });
390
+ });
391
+ it('emits create on dst and delete on src when move=true and existed=false', async () => {
392
+ const changes = [];
393
+ const unsub = documentChanges.subscribe((c) => changes.push(c));
394
+ const xfer = vi.fn(async () => ({ existed: false }));
395
+ const be = new MemoryDocumentBackend();
396
+ be.xfer = xfer;
397
+ const browse = createBrowseCapability(() => 'alice', be, { canRead: true, canWrite: true });
398
+ await browse.transferBetweenScopes('alice', 'notes', 'draft.md', 'proj-1', 'notes', 'draft.md', { delete: true });
399
+ unsub();
400
+ expect(changes).toContainEqual(expect.objectContaining({ type: 'create', path: 'draft.md', tenantId: 'proj-1', shardId: 'notes' }));
401
+ expect(changes).toContainEqual(expect.objectContaining({ type: 'delete', path: 'draft.md', tenantId: 'alice', shardId: 'notes' }));
402
+ });
403
+ it('emits update on dst when existed=true', async () => {
404
+ const changes = [];
405
+ const unsub = documentChanges.subscribe((c) => changes.push(c));
406
+ const xfer = vi.fn(async () => ({ existed: true }));
407
+ const be = new MemoryDocumentBackend();
408
+ be.xfer = xfer;
409
+ const browse = createBrowseCapability(() => 'alice', be, { canRead: true, canWrite: true });
410
+ await browse.transferBetweenScopes('alice', 'notes', 'draft.md', 'proj-1', 'notes', 'draft.md', { delete: false });
411
+ unsub();
412
+ expect(changes).toContainEqual(expect.objectContaining({ type: 'update', path: 'draft.md', tenantId: 'proj-1', shardId: 'notes' }));
413
+ });
414
+ it('falls back to read+write when backend.xfer is absent', async () => {
415
+ const be = new MemoryDocumentBackend();
416
+ await be.write('alice', 'notes', 'draft.md', 'hello');
417
+ const browse = createBrowseCapability(() => 'alice', be, { canRead: true, canWrite: true });
418
+ await browse.transferBetweenScopes('alice', 'notes', 'draft.md', 'proj-1', 'notes', 'draft.md', { delete: false });
419
+ const copied = await be.read('proj-1', 'notes', 'draft.md');
420
+ expect(copied).toBe('hello');
421
+ const original = await be.read('alice', 'notes', 'draft.md');
422
+ expect(original).toBe('hello');
423
+ });
424
+ it('falls back to read+write+delete when backend.xfer is absent and delete=true', async () => {
425
+ const be = new MemoryDocumentBackend();
426
+ await be.write('alice', 'notes', 'draft.md', 'hello');
427
+ const browse = createBrowseCapability(() => 'alice', be, { canRead: true, canWrite: true });
428
+ await browse.transferBetweenScopes('alice', 'notes', 'draft.md', 'proj-1', 'notes', 'draft.md', { delete: true });
429
+ expect(await be.read('alice', 'notes', 'draft.md')).toBeNull();
430
+ expect(await be.read('proj-1', 'notes', 'draft.md')).toBe('hello');
431
+ });
307
432
  });
@@ -4,12 +4,10 @@ import type { DocumentBackend } from './types';
4
4
  * scopeId for all document operations. Wired by createShell after bootstrap. */
5
5
  export declare function __setScopeResolver(resolver: (() => string | null) | null): void;
6
6
  export declare function getActiveScopeId(): string;
7
- /** @deprecated use getActiveScopeIdkept until callers migrate. */
8
- export declare function getTenantId(): string;
7
+ /** The user's base (personal) tenant id never overridden by the project resolver. */
8
+ export declare function getPersonalScopeId(): string;
9
9
  export declare function getDocumentBackend(): DocumentBackend;
10
10
  /** Host-only. Set the active scope id before bootstrap(). */
11
11
  export declare function __setActiveScope(id: string): void;
12
- /** @deprecated use __setActiveScope — kept until callers migrate. */
13
- export declare function __setTenantId(id: string): void;
14
12
  /** Host-only. Swap the document backend before bootstrap(). */
15
13
  export declare function __setDocumentBackend(b: DocumentBackend): void;
@@ -27,9 +27,9 @@ export function getActiveScopeId() {
27
27
  var _a;
28
28
  return (_a = scopeResolver === null || scopeResolver === void 0 ? void 0 : scopeResolver()) !== null && _a !== void 0 ? _a : scopeId;
29
29
  }
30
- /** @deprecated use getActiveScopeIdkept until callers migrate. */
31
- export function getTenantId() {
32
- return getActiveScopeId();
30
+ /** The user's base (personal) tenant id never overridden by the project resolver. */
31
+ export function getPersonalScopeId() {
32
+ return scopeId;
33
33
  }
34
34
  export function getDocumentBackend() {
35
35
  return backend;
@@ -38,10 +38,6 @@ export function getDocumentBackend() {
38
38
  export function __setActiveScope(id) {
39
39
  scopeId = id;
40
40
  }
41
- /** @deprecated use __setActiveScope — kept until callers migrate. */
42
- export function __setTenantId(id) {
43
- __setActiveScope(id);
44
- }
45
41
  /** Host-only. Swap the document backend before bootstrap(). */
46
42
  export function __setDocumentBackend(b) {
47
43
  backend = b;
@@ -86,6 +86,46 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
86
86
  shardId,
87
87
  });
88
88
  },
89
+ async mkdir(path, opts) {
90
+ const tid = resolveTenant(opts);
91
+ await backend.mkdir(tid, shardId, path);
92
+ documentChanges.emit({ type: 'folder-create', path, tenantId: tid, shardId });
93
+ },
94
+ async rmdir(path, opts) {
95
+ var _a;
96
+ const recursive = (_a = opts === null || opts === void 0 ? void 0 : opts.recursive) !== null && _a !== void 0 ? _a : false;
97
+ if (recursive) {
98
+ const folderPrefix = path + '/';
99
+ for (const ctrl of controllers) {
100
+ if (ctrl.path === path || ctrl.path.startsWith(folderPrefix)) {
101
+ throw new Error(`Cannot rmdir ${path}: active autosave on ${ctrl.path}; flush and dispose first`);
102
+ }
103
+ }
104
+ }
105
+ const tid = resolveTenant(opts);
106
+ await backend.rmdir(tid, shardId, path, { recursive });
107
+ documentChanges.emit({ type: 'folder-delete', path, tenantId: tid, shardId });
108
+ },
109
+ async renameFolder(oldPath, newPath, opts) {
110
+ const folderPrefix = oldPath + '/';
111
+ for (const ctrl of controllers) {
112
+ if (ctrl.path === oldPath || ctrl.path.startsWith(folderPrefix)) {
113
+ throw new Error(`Cannot rename folder ${oldPath}: active autosave on ${ctrl.path}; flush and dispose first`);
114
+ }
115
+ }
116
+ const tid = resolveTenant(opts);
117
+ await backend.renameFolder(tid, shardId, oldPath, newPath);
118
+ documentChanges.emit({
119
+ type: 'folder-rename',
120
+ path: newPath,
121
+ oldPath,
122
+ tenantId: tid,
123
+ shardId,
124
+ });
125
+ },
126
+ async listFolders(prefix, opts) {
127
+ return backend.listFolders(resolveTenant(opts), shardId, prefix !== null && prefix !== void 0 ? prefix : '');
128
+ },
89
129
  async exists(path) {
90
130
  return backend.exists(tenantId, shardId, path);
91
131
  },
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { MemoryDocumentBackend } from './backends';
3
3
  import { createDocumentHandle } from './handle';
4
4
  import { documentChanges } from './notifications';
@@ -28,6 +28,10 @@ describe('DocumentHandle.status()', () => {
28
28
  async exists() { return false; },
29
29
  async listAllShards() { return []; },
30
30
  async listAllDocuments() { return []; },
31
+ async mkdir() { },
32
+ async rmdir() { },
33
+ async renameFolder() { },
34
+ async listFolders() { return []; },
31
35
  };
32
36
  const handle = createDocumentHandle('t', 's', backend, { format: 'text' });
33
37
  await expect(handle.status('a.txt')).rejects.toThrow(/status/);
@@ -46,6 +50,10 @@ describe('DocumentHandle.resolveConflict()', () => {
46
50
  async listAllShards() { return []; },
47
51
  async listAllDocuments() { return []; },
48
52
  async resolve(t, s, p, c) { resolved.push({ t, s, p, c }); },
53
+ async mkdir() { },
54
+ async rmdir() { },
55
+ async renameFolder() { },
56
+ async listFolders() { return []; },
49
57
  };
50
58
  const handle = createDocumentHandle('tenant1', 'shard1', backend, { format: 'text' });
51
59
  await handle.resolveConflict('a.txt', 'local');
@@ -69,6 +77,10 @@ describe('DocumentHandle.readBranch()', () => {
69
77
  async listAllShards() { return []; },
70
78
  async listAllDocuments() { return []; },
71
79
  async readBranch(...args) { calls.push(args); return 'remote-content'; },
80
+ async mkdir() { },
81
+ async rmdir() { },
82
+ async renameFolder() { },
83
+ async listFolders() { return []; },
72
84
  };
73
85
  const handle = createDocumentHandle('tenant1', 'shard1', backend, { format: 'text' });
74
86
  const out = await handle.readBranch('a.txt', 'peer-1');
@@ -86,6 +98,10 @@ describe('DocumentHandle.readBranch()', () => {
86
98
  async listAllShards() { return []; },
87
99
  async listAllDocuments() { return []; },
88
100
  async readBranch() { return null; },
101
+ async mkdir() { },
102
+ async rmdir() { },
103
+ async renameFolder() { },
104
+ async listFolders() { return []; },
89
105
  };
90
106
  const handle = createDocumentHandle('t', 's', backend, { format: 'text' });
91
107
  expect(await handle.readBranch('a.txt', 'peer-1')).toBeNull();
@@ -164,3 +180,74 @@ describe('DocumentHandle.delete()', () => {
164
180
  unsub();
165
181
  });
166
182
  });
183
+ describe('DocumentHandle folder ops', () => {
184
+ let backend;
185
+ let handle;
186
+ beforeEach(() => {
187
+ backend = new MemoryDocumentBackend();
188
+ handle = createDocumentHandle('t', 's', backend, { format: 'text' });
189
+ });
190
+ it('mkdir forwards to backend with bound tenant/shard', async () => {
191
+ await handle.mkdir('a');
192
+ expect(await backend.listFolders('t', 's', '')).toEqual(['a']);
193
+ });
194
+ it('rmdir defaults recursive to false', async () => {
195
+ await handle.write('a/x.md', 'x');
196
+ await expect(handle.rmdir('a')).rejects.toThrow();
197
+ });
198
+ it('rmdir({recursive:true}) cascades', async () => {
199
+ await handle.write('a/x.md', 'x');
200
+ await handle.rmdir('a', { recursive: true });
201
+ expect(await handle.list()).toEqual([]);
202
+ });
203
+ it('renameFolder rewrites descendant paths', async () => {
204
+ await handle.write('old/x.md', 'x');
205
+ await handle.renameFolder('old', 'new');
206
+ const docs = (await handle.list()).map((d) => d.path).sort();
207
+ expect(docs).toEqual(['new/x.md']);
208
+ });
209
+ it('renameFolder refused when an autosave controller is inside the folder', async () => {
210
+ const ctrl = handle.autosave('a/x.md');
211
+ ctrl.update('content');
212
+ await expect(handle.renameFolder('a', 'b')).rejects.toThrow(/autosave/i);
213
+ await ctrl.dispose();
214
+ });
215
+ it('rmdir(recursive) refused when an autosave controller is inside the folder', async () => {
216
+ const ctrl = handle.autosave('a/x.md');
217
+ ctrl.update('content');
218
+ await expect(handle.rmdir('a', { recursive: true })).rejects.toThrow(/autosave/i);
219
+ await ctrl.dispose();
220
+ });
221
+ it('listFolders forwards to backend', async () => {
222
+ await handle.mkdir('a');
223
+ expect(await handle.listFolders()).toEqual(['a']);
224
+ expect(await handle.listFolders('a')).toEqual([]);
225
+ });
226
+ it('mkdir emits folder-create change event', async () => {
227
+ const events = [];
228
+ handle.watch((c) => events.push(c));
229
+ await handle.mkdir('a');
230
+ expect(events).toEqual([
231
+ { type: 'folder-create', path: 'a', tenantId: 't', shardId: 's' },
232
+ ]);
233
+ });
234
+ it('rmdir(recursive) emits a single folder-delete event', async () => {
235
+ await handle.write('a/x.md', 'x');
236
+ await handle.write('a/y.md', 'y');
237
+ const events = [];
238
+ handle.watch((c) => events.push(c));
239
+ await handle.rmdir('a', { recursive: true });
240
+ expect(events).toEqual([
241
+ { type: 'folder-delete', path: 'a', tenantId: 't', shardId: 's' },
242
+ ]);
243
+ });
244
+ it('renameFolder emits a single folder-rename event', async () => {
245
+ await handle.write('old/x.md', 'x');
246
+ const events = [];
247
+ handle.watch((c) => events.push(c));
248
+ await handle.renameFolder('old', 'new');
249
+ expect(events).toEqual([
250
+ { type: 'folder-rename', path: 'new', oldPath: 'old', tenantId: 't', shardId: 's' },
251
+ ]);
252
+ });
253
+ });
@@ -31,5 +31,16 @@ export declare class HttpDocumentBackend implements DocumentBackend {
31
31
  origin: string;
32
32
  } | string): Promise<void>;
33
33
  readBranch(tenantId: string, shardId: string, path: string, origin: string): Promise<string | null>;
34
+ xfer(srcTenant: string, srcPath: string, dstTenant: string, dstPath: string, opts?: {
35
+ move?: boolean;
36
+ }): Promise<{
37
+ existed: boolean;
38
+ }>;
34
39
  rename(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
40
+ mkdir(tenantId: string, shardId: string, path: string): Promise<void>;
41
+ rmdir(tenantId: string, shardId: string, path: string, opts: {
42
+ recursive: boolean;
43
+ }): Promise<void>;
44
+ renameFolder(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
45
+ listFolders(tenantId: string, shardId: string, prefix: string): Promise<string[]>;
35
46
  }