sh3-core 0.20.2 → 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 (45) 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/browse.d.ts +20 -0
  11. package/dist/documents/browse.js +35 -0
  12. package/dist/documents/browse.test.js +125 -0
  13. package/dist/documents/config.d.ts +0 -4
  14. package/dist/documents/config.js +0 -8
  15. package/dist/documents/http-backend.d.ts +5 -0
  16. package/dist/documents/http-backend.js +25 -0
  17. package/dist/documents/http-backend.test.js +66 -0
  18. package/dist/documents/index.d.ts +1 -1
  19. package/dist/documents/index.js +1 -1
  20. package/dist/documents/types.d.ts +11 -0
  21. package/dist/host-entry.d.ts +1 -1
  22. package/dist/host-entry.js +1 -1
  23. package/dist/host.d.ts +1 -1
  24. package/dist/host.js +1 -1
  25. package/dist/layout/slotHostPool.svelte.js +2 -2
  26. package/dist/overlays/FloatFrame.svelte +1 -0
  27. package/dist/projects/session-state.svelte.d.ts +3 -0
  28. package/dist/projects/session-state.svelte.js +25 -0
  29. package/dist/projects/session-state.test.js +43 -2
  30. package/dist/projects-shard/ProjectsSection.svelte +14 -18
  31. package/dist/runtime/runVerb-shell.test.js +2 -2
  32. package/dist/runtime/runVerb.test.js +2 -2
  33. package/dist/sh3core-shard/appActions.js +5 -2
  34. package/dist/shards/activate-browse.test.js +2 -2
  35. package/dist/shards/activate-contributions.test.js +2 -2
  36. package/dist/shards/activate-error-isolation.test.js +3 -3
  37. package/dist/shards/activate-on-key-revoked.test.js +2 -2
  38. package/dist/shards/activate-runtime.test.js +2 -2
  39. package/dist/shards/activate.svelte.js +4 -4
  40. package/dist/shards/ctx-fetch.test.js +4 -4
  41. package/dist/shell-shard/verbs/xfer.js +13 -27
  42. package/dist/shell-shard/verbs/xfer.test.js +36 -25
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +1 -1
@@ -13,7 +13,7 @@
13
13
  import { getLiveDispatcherState } from './actions/state.svelte';
14
14
  import { launchApp, returnToHome } from './apps/lifecycle';
15
15
  import { getBreadcrumbAppId, getRegisteredApp } from './apps/registry.svelte';
16
- import { sessionState, setActiveProjectId } from './projects/session-state.svelte';
16
+ import { sessionState, switchProjectScope } from './projects/session-state.svelte';
17
17
  import { projectsState } from './projects-shard/projectsShard.svelte';
18
18
 
19
19
  const activeAppId = $derived(getLiveDispatcherState().activeAppId);
@@ -48,7 +48,7 @@
48
48
  }
49
49
 
50
50
  function exitProject() {
51
- setActiveProjectId(null);
51
+ switchProjectScope(null);
52
52
  }
53
53
 
