sh3-core 0.11.4 → 0.11.7

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 (90) hide show
  1. package/dist/BrandSlot.svelte +80 -0
  2. package/dist/BrandSlot.svelte.d.ts +3 -0
  3. package/dist/BrandSlot.test.d.ts +1 -0
  4. package/dist/BrandSlot.test.js +71 -0
  5. package/dist/Shell.svelte +8 -10
  6. package/dist/actions/ActionPanel.svelte +143 -0
  7. package/dist/actions/ActionPanel.svelte.d.ts +13 -0
  8. package/dist/actions/ActionPanel.test.d.ts +1 -0
  9. package/dist/actions/ActionPanel.test.js +168 -0
  10. package/dist/actions/ContextMenu.svelte +17 -85
  11. package/dist/actions/MenuBar.svelte +57 -0
  12. package/dist/actions/MenuBar.svelte.d.ts +3 -0
  13. package/dist/actions/MenuBar.test.d.ts +1 -0
  14. package/dist/actions/MenuBar.test.js +109 -0
  15. package/dist/actions/MenuButton.svelte +150 -0
  16. package/dist/actions/MenuButton.svelte.d.ts +10 -0
  17. package/dist/actions/MenuButton.test.d.ts +1 -0
  18. package/dist/actions/MenuButton.test.js +125 -0
  19. package/dist/actions/contextMenuModel.d.ts +10 -0
  20. package/dist/actions/contextMenuModel.js +44 -9
  21. package/dist/actions/contextMenuModel.test.js +28 -1
  22. package/dist/actions/defaultMenuContainers.d.ts +2 -0
  23. package/dist/actions/defaultMenuContainers.js +7 -0
  24. package/dist/actions/defaultMenuContainers.test.d.ts +1 -0
  25. package/dist/actions/defaultMenuContainers.test.js +23 -0
  26. package/dist/actions/listeners.d.ts +4 -0
  27. package/dist/actions/listeners.js +77 -17
  28. package/dist/actions/listeners.test.js +50 -0
  29. package/dist/actions/menuBarModel.d.ts +42 -0
  30. package/dist/actions/menuBarModel.js +110 -0
  31. package/dist/actions/menuBarModel.test.d.ts +1 -0
  32. package/dist/actions/menuBarModel.test.js +158 -0
  33. package/dist/actions/palette-scorer.d.ts +4 -0
  34. package/dist/actions/palette-scorer.js +5 -0
  35. package/dist/actions/palette-scorer.test.js +9 -1
  36. package/dist/actions/paletteModel.d.ts +7 -1
  37. package/dist/actions/paletteModel.js +26 -1
  38. package/dist/actions/paletteModel.test.js +43 -0
  39. package/dist/actions/registry.js +5 -0
  40. package/dist/actions/registry.test.js +12 -0
  41. package/dist/actions/types.d.ts +48 -1
  42. package/dist/actions/types.test.d.ts +1 -0
  43. package/dist/actions/types.test.js +31 -0
  44. package/dist/apps/lifecycle.js +8 -1
  45. package/dist/apps/lifecycle.test.js +211 -1
  46. package/dist/apps/registry.svelte.d.ts +17 -1
  47. package/dist/apps/registry.svelte.js +20 -1
  48. package/dist/apps/types.d.ts +28 -0
  49. package/dist/assets/icons.svg +5 -0
  50. package/dist/documents/backends.d.ts +2 -0
  51. package/dist/documents/backends.js +55 -0
  52. package/dist/documents/backends.test.d.ts +1 -1
  53. package/dist/documents/backends.test.js +69 -1
  54. package/dist/documents/browse.d.ts +18 -0
  55. package/dist/documents/browse.js +13 -0
  56. package/dist/documents/browse.test.js +47 -0
  57. package/dist/documents/handle.js +23 -0
  58. package/dist/documents/handle.test.js +51 -0
  59. package/dist/documents/http-backend.d.ts +1 -0
  60. package/dist/documents/http-backend.js +19 -0
  61. package/dist/documents/http-backend.test.js +42 -0
  62. package/dist/documents/types.d.ts +29 -1
  63. package/dist/documents/types.js +4 -0
  64. package/dist/documents/types.test.d.ts +1 -0
  65. package/dist/documents/types.test.js +20 -0
  66. package/dist/layout/LayoutRenderer.browser.test.js +196 -0
  67. package/dist/layout/SlotContainer.svelte +13 -8
  68. package/dist/layout/SlotDropZone.svelte +44 -9
  69. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-7-fixed-slot-drop-protection-still-accepts-a-strip-drop-into-a-fixed-tabs-node-1.png +0 -0
  70. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-8-same-strip-reorder-keeps-the-active-pane-populated-after-moving-the-second-tab-to-first-1.png +0 -0
  71. package/dist/layout/ops.d.ts +10 -0
  72. package/dist/layout/ops.js +30 -2
  73. package/dist/layout/ops.test.js +111 -1
  74. package/dist/layout/slotHostPool.svelte.d.ts +7 -1
  75. package/dist/layout/slotHostPool.svelte.js +27 -8
  76. package/dist/layout/store.svelte.d.ts +27 -0
  77. package/dist/layout/store.svelte.js +63 -0
  78. package/dist/overlays/ConfirmDialog.svelte +138 -0
  79. package/dist/overlays/ConfirmDialog.svelte.d.ts +13 -0
  80. package/dist/overlays/ConfirmDialog.test.d.ts +1 -0
  81. package/dist/overlays/ConfirmDialog.test.js +123 -0
  82. package/dist/overlays/FloatFrame.svelte +2 -2
  83. package/dist/overlays/ToastItem.svelte +3 -3
  84. package/dist/primitives/base.css +5 -5
  85. package/dist/sh3core-shard/sh3coreShard.svelte.js +38 -4
  86. package/dist/shell-shard/shellShard.svelte.js +0 -4
  87. package/dist/tokens.css +1 -1
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +2 -1
@@ -1,5 +1,6 @@
1
+ import 'fake-indexeddb/auto';
1
2
  import { describe, it, expect } from 'vitest';
