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.
- package/dist/BrandSlot.svelte +2 -2
- package/dist/actions/ctx-actions.svelte.test.js +2 -2
- package/dist/artifact.d.ts +2 -0
- package/dist/boot/satellitePayload.d.ts +2 -0
- package/dist/boot/satellitePayload.test.js +19 -0
- package/dist/build.d.ts +7 -1
- package/dist/build.js +22 -3
- package/dist/build.test.js +27 -1
- package/dist/createShell.js +34 -9
- 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 +20 -0
- package/dist/documents/browse.js +35 -0
- package/dist/documents/browse.test.js +125 -0
- package/dist/documents/config.d.ts +2 -4
- package/dist/documents/config.js +3 -7
- package/dist/documents/handle.js +40 -0
- package/dist/documents/handle.test.js +88 -1
- package/dist/documents/http-backend.d.ts +11 -0
- package/dist/documents/http-backend.js +86 -0
- package/dist/documents/http-backend.test.js +117 -1
- package/dist/documents/index.d.ts +1 -1
- package/dist/documents/index.js +1 -1
- package/dist/documents/picker-api.test.js +2 -2
- package/dist/documents/types.d.ts +87 -14
- package/dist/documents/types.js +4 -0
- package/dist/host-entry.d.ts +1 -1
- package/dist/host-entry.js +1 -1
- package/dist/host.d.ts +1 -1
- package/dist/host.js +1 -1
- package/dist/layout/slotHostPool.svelte.js +2 -2
- package/dist/overlays/FloatFrame.svelte +1 -0
- package/dist/primitives/widgets/DocumentFilePicker.d.ts +6 -2
- package/dist/primitives/widgets/DocumentFilePicker.js +12 -5
- package/dist/primitives/widgets/DocumentFilePicker.svelte +23 -1
- 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/_DocumentBrowser.svelte +414 -27
- 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/session-state.svelte.d.ts +3 -0
- package/dist/projects/session-state.svelte.js +25 -0
- package/dist/projects/session-state.test.js +43 -2
- package/dist/projects-shard/ProjectsSection.svelte +14 -18
- package/dist/runtime/runVerb-shell.test.js +2 -2
- package/dist/runtime/runVerb.test.js +2 -2
- package/dist/sh3Api/headless.js +10 -0
- package/dist/sh3core-shard/appActions.js +5 -2
- package/dist/shards/activate-browse.test.js +2 -2
- package/dist/shards/activate-contributions.test.js +2 -2
- package/dist/shards/activate-error-isolation.test.js +3 -3
- package/dist/shards/activate-on-key-revoked.test.js +2 -2
- package/dist/shards/activate-runtime.test.js +2 -2
- package/dist/shards/activate.svelte.js +5 -5
- package/dist/shards/ctx-fetch.test.js +4 -4
- 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/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 +87 -0
- package/dist/shell-shard/verbs/xfer.test.d.ts +1 -0
- package/dist/shell-shard/verbs/xfer.test.js +107 -0
- 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,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`. */
|
package/dist/documents/browse.js
CHANGED
|
@@ -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
|
-
/**
|
|
8
|
-
export declare function
|
|
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;
|
package/dist/documents/config.js
CHANGED
|
@@ -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
|
-
/**
|
|
31
|
-
export function
|
|
32
|
-
return
|
|
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;
|
package/dist/documents/handle.js
CHANGED
|
@@ -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
|
}
|