54
54
  function reenterProjectHome() {
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { MemoryDocumentBackend } from '../documents/backends';
3
- import { __setDocumentBackend, __setTenantId } from '../documents/config';
3
+ import { __setDocumentBackend, __setActiveScope } from '../documents/config';
4
4
  import { registerShard, activateShard, __resetShardRegistryForTest, } from '../shards/activate.svelte';
5
5
  import { __resetViewRegistryForTest } from '../shards/registry';
6
6
  import { __resetActionsRegistryForTest } from './registry';
@@ -14,7 +14,7 @@ describe('ShardContext.listActions / runAction (integration)', () => {
14
14
  __resetActionsRegistryForTest();
15
15
  __resetDispatcherStateForTest();
16
16
  __setDocumentBackend(new MemoryDocumentBackend());
17
- __setTenantId('tenant-test');
17
+ __setActiveScope('tenant-test');
18
18
  });
19
19
  it('listActions enumerates actions registered by other shards', async () => {
20
20
  registerShard({
@@ -29,4 +29,6 @@ export interface ArtifactManifest {
29
29
  id: string;
30
30
  versionRange: string;
31
31
  }>;
32
+ /** Shard ids this app requires to be installed on the server. Written by sh3Artifact for app/combo bundles. */
33
+ requiredShards?: string[];
32
34
  }
@@ -8,10 +8,12 @@ export type SatellitePayload = {
8
8
  h: number;
9
9
  };
10
10
  activateShards: string[];
11
+ projectId?: string;
11
12
  } | {
12
13
  kind: 'app';
13
14
  appId: string;
14
15
  activateShards: string[];
16
+ projectId?: string;
15
17
  };
16
18
  export declare function encodePayload(payload: SatellitePayload): string;
17
19
  export declare function decodePayload(encoded: string): SatellitePayload;
@@ -36,6 +36,25 @@ describe('satellite payload encode/decode', () => {
36
36
  const bad = btoa(JSON.stringify({ kind: 'mystery' }));
37
37
  expect(() => decodePayload(bad)).toThrow();
38
38
  });
39
+ it('round-trips a float payload with projectId', () => {
40
+ const payload = {
41
+ kind: 'float',
42
+ content: { type: 'slot', slotId: 's:1', viewId: 'foo:bar' },
43
+ size: { w: 800, h: 600 },
44
+ activateShards: ['foo'],
45
+ projectId: 'proj-abc',
46
+ };
47
+ expect(decodePayload(encodePayload(payload))).toEqual(payload);
48
+ });
49
+ it('round-trips an app payload with projectId', () => {
50
+ const payload = {
51
+ kind: 'app',
52
+ appId: 'sh3-store',
53
+ activateShards: ['sh3-core'],
54
+ projectId: 'proj-xyz',
55
+ };
56
+ expect(decodePayload(encodePayload(payload))).toEqual(payload);
57
+ });
39
58
  it('produces a URL-safe encoding (no + / =)', () => {
40
59
  // A long-ish payload increases the chance the underlying base64
41
60
  // would contain non-URL-safe characters from the standard alphabet.
package/dist/build.d.ts CHANGED
@@ -70,10 +70,16 @@ export declare function composeArtifactVersion(pkgVersion: string, suffix: strin
70
70
  * After Vite's lib build completes, this plugin:
71
71
  * 1. Finds the entry chunk and renames it to client.js
72
72
  * 2. Optionally copies a pre-built server bundle as server.js
73
- * 3. Extracts manifest fields (id, label, type) from the source
73
+ * 3. Extracts manifest fields (id, label, type, requiredShards) from the source
74
74
  * 4. Reads `version` from `package.json.version` — the authoritative
75
75
  * source per ADR-013. A literal `version:` in a source manifest is
76
76
  * ignored; only `package.json.version` matters for the artifact.
77
77
  * 5. Writes manifest.json alongside the bundles
78
78
  */
79
+ /**
80
+ * Extract the requiredShards array from a bundled source string.
81
+ * Finds the first `requiredShards: [...]` array literal and parses its string IDs.
82
+ * Exported for testing; used internally by sh3Artifact.
83
+ */
84
+ export declare function extractRequiredShardsFromBundle(bundleSource: string): string[];
79
85
  export declare function sh3Artifact(options?: Sh3ArtifactOptions): Plugin;
package/dist/build.js CHANGED
@@ -136,12 +136,28 @@ function resolveAutoBuildSuffix() {
136
136
  * After Vite's lib build completes, this plugin:
137
137
  * 1. Finds the entry chunk and renames it to client.js
138
138
  * 2. Optionally copies a pre-built server bundle as server.js
139
- * 3. Extracts manifest fields (id, label, type) from the source
139
+ * 3. Extracts manifest fields (id, label, type, requiredShards) from the source
140
140
  * 4. Reads `version` from `package.json.version` — the authoritative
141
141
  * source per ADR-013. A literal `version:` in a source manifest is
142
142
  * ignored; only `package.json.version` matters for the artifact.
143
143
  * 5. Writes manifest.json alongside the bundles
144
144
  */
145
+ /**
146
+ * Extract the requiredShards array from a bundled source string.
147
+ * Finds the first `requiredShards: [...]` array literal and parses its string IDs.
148
+ * Exported for testing; used internally by sh3Artifact.
149
+ */
150
+ export function extractRequiredShardsFromBundle(bundleSource) {
151
+ const arrayMatch = bundleSource.match(/\brequiredShards\s*:\s*\[([^\]]*)\]/);
152
+ if (!arrayMatch)
153
+ return [];
154
+ const ids = [];
155
+ const idRe = /["']([^"']+)["']/g;
156
+ let m;
157
+ while ((m = idRe.exec(arrayMatch[1])) !== null)
158
+ ids.push(m[1]);
159
+ return ids;
160
+ }
145
161
  export function sh3Artifact(options = {}) {
146
162
  let outDir = '';
147
163
  let entryFileName = '';
@@ -238,14 +254,17 @@ export function sh3Artifact(options = {}) {
238
254
  const m = block.match(pattern);
239
255
  return m ? m[1] : '';
240
256
  };
257
+ // Extract the requiredShards string-id array from the app manifest block.
258
+ const requiredShards = extractRequiredShardsFromBundle(block);
241
259
  return {
242
260
  id: get(/\bid\s*:\s*["']([^"']+)["']/),
243
261
  label: get(/\blabel\s*:\s*["']([^"']+)["']/),
262
+ requiredShards,
244
263
  };
245
264
  }
246
265
  // App first, then Shard.
247
266
  const extracted = (_a = extractFromBlock(/\brequiredShards\s*:\s*\[/)) !== null && _a !== void 0 ? _a : extractFromBlock(/\bviews\s*:\s*\[/);
248
- const { id, label } = extracted;
267
+ const { id, label, requiredShards } = extracted;
249
268
  // --- Optional server bundle ---
250
269
  let hasServer = false;
251
270
  if (options.serverEntry && existsSync(options.serverEntry)) {
@@ -292,7 +311,7 @@ export function sh3Artifact(options = {}) {
292
311
  if (!finalAuthor) {
293
312
  throw new Error('[sh3-artifact] Missing "author". Add it to package.json or pass it via sh3Artifact({ manifest: { author } }).');
294
313
  }
295
- const manifest = Object.assign(Object.assign(Object.assign({ id: id || 'unknown', type, label: label || id || 'unknown', version: artifactVersion, contractVersion: 1, client: 'client.js' }, (hasServer ? { server: 'server.js' } : {})), { description: finalDescription, author: finalAuthor }), overrides);
314
+ const manifest = Object.assign(Object.assign(Object.assign(Object.assign({ id: id || 'unknown', type, label: label || id || 'unknown', version: artifactVersion, contractVersion: 1, client: 'client.js' }, (hasServer ? { server: 'server.js' } : {})), { description: finalDescription, author: finalAuthor }), ((type === 'app' || type === 'combo') ? { requiredShards } : {})), overrides);
296
315
  writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
297
316
  // --- Log summary ---
298
317
  const files = ['manifest.json', 'client.js'];
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { composeArtifactVersion } from './build';
2
+ import { composeArtifactVersion, extractRequiredShardsFromBundle } from './build';
3
3
  describe('composeArtifactVersion', () => {
4
4
  it('returns pkgVersion unchanged when suffix is undefined', () => {
5
5
  expect(composeArtifactVersion('1.2.3', undefined)).toBe('1.2.3');
@@ -29,3 +29,29 @@ describe('composeArtifactVersion', () => {
29
29
  expect(() => composeArtifactVersion('1.2.3', '+42')).toThrow(/not a valid semver build-metadata identifier/);
30
30
  });
31
31
  });
32
+ describe('extractRequiredShardsFromBundle', () => {
33
+ it('extracts a single shard id', () => {
34
+ const src = `const App = { manifest: { id: "my-app", requiredShards: ["guml.core"] } };`;
35
+ expect(extractRequiredShardsFromBundle(src)).toEqual(['guml.core']);
36
+ });
37
+ it('extracts multiple shard ids', () => {
38
+ const src = `requiredShards: ["guml.core", "sh3-file-explorer", "some-other"]`;
39
+ expect(extractRequiredShardsFromBundle(src)).toEqual(['guml.core', 'sh3-file-explorer', 'some-other']);
40
+ });
41
+ it('handles single-quoted ids', () => {
42
+ const src = `requiredShards: ['guml.core', 'sh3-files']`;
43
+ expect(extractRequiredShardsFromBundle(src)).toEqual(['guml.core', 'sh3-files']);
44
+ });
45
+ it('returns empty array when requiredShards is not present', () => {
46
+ const src = `const Shard = { manifest: { id: "my-shard", views: [] } };`;
47
+ expect(extractRequiredShardsFromBundle(src)).toEqual([]);
48
+ });
49
+ it('returns empty array when requiredShards is empty', () => {
50
+ const src = `requiredShards: []`;
51
+ expect(extractRequiredShardsFromBundle(src)).toEqual([]);
52
+ });
53
+ it('handles minified format without spaces', () => {
54
+ const src = `{id:"my-app",requiredShards:["guml.core","other-shard"]}`;
55
+ expect(extractRequiredShardsFromBundle(src)).toEqual(['guml.core', 'other-shard']);
56
+ });
57
+ });
@@ -22,11 +22,33 @@ import { registerLoadedBundle } from './registry/register';
22
22
  import { attachGlobalListeners } from './actions/listeners';
23
23
  import { detectSatelliteMode } from './boot/satelliteMode';
24
24
  import { MemoryBackend } from './state/backends';
25
- import { sessionState } from './projects/session-state.svelte';
25
+ import { sessionState, readPendingScope, PENDING_SCOPE_KEY } from './projects/session-state.svelte';
26
26
  import SatelliteShell from './satellite/SatelliteShell.svelte';
27
27
  export async function createShell(config) {
28
28
  var _a, _b;
29
29
  const sUrl = (_a = config === null || config === void 0 ? void 0 : config.serverUrl) !== null && _a !== void 0 ? _a : '';
30
+ // 0. Restore pending project scope from a reload-triggered scope switch.
31
+ // Written before the previous page reload by switchProjectScope(); must
32
+ // be set before bootstrap() so shards activate with the correct scope.
33
+ const pendingProjectId = readPendingScope();
34
+ if (pendingProjectId !== null) {
35
+ sessionState.activeProjectId = pendingProjectId;
36
+ }
37
+ // Read ?project= injected by Tauri's --project CLI arg (or any deep-link URL).
38
+ // Guard against overriding an in-session scope switch that wrote to sessionStorage.
39
+ if (typeof window !== 'undefined' && pendingProjectId === null) {
40
+ const urlProject = new URLSearchParams(window.location.search).get('project');
41
+ if (urlProject) {
42
+ // Mirror switchProjectScope's sessionStorage write so subsequent reloads
43
+ // within this tab re-enter the same project (readPendingScope will pick it up).
44
+ sessionStorage.setItem(PENDING_SCOPE_KEY, JSON.stringify({ projectId: urlProject }));
45
+ sessionState.activeProjectId = urlProject;
46
+ // Strip the param so it doesn't linger in bookmarks or history.
47
+ const cleanUrl = new URL(window.location.href);
48
+ cleanUrl.searchParams.delete('project');
49
+ history.replaceState(null, '', cleanUrl.toString());
50
+ }
51
+ }
30
52
  // 1. Platform detection
31
53
  const platform = await resolvePlatform();
32
54
  if (platform.backends) {
@@ -60,6 +82,11 @@ export async function createShell(config) {
60
82
  // but pop-out is currently a Tauri-only POC so we don't fetch it.
61
83
  if (platform.localOwner)
62
84
  __setActiveScope('local');
85
+ // Inherit the host's active project scope so shard activation lands in the
86
+ // correct tenant (satellite opens in the same project context as the host).
87
+ if (satellite.payload.projectId) {
88
+ sessionState.activeProjectId = satellite.payload.projectId;
89
+ }
63
90
  __setScopeResolver(() => sessionState.activeProjectId);
64
91
  __setShardScopeResolver(() => sessionState.activeProjectId ? 'project' : 'tenant');
65
92
  if (config === null || config === void 0 ? void 0 : config.shards)
@@ -138,19 +165,17 @@ export async function createShell(config) {
138
165
  for (const app of config.apps)
139
166
  registerApp(app);
140
167
  }
141
- // 7. Bootstrap
168
+ // 7. Wire scope resolvers before bootstrap so shards activate in the correct scope.
169
+ __setScopeResolver(() => sessionState.activeProjectId);
170
+ __setShardScopeResolver(() => sessionState.activeProjectId ? 'project' : 'tenant');
171
+ // 8. Bootstrap
142
172
  const bootstrapConfig = {};
143
173
  if (config === null || config === void 0 ? void 0 : config.excludeShards)
144
174
  bootstrapConfig.excludeShards = config.excludeShards;
145
175
  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');
151
- // 8. Attach document-level keyboard / focus listeners
176
+ // 9. Attach document-level keyboard / focus listeners
152
177
  attachGlobalListeners();
153
- // 9. Mount the sh3
178
+ // 10. Mount the sh3
154
179
  mount(Sh3, { target });
155
180
  }
156
181
  /**
@@ -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
  });
@@ -6,12 +6,8 @@ export declare function __setScopeResolver(resolver: (() => string | null) | nul
6
6
  export declare function getActiveScopeId(): string;
7
7
  /** The user's base (personal) tenant id — never overridden by the project resolver. */
8
8
  export declare function getPersonalScopeId(): string;
9
- /** @deprecated use getActiveScopeId — kept until callers migrate. */
10
- export declare function getTenantId(): string;
11
9
  export declare function getDocumentBackend(): DocumentBackend;
12
10
  /** Host-only. Set the active scope id before bootstrap(). */
13
11
  export declare function __setActiveScope(id: string): void;
14
- /** @deprecated use __setActiveScope — kept until callers migrate. */
15
- export declare function __setTenantId(id: string): void;
16
12
  /** Host-only. Swap the document backend before bootstrap(). */
17
13
  export declare function __setDocumentBackend(b: DocumentBackend): void;
@@ -31,10 +31,6 @@ export function getActiveScopeId() {
31
31
  export function getPersonalScopeId() {
32
32
  return scopeId;
33
33
  }
34
- /** @deprecated use getActiveScopeId — kept until callers migrate. */
35
- export function getTenantId() {
36
- return getActiveScopeId();
37
- }
38
34
  export function getDocumentBackend() {
39
35
  return backend;
40
36
  }
@@ -42,10 +38,6 @@ export function getDocumentBackend() {
42
38
  export function __setActiveScope(id) {
43
39
  scopeId = id;
44
40
  }
45
- /** @deprecated use __setActiveScope — kept until callers migrate. */
46
- export function __setTenantId(id) {
47
- __setActiveScope(id);
48
- }
49
41
  /** Host-only. Swap the document backend before bootstrap(). */
50
42
  export function __setDocumentBackend(b) {
51
43
  backend = b;
@@ -31,6 +31,11 @@ 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>;
35
40
  mkdir(tenantId: string, shardId: string, path: string): Promise<void>;
36
41
  rmdir(tenantId: string, shardId: string, path: string, opts: {
@@ -128,6 +128,31 @@ export class HttpDocumentBackend {
128
128
  throw new Error(`readBranch failed: ${res.status}`);
129
129
  return res.text();
130
130
  }
131
+ async xfer(srcTenant, srcPath, dstTenant, dstPath, opts) {
132
+ var _a;
133
+ const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/xfer`;
134
+ const res = await apiFetch(url, {
135
+ method: 'POST',
136
+ headers: Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': 'application/json' }),
137
+ body: JSON.stringify({
138
+ src: { scope: srcTenant, path: srcPath },
139
+ dst: { scope: dstTenant, path: dstPath },
140
+ move: (_a = opts === null || opts === void 0 ? void 0 : opts.move) !== null && _a !== void 0 ? _a : false,
141
+ }),
142
+ credentials: 'include',
143
+ });
144
+ if (!res.ok) {
145
+ let detail = `HTTP ${res.status}`;
146
+ try {
147
+ const b = await res.json();
148
+ if (b.error)
149
+ detail = b.error;
150
+ }
151
+ catch ( /* not JSON */_b) { /* not JSON */ }
152
+ throw new Error(`xfer failed: ${detail}`);
153
+ }
154
+ return res.json();
155
+ }
131
156
  async rename(tenantId, shardId, oldPath, newPath) {
132
157
  const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${oldPath}/rename`;
133
158
  const headers = Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': 'application/json' });
@@ -129,3 +129,69 @@ describe('HttpDocumentBackend folder ops', () => {
129
129
  expect(result).toEqual(['c']);
130
130
  });
131
131
  });
132
+ describe('HttpDocumentBackend.xfer', () => {
133
+ afterEach(() => {
134
+ globalThis.fetch = originalFetch;
135
+ });
136
+ it('POSTs to /api/xfer with src, dst, and move in the body', async () => {
137
+ var _a, _b;
138
+ const calls = [];
139
+ globalThis.fetch = (async (url, init) => {
140
+ calls.push({ url: String(url), init });
141
+ return new Response(JSON.stringify({ ok: true, existed: false }), { status: 200 });
142
+ });
143
+ const be = new HttpDocumentBackend('http://server', 'apikey-1');
144
+ const result = await be.xfer('alice', 'notes/draft.md', 'proj-1', 'notes/draft.md', { move: true });
145
+ expect(calls).toHaveLength(1);
146
+ expect(calls[0].url).toBe('http://server/api/xfer');
147
+ expect((_a = calls[0].init) === null || _a === void 0 ? void 0 : _a.method).toBe('POST');
148
+ const body = JSON.parse((_b = calls[0].init) === null || _b === void 0 ? void 0 : _b.body);
149
+ expect(body).toEqual({
150
+ src: { scope: 'alice', path: 'notes/draft.md' },
151
+ dst: { scope: 'proj-1', path: 'notes/draft.md' },
152
+ move: true,
153
+ });
154
+ expect(result).toEqual({ ok: true, existed: false });
155
+ });
156
+ it('sends move=false when opts.move is false', async () => {
157
+ var _a;
158
+ const calls = [];
159
+ globalThis.fetch = (async (url, init) => {
160
+ calls.push({ url: String(url), init });
161
+ return new Response(JSON.stringify({ ok: true, existed: true }), { status: 200 });
162
+ });
163
+ const be = new HttpDocumentBackend('http://server');
164
+ await be.xfer('alice', 'notes/a.md', 'proj-1', 'notes/a.md', { move: false });
165
+ const body = JSON.parse((_a = calls[0].init) === null || _a === void 0 ? void 0 : _a.body);
166
+ expect(body.move).toBe(false);
167
+ });
168
+ it('sends move=false when opts is omitted', async () => {
169
+ var _a;
170
+ const calls = [];
171
+ globalThis.fetch = (async (url, init) => {
172
+ calls.push({ url: String(url), init });
173
+ return new Response(JSON.stringify({ ok: true, existed: false }), { status: 200 });
174
+ });
175
+ const be = new HttpDocumentBackend('http://server');
176
+ await be.xfer('alice', 'notes/a.md', 'proj-1', 'notes/a.md');
177
+ const body = JSON.parse((_a = calls[0].init) === null || _a === void 0 ? void 0 : _a.body);
178
+ expect(body.move).toBe(false);
179
+ });
180
+ it('includes Authorization header when apiKey is set', async () => {
181
+ var _a;
182
+ const calls = [];
183
+ globalThis.fetch = (async (url, init) => {
184
+ calls.push({ url: String(url), init });
185
+ return new Response(JSON.stringify({ ok: true, existed: false }), { status: 200 });
186
+ });
187
+ const be = new HttpDocumentBackend('http://server', 'my-key');
188
+ await be.xfer('alice', 'notes/a.md', 'proj-1', 'notes/a.md');
189
+ const headers = (_a = calls[0].init) === null || _a === void 0 ? void 0 : _a.headers;
190
+ expect(headers['Authorization']).toBe('Bearer my-key');
191
+ });
192
+ it('throws with server error message on non-ok response', async () => {
193
+ globalThis.fetch = (async () => new Response(JSON.stringify({ error: 'Source document not found' }), { status: 404 }));
194
+ const be = new HttpDocumentBackend('http://server');
195
+ await expect(be.xfer('alice', 'notes/missing.md', 'proj-1', 'notes/missing.md')).rejects.toThrow('Source document not found');
196
+ });
197
+ });
@@ -3,6 +3,6 @@ export { MemoryDocumentBackend, IndexedDBDocumentBackend } from './backends';
3
3
  export { HttpDocumentBackend } from './http-backend';
4
4
  export { createDocumentHandle } from './handle';
5
5
  export { documentChanges } from './notifications';
6
- export { getActiveScopeId, getTenantId, getDocumentBackend, __setActiveScope, __setTenantId, __setDocumentBackend, __setScopeResolver, } from './config';
6
+ export { getActiveScopeId, getDocumentBackend, __setActiveScope, __setDocumentBackend, __setScopeResolver, } from './config';
7
7
  export type { SyncPolicy, SyncPolicyRule, DocStatus, ConflictFile, ConflictBranch, } from './sync-types';
8
8
  export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY, } from './sync-types';