2
- import { MemoryDocumentBackend } from './backends';
3
+ import { MemoryDocumentBackend, IndexedDBDocumentBackend } from './backends';
3
4
  describe('DocumentBackend tenant-wide primitives', () => {
4
5
  it('listAllShards returns every shard that has content for a tenant', async () => {
5
6
  const be = new MemoryDocumentBackend();
@@ -31,3 +32,70 @@ describe('DocumentBackend tenant-wide primitives', () => {
31
32
  expect(await be.listAllShards('ghost')).toEqual([]);
32
33
  });
33
34
  });
35
+ describe('MemoryDocumentBackend.rename', () => {
36
+ it('moves content from oldPath to newPath', async () => {
37
+ const be = new MemoryDocumentBackend();
38
+ await be.write('t1', 's1', 'old.txt', 'hello');
39
+ await be.rename('t1', 's1', 'old.txt', 'new.txt');
40
+ expect(await be.read('t1', 's1', 'new.txt')).toBe('hello');
41
+ expect(await be.read('t1', 's1', 'old.txt')).toBeNull();
42
+ expect(await be.exists('t1', 's1', 'new.txt')).toBe(true);
43
+ expect(await be.exists('t1', 's1', 'old.txt')).toBe(false);
44
+ });
45
+ it('preserves size and lastModified semantics on the new key', async () => {
46
+ const be = new MemoryDocumentBackend();
47
+ await be.write('t1', 's1', 'old.txt', 'hello');
48
+ const before = await be.list('t1', 's1');
49
+ await be.rename('t1', 's1', 'old.txt', 'new.txt');
50
+ const after = await be.list('t1', 's1');
51
+ expect(after).toHaveLength(1);
52
+ expect(after[0].path).toBe('new.txt');
53
+ expect(after[0].size).toBe(before[0].size);
54
+ });
55
+ it('throws when newPath already exists; original content unchanged', async () => {
56
+ const be = new MemoryDocumentBackend();
57
+ await be.write('t1', 's1', 'old.txt', 'src');
58
+ await be.write('t1', 's1', 'new.txt', 'target');
59
+ await expect(be.rename('t1', 's1', 'old.txt', 'new.txt'))
60
+ .rejects.toThrow(/already exists/);
61
+ expect(await be.read('t1', 's1', 'new.txt')).toBe('target');
62
+ expect(await be.read('t1', 's1', 'old.txt')).toBe('src');
63
+ });
64
+ it('throws when oldPath does not exist', async () => {
65
+ const be = new MemoryDocumentBackend();
66
+ await expect(be.rename('t1', 's1', 'ghost.txt', 'new.txt'))
67
+ .rejects.toThrow(/not found/);
68
+ });
69
+ it('respects tenant + shard isolation', async () => {
70
+ const be = new MemoryDocumentBackend();
71
+ await be.write('t1', 's1', 'a.txt', 'one');
72
+ await be.write('t2', 's1', 'a.txt', 'two');
73
+ await be.rename('t1', 's1', 'a.txt', 'b.txt');
74
+ expect(await be.read('t1', 's1', 'b.txt')).toBe('one');
75
+ expect(await be.read('t2', 's1', 'a.txt')).toBe('two');
76
+ expect(await be.read('t2', 's1', 'b.txt')).toBeNull();
77
+ });
78
+ });
79
+ describe('IndexedDBDocumentBackend.rename', () => {
80
+ it('moves content from oldPath to newPath atomically', async () => {
81
+ const be = new IndexedDBDocumentBackend();
82
+ await be.write('t1', 's1', 'old.txt', 'hello');
83
+ await be.rename('t1', 's1', 'old.txt', 'new.txt');
84
+ expect(await be.read('t1', 's1', 'new.txt')).toBe('hello');
85
+ expect(await be.read('t1', 's1', 'old.txt')).toBeNull();
86
+ });
87
+ it('throws when newPath already exists; original content unchanged', async () => {
88
+ const be = new IndexedDBDocumentBackend();
89
+ await be.write('t1', 's1', 'old.txt', 'src');
90
+ await be.write('t1', 's1', 'new.txt', 'target');
91
+ await expect(be.rename('t1', 's1', 'old.txt', 'new.txt'))
92
+ .rejects.toThrow(/already exists/);
93
+ expect(await be.read('t1', 's1', 'new.txt')).toBe('target');
94
+ expect(await be.read('t1', 's1', 'old.txt')).toBe('src');
95
+ });
96
+ it('throws when oldPath does not exist', async () => {
97
+ const be = new IndexedDBDocumentBackend();
98
+ await expect(be.rename('t1', 's1', 'ghost.txt', 'new.txt'))
99
+ .rejects.toThrow(/not found/);
100
+ });
101
+ });
@@ -58,6 +58,24 @@ export interface BrowseCapability {
58
58
  resolveConflictFrom?(shardId: string, path: string, choice: 'local' | 'remote' | {
59
59
  origin: string;
60
60
  } | string): Promise<void>;
61
+ /**
62
+ * Rename a document in another shard's namespace within the active
63
+ * tenant. Available only when the caller declares both
64
+ * `documents:browse` and `documents:write`. Emits a `'rename'`
65
+ * `DocumentChange` so other shards and the file-explorer pick up
66
+ * the move. Tenant-scoped — cannot cross tenants.
67
+ *
68
+ * The optional `opts.newShardId` parameter is reserved for a future
69
+ * cross-shard move operation per ADR-018 amendment 2026-04-27 and
70
+ * throws at runtime if provided.
71
+ *
72
+ * Absent (undefined) on the capability object when `documents:write`
73
+ * is not declared; feature-detect with
74
+ * `typeof ctx.browse.renameFrom === 'function'`.
75
+ */
76
+ renameFrom?(shardId: string, oldPath: string, newPath: string, opts?: {
77
+ newShardId?: string;
78
+ }): Promise<void>;
61
79
  }
62
80
  export interface BrowseCapabilityOptions {
63
81
  /** When true, the returned capability exposes `readFrom`. */
@@ -46,6 +46,19 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
46
46
  await backend.resolve(tenantId, shardId, path, choice);
47
47
  documentChanges.emit({ type: 'update', path, tenantId, shardId });
48
48
  };
49
+ capability.renameFrom = async (shardId, oldPath, newPath, opts) => {
50
+ if ((opts === null || opts === void 0 ? void 0 : opts.newShardId) !== undefined) {
51
+ throw new Error('Cross-shard move is not yet supported (ADR-018 amendment 2026-04-27)');
52
+ }
53
+ await backend.rename(tenantId, shardId, oldPath, newPath);
54
+ documentChanges.emit({
55
+ type: 'rename',
56
+ path: newPath,
57
+ oldPath,
58
+ tenantId,
59
+ shardId,
60
+ });
61
+ };
49
62
  }
50
63
  return capability;
51
64
  }
@@ -176,6 +176,53 @@ describe('BrowseCapability', () => {
176
176
  expect(await browse.readBranchFrom('other', 'a.txt', 'peer-1')).toBeNull();
177
177
  });
178
178
  });
179
+ describe('renameFrom (documents:write gate)', () => {
180
+ it('absent when canWrite is false', () => {
181
+ const be = new MemoryDocumentBackend();
182
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
183
+ expect(browse.renameFrom).toBeUndefined();
184
+ });
185
+ it('present when canWrite is true', () => {
186
+ const be = new MemoryDocumentBackend();
187
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
188
+ expect(typeof browse.renameFrom).toBe('function');
189
+ });
190
+ it('renames in the target shard namespace and emits a rename event', async () => {
191
+ const be = new MemoryDocumentBackend();
192
+ await be.write('t1', 'target-shard', 'old.txt', 'hello');
193
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
194
+ const events = [];
195
+ const unsub = documentChanges.subscribe((c) => events.push(c));
196
+ await browse.renameFrom('target-shard', 'old.txt', 'new.txt');
197
+ expect(await be.read('t1', 'target-shard', 'new.txt')).toBe('hello');
198
+ expect(await be.read('t1', 'target-shard', 'old.txt')).toBeNull();
199
+ expect(events).toEqual([
200
+ {
201
+ type: 'rename',
202
+ path: 'new.txt',
203
+ oldPath: 'old.txt',
204
+ tenantId: 't1',
205
+ shardId: 'target-shard',
206
+ },
207
+ ]);
208
+ unsub();
209
+ });
210
+ it('never crosses tenants: cannot be tricked into renaming in tenant b', async () => {
211
+ const be = new MemoryDocumentBackend();
212
+ await be.write('t2', 's', 'old.txt', 'hidden');
213
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
214
+ await expect(browse.renameFrom('s', 'old.txt', 'new.txt'))
215
+ .rejects.toThrow(/not found/);
216
+ expect(await be.read('t2', 's', 'old.txt')).toBe('hidden');
217
+ });
218
+ it('throws when opts.newShardId is provided (cross-shard move reserved)', async () => {
219
+ const be = new MemoryDocumentBackend();
220
+ await be.write('t1', 's1', 'old.txt', 'hello');
221
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
222
+ await expect(browse.renameFrom('s1', 'old.txt', 'new.txt', { newShardId: 's2' })).rejects.toThrow(/Cross-shard move is not yet supported/);
223
+ expect(await be.read('t1', 's1', 'old.txt')).toBe('hello');
224
+ });
225
+ });
179
226
  describe('resolveConflictFrom (documents:write gate)', () => {
180
227
  it('absent when canWrite is false', () => {
181
228
  const be = new MemoryDocumentBackend();
@@ -58,6 +58,24 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
58
58
  await backend.delete(tenantId, shardId, path);
59
59
  emitChange('delete', path);
60
60
  },
61
+ async rename(oldPath, newPath) {
62
+ if (!matchesExtensions(newPath)) {
63
+ throw new Error(`Cannot rename to ${newPath}: violates handle extensions filter`);
64
+ }
65
+ for (const ctrl of controllers) {
66
+ if (ctrl.path === oldPath) {
67
+ throw new Error(`Cannot rename: active autosave on ${oldPath}; flush and dispose first`);
68
+ }
69
+ }
70
+ await backend.rename(tenantId, shardId, oldPath, newPath);
71
+ documentChanges.emit({
72
+ type: 'rename',
73
+ path: newPath,
74
+ oldPath,
75
+ tenantId,
76
+ shardId,
77
+ });
78
+ },
61
79
  async exists(path) {
62
80
  return backend.exists(tenantId, shardId, path);
63
81
  },
@@ -135,6 +153,11 @@ class AutosaveControllerImpl {
135
153
  get dirty() {
136
154
  return __classPrivateFieldGet(this, _AutosaveControllerImpl_dirty, "f");
137
155
  }
156
+ /** Path this controller writes to. Exposed so DocumentHandle.rename
157
+ * can refuse if a controller is active on the rename source. */
158
+ get path() {
159
+ return __classPrivateFieldGet(this, _AutosaveControllerImpl_path, "f");
160
+ }
138
161
  update(content) {
139
162
  if (__classPrivateFieldGet(this, _AutosaveControllerImpl_disposed, "f"))
140
163
  return;
@@ -22,6 +22,7 @@ describe('DocumentHandle.status()', () => {
22
22
  async read() { return null; },
23
23
  async write() { },
24
24
  async delete() { },
25
+ async rename() { },
25
26
  async list() { return []; },
26
27
  async exists() { return false; },
27
28
  async listAllShards() { return []; },
@@ -38,6 +39,7 @@ describe('DocumentHandle.resolveConflict()', () => {
38
39
  async read() { return null; },
39
40
  async write() { },
40
41
  async delete() { },
42
+ async rename() { },
41
43
  async list() { return []; },
42
44
  async exists() { return false; },
43
45
  async listAllShards() { return []; },
@@ -60,6 +62,7 @@ describe('DocumentHandle.readBranch()', () => {
60
62
  async read() { return null; },
61
63
  async write() { },
62
64
  async delete() { },
65
+ async rename() { },
63
66
  async list() { return []; },
64
67
  async exists() { return false; },
65
68
  async listAllShards() { return []; },
@@ -76,6 +79,7 @@ describe('DocumentHandle.readBranch()', () => {
76
79
  async read() { return null; },
77
80
  async write() { },
78
81
  async delete() { },
82
+ async rename() { },
79
83
  async list() { return []; },
80
84
  async exists() { return false; },
81
85
  async listAllShards() { return []; },
@@ -90,3 +94,50 @@ describe('DocumentHandle.readBranch()', () => {
90
94
  await expect(handle.readBranch('a.txt', 'peer-1')).rejects.toThrow(/readBranch/);
91
95
  });
92
96
  });
97
+ describe('DocumentHandle.rename', () => {
98
+ it('moves the doc and emits one rename event', async () => {
99
+ const { backend, handle } = harness();
100
+ await handle.write('old.txt', 'hello');
101
+ const events = [];
102
+ const unsub = (await import('./notifications')).documentChanges.subscribe((c) => events.push(c));
103
+ await handle.rename('old.txt', 'new.txt');
104
+ expect(await backend.read('tenant1', 'shard1', 'new.txt')).toBe('hello');
105
+ expect(await backend.read('tenant1', 'shard1', 'old.txt')).toBeNull();
106
+ expect(events.at(-1)).toEqual({
107
+ type: 'rename',
108
+ path: 'new.txt',
109
+ oldPath: 'old.txt',
110
+ tenantId: 'tenant1',
111
+ shardId: 'shard1',
112
+ });
113
+ unsub();
114
+ });
115
+ it('throws when an autosave controller is active on oldPath', async () => {
116
+ const { handle } = harness();
117
+ await handle.write('old.txt', 'v1');
118
+ const ctrl = handle.autosave('old.txt', { debounceMs: 10000 });
119
+ ctrl.update('v2');
120
+ await expect(handle.rename('old.txt', 'new.txt'))
121
+ .rejects.toThrow(/active autosave/);
122
+ await ctrl.dispose();
123
+ });
124
+ it('does not throw when an autosave controller exists for an unrelated path', async () => {
125
+ const { handle } = harness();
126
+ await handle.write('old.txt', 'src');
127
+ await handle.write('other.txt', 'unrelated');
128
+ const ctrl = handle.autosave('other.txt', { debounceMs: 10000 });
129
+ ctrl.update('still-unrelated');
130
+ await expect(handle.rename('old.txt', 'new.txt')).resolves.toBeUndefined();
131
+ await ctrl.dispose();
132
+ });
133
+ it('throws when newPath violates the handle extensions filter', async () => {
134
+ const backend = new MemoryDocumentBackend();
135
+ const handle = createDocumentHandle('t1', 's1', backend, {
136
+ format: 'text',
137
+ extensions: ['.txt'],
138
+ });
139
+ await handle.write('a.txt', 'hi');
140
+ await expect(handle.rename('a.txt', 'a.md'))
141
+ .rejects.toThrow(/extensions/);
142
+ });
143
+ });
@@ -31,4 +31,5 @@ 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
+ rename(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
34
35
  }
@@ -119,6 +119,25 @@ export class HttpDocumentBackend {
119
119
  throw new Error(`readBranch failed: ${res.status}`);
120
120
  return res.text();
121
121
  }
122
+ async rename(tenantId, shardId, oldPath, newPath) {
123
+ const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${oldPath}/rename`;
124
+ const headers = Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': 'application/json' });
125
+ const res = await fetch(url, {
126
+ method: 'POST',
127
+ headers,
128
+ body: JSON.stringify({ to: newPath }),
129
+ credentials: 'include',
130
+ });
131
+ if (res.status === 404) {
132
+ throw new Error(`Document not found at ${oldPath}`);
133
+ }
134
+ if (res.status === 409) {
135
+ throw new Error(`Document already exists at ${newPath}`);
136
+ }
137
+ if (!res.ok) {
138
+ throw new Error(`Document rename failed: ${res.status}`);
139
+ }
140
+ }
122
141
  }
123
142
  _HttpDocumentBackend_baseUrl = new WeakMap(), _HttpDocumentBackend_apiKey = new WeakMap(), _HttpDocumentBackend_instances = new WeakSet(), _HttpDocumentBackend_authHeaders = function _HttpDocumentBackend_authHeaders() {
124
143
  if (!__classPrivateFieldGet(this, _HttpDocumentBackend_apiKey, "f"))
@@ -37,3 +37,45 @@ describe('HttpDocumentBackend.readBranch', () => {
37
37
  expect(calls[0]).toContain('origin=peer%20with%20space');
38
38
  });
39
39
  });
40
+ describe('HttpDocumentBackend.rename', () => {
41
+ afterEach(() => {
42
+ globalThis.fetch = originalFetch;
43
+ });
44
+ it('POSTs to /api/docs/.../rename with { to } body and Authorization header', async () => {
45
+ var _a, _b, _c;
46
+ const calls = [];
47
+ globalThis.fetch = (async (url, init) => {
48
+ calls.push({ url: String(url), init });
49
+ return new Response(JSON.stringify({ ok: true, version: 2 }), { status: 200 });
50
+ });
51
+ const be = new HttpDocumentBackend('http://x', 'apikey-1');
52
+ await be.rename('t1', 'shard', 'old.txt', 'new.txt');
53
+ expect(calls).toHaveLength(1);
54
+ expect(calls[0].url).toBe('http://x/api/docs/t1/shard/old.txt/rename');
55
+ expect((_a = calls[0].init) === null || _a === void 0 ? void 0 : _a.method).toBe('POST');
56
+ const headers = (_b = calls[0].init) === null || _b === void 0 ? void 0 : _b.headers;
57
+ expect(headers === null || headers === void 0 ? void 0 : headers['Authorization']).toBe('Bearer apikey-1');
58
+ expect(headers === null || headers === void 0 ? void 0 : headers['Content-Type']).toBe('application/json');
59
+ expect(JSON.parse(String((_c = calls[0].init) === null || _c === void 0 ? void 0 : _c.body))).toEqual({ to: 'new.txt' });
60
+ });
61
+ it('throws "not found" on 404', async () => {
62
+ globalThis.fetch = (async () => new Response(null, { status: 404 }));
63
+ const be = new HttpDocumentBackend('http://x', 'apikey-1');
64
+ await expect(be.rename('t1', 'shard', 'ghost.txt', 'new.txt'))
65
+ .rejects.toThrow(/not found/);
66
+ });
67
+ it('throws "already exists" on 409', async () => {
68
+ globalThis.fetch = (async () => new Response(JSON.stringify({ error: 'Document already exists at new.txt' }), {
69
+ status: 409,
70
+ }));
71
+ const be = new HttpDocumentBackend('http://x', 'apikey-1');
72
+ await expect(be.rename('t1', 'shard', 'old.txt', 'new.txt'))
73
+ .rejects.toThrow(/already exists/);
74
+ });
75
+ it('throws on other non-2xx errors', async () => {
76
+ globalThis.fetch = (async () => new Response('boom', { status: 500 }));
77
+ const be = new HttpDocumentBackend('http://x', 'apikey-1');
78
+ await expect(be.rename('t1', 'shard', 'old.txt', 'new.txt'))
79
+ .rejects.toThrow(/rename failed/i);
80
+ });
81
+ });
@@ -72,11 +72,25 @@ export interface DocumentMeta {
72
72
  }
73
73
  /** Change notification payload delivered to watch callbacks. */
74
74
  export interface DocumentChange {
75
- type: 'create' | 'update' | 'delete';
75
+ type: 'create' | 'update' | 'delete' | 'rename';
76
+ /**
77
+ * For 'create' / 'update' / 'delete', the affected document path.
78
+ * For 'rename', the new path the document now lives at.
79
+ */
76
80
  path: string;
81
+ /**
82
+ * Populated only when type === 'rename'. The path the document
83
+ * used to live at before the rename.
84
+ */
85
+ oldPath?: string;
77
86
  tenantId: string;
78
87
  shardId: string;
79
88
  }
89
+ /** Type guard: narrows a DocumentChange to the rename variant. */
90
+ export declare function isRename(change: DocumentChange): change is DocumentChange & {
91
+ type: 'rename';
92
+ oldPath: string;
93
+ };
80
94
  import type { DocStatus } from './sync-types';
81
95
  /**
82
96
  * File-oriented backend for the document zone.
@@ -97,6 +111,13 @@ export interface DocumentBackend {
97
111
  write(tenantId: string, shardId: string, path: string, content: string | ArrayBuffer): Promise<void>;
98
112
  /** Delete a document. No-op if the document does not exist. */
99
113
  delete(tenantId: string, shardId: string, path: string): Promise<void>;
114
+ /**
115
+ * Rename a document within a single shard's namespace. Atomic at the
116
+ * backend's storage tier. Throws if newPath already exists or if oldPath
117
+ * does not. Single-document only — does not interpret trailing slashes
118
+ * as folder semantics.
119
+ */
120
+ rename(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
100
121
  /** List all documents stored for this tenant + shard combination. */
101
122
  list(tenantId: string, shardId: string): Promise<DocumentMeta[]>;
102
123
  /** Return true if the document at `path` exists. */
@@ -147,6 +168,13 @@ export interface DocumentHandle {
147
168
  write(path: string, content: string): Promise<void>;
148
169
  /** Delete a document. */
149
170
  delete(path: string): Promise<void>;
171
+ /**
172
+ * Rename a document. Throws if there is an active autosave controller
173
+ * for oldPath (caller must flush and dispose first). Throws if newPath
174
+ * already exists or if oldPath does not. Subject to the handle's
175
+ * extensions filter — newPath must satisfy the filter.
176
+ */
177
+ rename(oldPath: string, newPath: string): Promise<void>;
150
178
  /** Check existence without reading content. */
151
179
  exists(path: string): Promise<boolean>;
152
180
  /** Fetch sync-state metadata for a path. Null if the doc does not exist. */
@@ -45,3 +45,7 @@ export const PERMISSION_DOCUMENTS_READ = 'documents:read';
45
45
  * `browse`.
46
46
  */
47
47
  export const PERMISSION_DOCUMENTS_WRITE = 'documents:write';
48
+ /** Type guard: narrows a DocumentChange to the rename variant. */
49
+ export function isRename(change) {
50
+ return change.type === 'rename';
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isRename } from './types';
3
+ describe('isRename type guard', () => {
4
+ it('returns true for a rename event with oldPath', () => {
5
+ const change = {
6
+ type: 'rename',
7
+ path: 'new.txt',
8
+ oldPath: 'old.txt',
9
+ tenantId: 't1',
10
+ shardId: 's1',
11
+ };
12
+ expect(isRename(change)).toBe(true);
13
+ });
14
+ it('returns false for create/update/delete', () => {
15
+ const base = { path: 'a.txt', tenantId: 't1', shardId: 's1' };
16
+ expect(isRename(Object.assign(Object.assign({}, base), { type: 'create' }))).toBe(false);
17
+ expect(isRename(Object.assign(Object.assign({}, base), { type: 'update' }))).toBe(false);
18
+ expect(isRename(Object.assign(Object.assign({}, base), { type: 'delete' }))).toBe(false);
19
+ });
20
+ });