sh3-core 0.7.5 → 0.8.1
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/api.d.ts +12 -2
- package/dist/api.js +13 -1
- package/dist/app/admin/SystemView.svelte +149 -11
- package/dist/app/store/StoreView.svelte +36 -7
- package/dist/app/store/storeShard.svelte.js +9 -3
- package/dist/app/store/verbs.js +8 -2
- package/dist/apps/lifecycle.d.ts +11 -0
- package/dist/apps/lifecycle.js +21 -1
- package/dist/apps/lifecycle.test.js +50 -1
- package/dist/apps/types.d.ts +7 -2
- package/dist/createShell.d.ts +2 -0
- package/dist/createShell.js +9 -7
- package/dist/documents/backends.d.ts +8 -0
- package/dist/documents/backends.js +87 -0
- package/dist/documents/backends.test.d.ts +1 -0
- package/dist/documents/backends.test.js +33 -0
- package/dist/documents/browse.d.ts +12 -0
- package/dist/documents/browse.js +19 -0
- package/dist/documents/browse.test.d.ts +1 -0
- package/dist/documents/browse.test.js +41 -0
- package/dist/documents/handle.js +5 -0
- package/dist/documents/http-backend.d.ts +4 -0
- package/dist/documents/http-backend.js +14 -0
- package/dist/documents/index.d.ts +1 -0
- package/dist/documents/index.js +1 -0
- package/dist/documents/journal-hook.d.ts +6 -0
- package/dist/documents/journal-hook.js +16 -0
- package/dist/documents/sync/activate-integration.test.d.ts +1 -0
- package/dist/documents/sync/activate-integration.test.js +37 -0
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
- package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
- package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
- package/dist/documents/sync/conflicts.d.ts +30 -0
- package/dist/documents/sync/conflicts.js +77 -0
- package/dist/documents/sync/conflicts.test.d.ts +1 -0
- package/dist/documents/sync/conflicts.test.js +71 -0
- package/dist/documents/sync/engine.d.ts +19 -0
- package/dist/documents/sync/engine.js +188 -0
- package/dist/documents/sync/engine.test.d.ts +1 -0
- package/dist/documents/sync/engine.test.js +169 -0
- package/dist/documents/sync/handle.d.ts +11 -0
- package/dist/documents/sync/handle.js +79 -0
- package/dist/documents/sync/handle.test.d.ts +1 -0
- package/dist/documents/sync/handle.test.js +56 -0
- package/dist/documents/sync/hash.d.ts +1 -0
- package/dist/documents/sync/hash.js +13 -0
- package/dist/documents/sync/hash.test.d.ts +1 -0
- package/dist/documents/sync/hash.test.js +20 -0
- package/dist/documents/sync/index.d.ts +5 -0
- package/dist/documents/sync/index.js +10 -0
- package/dist/documents/sync/journal.d.ts +30 -0
- package/dist/documents/sync/journal.js +179 -0
- package/dist/documents/sync/journal.test.d.ts +1 -0
- package/dist/documents/sync/journal.test.js +87 -0
- package/dist/documents/sync/observer.d.ts +3 -0
- package/dist/documents/sync/observer.js +45 -0
- package/dist/documents/sync/registry.d.ts +13 -0
- package/dist/documents/sync/registry.js +73 -0
- package/dist/documents/sync/registry.test.d.ts +1 -0
- package/dist/documents/sync/registry.test.js +53 -0
- package/dist/documents/sync/serialization.d.ts +5 -0
- package/dist/documents/sync/serialization.js +24 -0
- package/dist/documents/sync/serialization.test.d.ts +1 -0
- package/dist/documents/sync/serialization.test.js +26 -0
- package/dist/documents/sync/singleton.d.ts +11 -0
- package/dist/documents/sync/singleton.js +26 -0
- package/dist/documents/sync/tombstones.d.ts +19 -0
- package/dist/documents/sync/tombstones.js +58 -0
- package/dist/documents/sync/tombstones.test.d.ts +1 -0
- package/dist/documents/sync/tombstones.test.js +37 -0
- package/dist/documents/sync/types.d.ts +116 -0
- package/dist/documents/sync/types.js +27 -0
- package/dist/documents/sync/write-hook.test.d.ts +1 -0
- package/dist/documents/sync/write-hook.test.js +36 -0
- package/dist/documents/types.d.ts +18 -0
- package/dist/documents/types.js +6 -1
- package/dist/env/client.d.ts +10 -5
- package/dist/env/client.js +12 -4
- package/dist/layout/inspection.d.ts +17 -0
- package/dist/layout/inspection.js +53 -0
- package/dist/registry/installer.d.ts +10 -7
- package/dist/registry/installer.js +39 -35
- package/dist/registry/register.d.ts +17 -0
- package/dist/registry/register.js +22 -0
- package/dist/registry/register.test.d.ts +1 -0
- package/dist/registry/register.test.js +28 -0
- package/dist/shards/activate-browse.test.d.ts +1 -0
- package/dist/shards/activate-browse.test.js +36 -0
- package/dist/shards/activate-sync-registry.test.d.ts +1 -0
- package/dist/shards/activate-sync-registry.test.js +42 -0
- package/dist/shards/activate-tenantid.test.d.ts +1 -0
- package/dist/shards/activate-tenantid.test.js +21 -0
- package/dist/shards/activate.svelte.d.ts +12 -0
- package/dist/shards/activate.svelte.js +53 -2
- package/dist/shards/types.d.ts +43 -1
- package/dist/shell-shard/Terminal.svelte +140 -33
- package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
- package/dist/shell-shard/auto-relocate.d.ts +12 -0
- package/dist/shell-shard/auto-relocate.js +20 -0
- package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
- package/dist/shell-shard/auto-relocate.test.js +35 -0
- package/dist/shell-shard/dispatch.d.ts +15 -0
- package/dist/shell-shard/dispatch.js +56 -0
- package/dist/shell-shard/manifest.js +1 -1
- package/dist/shell-shard/modes/builtin.d.ts +5 -0
- package/dist/shell-shard/modes/builtin.js +18 -0
- package/dist/shell-shard/modes/prefs.d.ts +5 -0
- package/dist/shell-shard/modes/prefs.js +31 -0
- package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
- package/dist/shell-shard/modes/prefs.test.js +46 -0
- package/dist/shell-shard/modes/registry.d.ts +7 -0
- package/dist/shell-shard/modes/registry.js +27 -0
- package/dist/shell-shard/modes/registry.test.d.ts +1 -0
- package/dist/shell-shard/modes/registry.test.js +35 -0
- package/dist/shell-shard/modes/types.d.ts +8 -0
- package/dist/shell-shard/modes/types.js +1 -0
- package/dist/shell-shard/protocol.d.ts +6 -0
- package/dist/shell-shard/shellShard.svelte.js +57 -5
- package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
- package/dist/shell-shard/tenant-fs-client.js +44 -0
- package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
- package/dist/shell-shard/tenant-fs-client.test.js +49 -0
- package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
- package/dist/shell-shard/terminal-dispatch.test.js +53 -0
- package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
- package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
- package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
- package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
- package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
- package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
- package/dist/shell-shard/toolbar/slots.d.ts +17 -0
- package/dist/shell-shard/toolbar/slots.js +26 -0
- package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
- package/dist/shell-shard/toolbar/slots.test.js +28 -0
- package/dist/shell-shard/verbs/cat.d.ts +2 -0
- package/dist/shell-shard/verbs/cat.js +34 -0
- package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
- package/dist/shell-shard/verbs/cd.test.js +56 -0
- package/dist/shell-shard/verbs/env.d.ts +2 -0
- package/dist/shell-shard/verbs/env.js +14 -0
- package/dist/shell-shard/verbs/index.js +9 -2
- package/dist/shell-shard/verbs/ls.d.ts +2 -0
- package/dist/shell-shard/verbs/ls.js +29 -0
- package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
- package/dist/shell-shard/verbs/ls.test.js +49 -0
- package/dist/shell-shard/verbs/session.d.ts +0 -1
- package/dist/shell-shard/verbs/session.js +58 -26
- package/dist/shell-shard/verbs/views.d.ts +2 -0
- package/dist/shell-shard/verbs/views.js +103 -2
- package/dist/verbs/types.d.ts +21 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -62,6 +62,36 @@ export class MemoryDocumentBackend {
|
|
|
62
62
|
async exists(tenantId, shardId, path) {
|
|
63
63
|
return __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").has(compositeKey(tenantId, shardId, path));
|
|
64
64
|
}
|
|
65
|
+
async listAllShards(tenantId) {
|
|
66
|
+
const prefix = `${tenantId}/`;
|
|
67
|
+
const shards = new Set();
|
|
68
|
+
for (const key of __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").keys()) {
|
|
69
|
+
if (!key.startsWith(prefix))
|
|
70
|
+
continue;
|
|
71
|
+
const rest = key.slice(prefix.length);
|
|
72
|
+
const slash = rest.indexOf('/');
|
|
73
|
+
if (slash < 0)
|
|
74
|
+
continue;
|
|
75
|
+
shards.add(rest.slice(0, slash));
|
|
76
|
+
}
|
|
77
|
+
return [...shards];
|
|
78
|
+
}
|
|
79
|
+
async listAllDocuments(tenantId) {
|
|
80
|
+
const prefix = `${tenantId}/`;
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const [key, entry] of __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f")) {
|
|
83
|
+
if (!key.startsWith(prefix))
|
|
84
|
+
continue;
|
|
85
|
+
const rest = key.slice(prefix.length);
|
|
86
|
+
const slash = rest.indexOf('/');
|
|
87
|
+
if (slash < 0)
|
|
88
|
+
continue;
|
|
89
|
+
const shardId = rest.slice(0, slash);
|
|
90
|
+
const path = rest.slice(slash + 1);
|
|
91
|
+
out.push({ shardId, path, size: entry.size, lastModified: entry.lastModified });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
65
95
|
}
|
|
66
96
|
_MemoryDocumentBackend_store = new WeakMap();
|
|
67
97
|
// ---------------------------------------------------------------------------
|
|
@@ -126,6 +156,63 @@ export class IndexedDBDocumentBackend {
|
|
|
126
156
|
const result = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_tx).call(this, 'readonly', (s) => s.getKey(key));
|
|
127
157
|
return result !== undefined;
|
|
128
158
|
}
|
|
159
|
+
async listAllShards(tenantId) {
|
|
160
|
+
const prefix = `${tenantId}/`;
|
|
161
|
+
const db = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_db).call(this);
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const tx = db.transaction(IDB_STORE, 'readonly');
|
|
164
|
+
const store = tx.objectStore(IDB_STORE);
|
|
165
|
+
const range = IDBKeyRange.bound(prefix, prefix + '\uffff', false, false);
|
|
166
|
+
const req = store.openKeyCursor(range);
|
|
167
|
+
const shards = new Set();
|
|
168
|
+
req.onsuccess = () => {
|
|
169
|
+
const cursor = req.result;
|
|
170
|
+
if (cursor) {
|
|
171
|
+
const rest = cursor.key.slice(prefix.length);
|
|
172
|
+
const slash = rest.indexOf('/');
|
|
173
|
+
if (slash >= 0)
|
|
174
|
+
shards.add(rest.slice(0, slash));
|
|
175
|
+
cursor.continue();
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
resolve([...shards]);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
req.onerror = () => reject(req.error);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
async listAllDocuments(tenantId) {
|
|
185
|
+
const prefix = `${tenantId}/`;
|
|
186
|
+
const db = await __classPrivateFieldGet(this, _IndexedDBDocumentBackend_instances, "m", _IndexedDBDocumentBackend_db).call(this);
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const tx = db.transaction(IDB_STORE, 'readonly');
|
|
189
|
+
const store = tx.objectStore(IDB_STORE);
|
|
190
|
+
const range = IDBKeyRange.bound(prefix, prefix + '\uffff', false, false);
|
|
191
|
+
const req = store.openCursor(range);
|
|
192
|
+
const out = [];
|
|
193
|
+
req.onsuccess = () => {
|
|
194
|
+
const cursor = req.result;
|
|
195
|
+
if (cursor) {
|
|
196
|
+
const rest = cursor.key.slice(prefix.length);
|
|
197
|
+
const slash = rest.indexOf('/');
|
|
198
|
+
if (slash >= 0) {
|
|
199
|
+
const entry = cursor.value;
|
|
200
|
+
out.push({
|
|
201
|
+
shardId: rest.slice(0, slash),
|
|
202
|
+
path: rest.slice(slash + 1),
|
|
203
|
+
size: entry.size,
|
|
204
|
+
lastModified: entry.lastModified,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
cursor.continue();
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
resolve(out);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
req.onerror = () => reject(req.error);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
129
216
|
}
|
|
130
217
|
_IndexedDBDocumentBackend_dbPromise = new WeakMap(), _IndexedDBDocumentBackend_instances = new WeakSet(), _IndexedDBDocumentBackend_db = function _IndexedDBDocumentBackend_db() {
|
|
131
218
|
if (!__classPrivateFieldGet(this, _IndexedDBDocumentBackend_dbPromise, "f")) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from './backends';
|
|
3
|
+
describe('DocumentBackend tenant-wide primitives', () => {
|
|
4
|
+
it('listAllShards returns every shard that has content for a tenant', async () => {
|
|
5
|
+
const be = new MemoryDocumentBackend();
|
|
6
|
+
await be.write('t1', 'shard-a', 'x.txt', 'a');
|
|
7
|
+
await be.write('t1', 'shard-b', 'y.txt', 'b');
|
|
8
|
+
await be.write('t1', 'shard-b', 'nested/z.txt', 'bb');
|
|
9
|
+
await be.write('t2', 'shard-c', 'z.txt', 'c');
|
|
10
|
+
const shards = await be.listAllShards('t1');
|
|
11
|
+
expect(shards.sort()).toEqual(['shard-a', 'shard-b']);
|
|
12
|
+
const other = await be.listAllShards('t2');
|
|
13
|
+
expect(other).toEqual(['shard-c']);
|
|
14
|
+
});
|
|
15
|
+
it('listAllDocuments returns docs with shardId attached across the tenant', async () => {
|
|
16
|
+
const be = new MemoryDocumentBackend();
|
|
17
|
+
await be.write('t1', 'shard-a', 'x.txt', 'a');
|
|
18
|
+
await be.write('t1', 'shard-b', 'nested/y.txt', 'bb');
|
|
19
|
+
await be.write('t2', 'shard-c', 'z.txt', 'c');
|
|
20
|
+
const docs = await be.listAllDocuments('t1');
|
|
21
|
+
expect(docs).toHaveLength(2);
|
|
22
|
+
expect(docs.map((d) => `${d.shardId}/${d.path}`).sort()).toEqual([
|
|
23
|
+
'shard-a/x.txt',
|
|
24
|
+
'shard-b/nested/y.txt',
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
it('listAllDocuments returns an empty array for an unknown tenant', async () => {
|
|
28
|
+
const be = new MemoryDocumentBackend();
|
|
29
|
+
await be.write('t1', 'shard-a', 'x.txt', 'a');
|
|
30
|
+
expect(await be.listAllDocuments('ghost')).toEqual([]);
|
|
31
|
+
expect(await be.listAllShards('ghost')).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DocumentBackend, DocumentChange, DocumentMeta } from './types';
|
|
2
|
+
export interface BrowseCapability {
|
|
3
|
+
/** Every document in the tenant across all shards, each tagged with its owning shardId. */
|
|
4
|
+
listDocuments(): Promise<Array<DocumentMeta & {
|
|
5
|
+
shardId: string;
|
|
6
|
+
}>>;
|
|
7
|
+
/** Subscribe to tenant-wide document changes. Returns an unsubscribe. */
|
|
8
|
+
watchDocuments(callback: (change: DocumentChange) => void): () => void;
|
|
9
|
+
/** Enumerate shard ids with at least one document in the tenant. */
|
|
10
|
+
listShards(): Promise<string[]>;
|
|
11
|
+
}
|
|
12
|
+
export declare function createBrowseCapability(tenantId: string, backend: DocumentBackend): BrowseCapability;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* BrowseCapability — tenant-wide document observation surface.
|
|
3
|
+
*
|
|
4
|
+
* Exposed on ShardContext as `ctx.browse` when the shard declares the
|
|
5
|
+
* 'documents:browse' permission. Read-only: writes still flow through
|
|
6
|
+
* the owning shard's own ctx.documents() handle.
|
|
7
|
+
*/
|
|
8
|
+
import { documentChanges } from './notifications';
|
|
9
|
+
export function createBrowseCapability(tenantId, backend) {
|
|
10
|
+
return {
|
|
11
|
+
listDocuments: () => backend.listAllDocuments(tenantId),
|
|
12
|
+
listShards: () => backend.listAllShards(tenantId),
|
|
13
|
+
watchDocuments: (callback) => documentChanges.subscribe((change) => {
|
|
14
|
+
if (change.tenantId !== tenantId)
|
|
15
|
+
return;
|
|
16
|
+
callback(change);
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from './backends';
|
|
3
|
+
import { createBrowseCapability } from './browse';
|
|
4
|
+
import { documentChanges } from './notifications';
|
|
5
|
+
describe('BrowseCapability', () => {
|
|
6
|
+
it('lists documents tenant-wide with shardId attached', async () => {
|
|
7
|
+
const be = new MemoryDocumentBackend();
|
|
8
|
+
await be.write('t1', 'a', 'x.txt', '1');
|
|
9
|
+
await be.write('t1', 'b', 'y.txt', '22');
|
|
10
|
+
const browse = createBrowseCapability('t1', be);
|
|
11
|
+
const docs = await browse.listDocuments();
|
|
12
|
+
expect(docs.map((d) => d.shardId).sort()).toEqual(['a', 'b']);
|
|
13
|
+
});
|
|
14
|
+
it('listShards enumerates tenant shards', async () => {
|
|
15
|
+
const be = new MemoryDocumentBackend();
|
|
16
|
+
await be.write('t1', 'a', 'x.txt', '1');
|
|
17
|
+
await be.write('t1', 'b', 'y.txt', '2');
|
|
18
|
+
const browse = createBrowseCapability('t1', be);
|
|
19
|
+
expect((await browse.listShards()).sort()).toEqual(['a', 'b']);
|
|
20
|
+
});
|
|
21
|
+
it('watchDocuments fires with shardId on tenant-wide emits; filters other tenants', () => {
|
|
22
|
+
const be = new MemoryDocumentBackend();
|
|
23
|
+
const browse = createBrowseCapability('t1', be);
|
|
24
|
+
const cb = vi.fn();
|
|
25
|
+
const unsub = browse.watchDocuments(cb);
|
|
26
|
+
documentChanges.emit({ type: 'create', path: 'f.txt', tenantId: 't1', shardId: 's1' });
|
|
27
|
+
documentChanges.emit({ type: 'create', path: 'g.txt', tenantId: 't2', shardId: 's2' });
|
|
28
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
29
|
+
expect(cb).toHaveBeenCalledWith(expect.objectContaining({ shardId: 's1', path: 'f.txt', tenantId: 't1' }));
|
|
30
|
+
unsub();
|
|
31
|
+
});
|
|
32
|
+
it('watchDocuments unsubscribe stops callbacks', () => {
|
|
33
|
+
const be = new MemoryDocumentBackend();
|
|
34
|
+
const browse = createBrowseCapability('t1', be);
|
|
35
|
+
const cb = vi.fn();
|
|
36
|
+
const unsub = browse.watchDocuments(cb);
|
|
37
|
+
unsub();
|
|
38
|
+
documentChanges.emit({ type: 'create', path: 'f.txt', tenantId: 't1', shardId: 's1' });
|
|
39
|
+
expect(cb).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
});
|
package/dist/documents/handle.js
CHANGED
|
@@ -18,6 +18,8 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
|
|
|
18
18
|
};
|
|
19
19
|
var _AutosaveControllerImpl_instances, _AutosaveControllerImpl_handle, _AutosaveControllerImpl_path, _AutosaveControllerImpl_debounceMs, _AutosaveControllerImpl_pending, _AutosaveControllerImpl_timer, _AutosaveControllerImpl_dirty, _AutosaveControllerImpl_disposed, _AutosaveControllerImpl_scheduleFlush, _AutosaveControllerImpl_clearTimer;
|
|
20
20
|
import { documentChanges } from './notifications';
|
|
21
|
+
import { notifyJournal } from './journal-hook';
|
|
22
|
+
import { hashContent } from './sync/hash';
|
|
21
23
|
const DEFAULT_DEBOUNCE_MS = 1000;
|
|
22
24
|
/**
|
|
23
25
|
* Create a document handle scoped to a tenant, shard, and file filter.
|
|
@@ -53,10 +55,13 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
|
|
|
53
55
|
const existed = await backend.exists(tenantId, shardId, path);
|
|
54
56
|
await backend.write(tenantId, shardId, path, content);
|
|
55
57
|
emitChange(existed ? 'update' : 'create', path);
|
|
58
|
+
const hash = await hashContent(content);
|
|
59
|
+
await notifyJournal({ shardId, path, op: 'upsert', hash });
|
|
56
60
|
},
|
|
57
61
|
async delete(path) {
|
|
58
62
|
await backend.delete(tenantId, shardId, path);
|
|
59
63
|
emitChange('delete', path);
|
|
64
|
+
await notifyJournal({ shardId, path, op: 'delete', hash: null });
|
|
60
65
|
},
|
|
61
66
|
async exists(path) {
|
|
62
67
|
return backend.exists(tenantId, shardId, path);
|
|
@@ -19,4 +19,8 @@ export declare class HttpDocumentBackend implements DocumentBackend {
|
|
|
19
19
|
delete(tenantId: string, shardId: string, path: string): Promise<void>;
|
|
20
20
|
list(tenantId: string, shardId: string): Promise<DocumentMeta[]>;
|
|
21
21
|
exists(tenantId: string, shardId: string, path: string): Promise<boolean>;
|
|
22
|
+
listAllShards(tenantId: string): Promise<string[]>;
|
|
23
|
+
listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
|
|
24
|
+
shardId: string;
|
|
25
|
+
}>>;
|
|
22
26
|
}
|
|
@@ -70,6 +70,20 @@ export class HttpDocumentBackend {
|
|
|
70
70
|
const res = await fetch(url, { method: 'HEAD' });
|
|
71
71
|
return res.ok;
|
|
72
72
|
}
|
|
73
|
+
async listAllShards(tenantId) {
|
|
74
|
+
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/_shards`;
|
|
75
|
+
const res = await fetch(url);
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
throw new Error(`listAllShards failed: ${res.status}`);
|
|
78
|
+
return res.json();
|
|
79
|
+
}
|
|
80
|
+
async listAllDocuments(tenantId) {
|
|
81
|
+
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/_all`;
|
|
82
|
+
const res = await fetch(url);
|
|
83
|
+
if (!res.ok)
|
|
84
|
+
throw new Error(`listAllDocuments failed: ${res.status}`);
|
|
85
|
+
return res.json();
|
|
86
|
+
}
|
|
73
87
|
}
|
|
74
88
|
_HttpDocumentBackend_baseUrl = new WeakMap(), _HttpDocumentBackend_apiKey = new WeakMap(), _HttpDocumentBackend_instances = new WeakSet(), _HttpDocumentBackend_authHeaders = function _HttpDocumentBackend_authHeaders() {
|
|
75
89
|
if (!__classPrivateFieldGet(this, _HttpDocumentBackend_apiKey, "f"))
|
|
@@ -4,3 +4,4 @@ export { HttpDocumentBackend } from './http-backend';
|
|
|
4
4
|
export { createDocumentHandle } from './handle';
|
|
5
5
|
export { documentChanges } from './notifications';
|
|
6
6
|
export { getTenantId, getDocumentBackend, __setTenantId, __setDocumentBackend, } from './config';
|
|
7
|
+
export * from './sync';
|
package/dist/documents/index.js
CHANGED
|
@@ -6,3 +6,4 @@ export { HttpDocumentBackend } from './http-backend';
|
|
|
6
6
|
export { createDocumentHandle } from './handle';
|
|
7
7
|
export { documentChanges } from './notifications';
|
|
8
8
|
export { getTenantId, getDocumentBackend, __setTenantId, __setDocumentBackend, } from './config';
|
|
9
|
+
export * from './sync';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { JournalEntry } from './sync/types';
|
|
2
|
+
type Appender = (entry: Omit<JournalEntry, 'seq' | 'ts'>) => Promise<void>;
|
|
3
|
+
export declare function setJournalAppender(fn: Appender): void;
|
|
4
|
+
export declare function clearJournalAppender(): void;
|
|
5
|
+
export declare function notifyJournal(entry: Omit<JournalEntry, 'seq' | 'ts'>): Promise<void>;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Journal appender hook — lets the sync engine subscribe to regular
|
|
3
|
+
* shard writes/deletes without creating an import cycle between the
|
|
4
|
+
* document handle and the sync subsystem.
|
|
5
|
+
*/
|
|
6
|
+
let appender = null;
|
|
7
|
+
export function setJournalAppender(fn) {
|
|
8
|
+
appender = fn;
|
|
9
|
+
}
|
|
10
|
+
export function clearJournalAppender() {
|
|
11
|
+
appender = null;
|
|
12
|
+
}
|
|
13
|
+
export async function notifyJournal(entry) {
|
|
14
|
+
if (appender)
|
|
15
|
+
await appender(entry);
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from '../backends';
|
|
3
|
+
import { __setDocumentBackend, __setTenantId } from '../config';
|
|
4
|
+
import { __resetShardRegistryForTest, registerShard, activateShard } from '../../shards/activate.svelte';
|
|
5
|
+
import { __resetSyncBundlesForTest } from './singleton';
|
|
6
|
+
import { PERMISSION_DOCUMENTS_SYNC } from './types';
|
|
7
|
+
describe('ctx.sync() gating', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
__resetShardRegistryForTest();
|
|
10
|
+
__resetSyncBundlesForTest();
|
|
11
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
12
|
+
__setTenantId('tenant-a');
|
|
13
|
+
});
|
|
14
|
+
it('is undefined without documents:sync permission', async () => {
|
|
15
|
+
let captured;
|
|
16
|
+
const shard = {
|
|
17
|
+
manifest: { id: 's-none', version: '0', views: [] },
|
|
18
|
+
activate: async (ctx) => { captured = ctx; },
|
|
19
|
+
};
|
|
20
|
+
registerShard(shard);
|
|
21
|
+
await activateShard('s-none');
|
|
22
|
+
expect(captured.sync).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
it('is a function when documents:sync is declared', async () => {
|
|
25
|
+
let captured;
|
|
26
|
+
const shard = {
|
|
27
|
+
manifest: { id: 's-sync', version: '0', views: [], permissions: [PERMISSION_DOCUMENTS_SYNC] },
|
|
28
|
+
activate: async (ctx) => { captured = ctx; },
|
|
29
|
+
};
|
|
30
|
+
registerShard(shard);
|
|
31
|
+
await activateShard('s-sync');
|
|
32
|
+
expect(typeof captured.sync).toBe('function');
|
|
33
|
+
const h = captured.sync();
|
|
34
|
+
expect(h.connectorId).toBe('s-sync');
|
|
35
|
+
expect(await h.grantedScopes()).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import SyncGrantPicker from './SyncGrantPicker.svelte';
|
|
4
|
+
import { createSyncRegistry, type SyncRegistry } from '../registry';
|
|
5
|
+
import { getDocumentBackend, getTenantId } from '../../config';
|
|
6
|
+
import type { GrantRecord, SyncScope, ConflictResolution } from '../types';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Optional connector-specific filter; if omitted, shows everything. */
|
|
10
|
+
connectorId?: string;
|
|
11
|
+
/** Shard IDs whose conflict artifacts should be listed. */
|
|
12
|
+
conflictShardIds?: string[];
|
|
13
|
+
/** Pending grant request, if any — embeds the picker when set. */
|
|
14
|
+
pendingRequest?: { connectorId: string; scope: SyncScope };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let { connectorId, conflictShardIds = [], pendingRequest }: Props = $props();
|
|
18
|
+
|
|
19
|
+
let registry: SyncRegistry | null = $state(null);
|
|
20
|
+
let grants: GrantRecord[] = $state([]);
|
|
21
|
+
let conflicts: ConflictResolution[] = $state([]);
|
|
22
|
+
|
|
23
|
+
async function refresh() {
|
|
24
|
+
if (!registry) return;
|
|
25
|
+
grants = await registry.list(connectorId);
|
|
26
|
+
const all: ConflictResolution[] = [];
|
|
27
|
+
for (const shardId of conflictShardIds) {
|
|
28
|
+
all.push(...await registry.listConflicts(shardId));
|
|
29
|
+
}
|
|
30
|
+
conflicts = all;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
onMount(async () => {
|
|
34
|
+
registry = createSyncRegistry(getDocumentBackend(), getTenantId());
|
|
35
|
+
await refresh();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
async function revoke(record: GrantRecord) {
|
|
39
|
+
if (!registry) return;
|
|
40
|
+
await registry.revoke(record.connectorId, record.scope);
|
|
41
|
+
await refresh();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function describeScope(s: SyncScope): string {
|
|
45
|
+
if (s.kind === 'tenant') return 'entire tenant';
|
|
46
|
+
if (s.kind === 'shard') return `shard:${s.shardId}`;
|
|
47
|
+
return `shard:${s.shardId}/${s.prefix}`;
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<section class="document-sync-explorer" part="container">
|
|
52
|
+
<h2 part="title">Document Sync</h2>
|
|
53
|
+
|
|
54
|
+
{#if pendingRequest}
|
|
55
|
+
<SyncGrantPicker
|
|
56
|
+
connectorId={pendingRequest.connectorId}
|
|
57
|
+
scope={pendingRequest.scope}
|
|
58
|
+
onGranted={refresh}
|
|
59
|
+
/>
|
|
60
|
+
{/if}
|
|
61
|
+
|
|
62
|
+
<h3 part="subtitle">Granted scopes</h3>
|
|
63
|
+
{#if grants.length === 0}
|
|
64
|
+
<p part="empty">No scopes granted yet.</p>
|
|
65
|
+
{:else}
|
|
66
|
+
<ul part="grants">
|
|
67
|
+
{#each grants as g}
|
|
68
|
+
<li>
|
|
69
|
+
<span part="grant-connector">{g.connectorId}</span>
|
|
70
|
+
<span part="grant-scope">{describeScope(g.scope)}</span>
|
|
71
|
+
<button type="button" onclick={() => revoke(g)} part="revoke">Revoke</button>
|
|
72
|
+
</li>
|
|
73
|
+
{/each}
|
|
74
|
+
</ul>
|
|
75
|
+
{/if}
|
|
76
|
+
|
|
77
|
+
<h3 part="subtitle">Conflicts</h3>
|
|
78
|
+
{#if conflicts.length === 0}
|
|
79
|
+
<p part="empty">No active conflicts.</p>
|
|
80
|
+
{:else}
|
|
81
|
+
<ul part="conflicts">
|
|
82
|
+
{#each conflicts as c}
|
|
83
|
+
<li>
|
|
84
|
+
<code part="conflict-path">{c.shardId}:{c.path}</code>
|
|
85
|
+
<small part="conflict-artifact">{c.conflictArtifactPath}</small>
|
|
86
|
+
</li>
|
|
87
|
+
{/each}
|
|
88
|
+
</ul>
|
|
89
|
+
{/if}
|
|
90
|
+
</section>
|
|
91
|
+
|
|
92
|
+
<style>
|
|
93
|
+
.document-sync-explorer {
|
|
94
|
+
display: grid;
|
|
95
|
+
gap: 0.75rem;
|
|
96
|
+
}
|
|
97
|
+
ul { list-style: none; padding: 0; margin: 0; }
|
|
98
|
+
li { display: flex; gap: 0.5rem; align-items: center; }
|
|
99
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SyncScope } from '../types';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Optional connector-specific filter; if omitted, shows everything. */
|
|
4
|
+
connectorId?: string;
|
|
5
|
+
/** Shard IDs whose conflict artifacts should be listed. */
|
|
6
|
+
conflictShardIds?: string[];
|
|
7
|
+
/** Pending grant request, if any — embeds the picker when set. */
|
|
8
|
+
pendingRequest?: {
|
|
9
|
+
connectorId: string;
|
|
10
|
+
scope: SyncScope;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
declare const DocumentSyncExplorer: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type DocumentSyncExplorer = ReturnType<typeof DocumentSyncExplorer>;
|
|
15
|
+
export default DocumentSyncExplorer;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getDocumentBackend, getTenantId } from '../../config';
|
|
3
|
+
import { __grantInternal } from '../registry';
|
|
4
|
+
import type { SyncScope } from '../types';
|
|
5
|
+
|
|
6
|
+
import type { Snippet } from 'svelte';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
connectorId: string;
|
|
10
|
+
scope: SyncScope;
|
|
11
|
+
onGranted?: () => void;
|
|
12
|
+
onCancel?: () => void;
|
|
13
|
+
rationale?: Snippet;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { connectorId, scope, onGranted, onCancel, rationale }: Props = $props();
|
|
17
|
+
|
|
18
|
+
let pending = $state(false);
|
|
19
|
+
let error = $state<string | null>(null);
|
|
20
|
+
|
|
21
|
+
function describe(s: SyncScope): string {
|
|
22
|
+
switch (s.kind) {
|
|
23
|
+
case 'tenant': return 'all your documents across every shard';
|
|
24
|
+
case 'shard': return `all documents for shard "${s.shardId}"`;
|
|
25
|
+
case 'path': return `documents under "${s.prefix}" in shard "${s.shardId}"`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function confirm() {
|
|
30
|
+
pending = true;
|
|
31
|
+
error = null;
|
|
32
|
+
try {
|
|
33
|
+
await __grantInternal(getDocumentBackend(), getTenantId(), connectorId, scope);
|
|
34
|
+
onGranted?.();
|
|
35
|
+
} catch (e) {
|
|
36
|
+
error = e instanceof Error ? e.message : String(e);
|
|
37
|
+
} finally {
|
|
38
|
+
pending = false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<section class="sync-grant-picker" part="container">
|
|
44
|
+
<header part="header">
|
|
45
|
+
<h3 part="title">Grant sync access</h3>
|
|
46
|
+
</header>
|
|
47
|
+
<p part="summary">
|
|
48
|
+
<strong>{connectorId}</strong> is requesting access to {describe(scope)}.
|
|
49
|
+
</p>
|
|
50
|
+
{#if rationale}{@render rationale()}{/if}
|
|
51
|
+
{#if error}
|
|
52
|
+
<p class="error" part="error">{error}</p>
|
|
53
|
+
{/if}
|
|
54
|
+
<footer part="actions">
|
|
55
|
+
<button type="button" disabled={pending} onclick={() => onCancel?.()} part="cancel">Cancel</button>
|
|
56
|
+
<button type="button" disabled={pending} onclick={confirm} part="confirm">Grant</button>
|
|
57
|
+
</footer>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<style>
|
|
61
|
+
.sync-grant-picker {
|
|
62
|
+
display: grid;
|
|
63
|
+
gap: 0.75rem;
|
|
64
|
+
padding: 1rem;
|
|
65
|
+
border: 1px solid var(--sh3-border, #444);
|
|
66
|
+
border-radius: 6px;
|
|
67
|
+
}
|
|
68
|
+
.error { color: var(--sh3-error, #c00); }
|
|
69
|
+
footer { display: flex; gap: 0.5rem; justify-content: flex-end; }
|
|
70
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SyncScope } from '../types';
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
interface Props {
|
|
4
|
+
connectorId: string;
|
|
5
|
+
scope: SyncScope;
|
|
6
|
+
onGranted?: () => void;
|
|
7
|
+
onCancel?: () => void;
|
|
8
|
+
rationale?: Snippet;
|
|
9
|
+
}
|
|
10
|
+
declare const SyncGrantPicker: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type SyncGrantPicker = ReturnType<typeof SyncGrantPicker>;
|
|
12
|
+
export default SyncGrantPicker;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DocumentBackend } from '../types';
|
|
2
|
+
import type { ConflictPolicy, ConflictResolution } from './types';
|
|
3
|
+
interface ConflictInput {
|
|
4
|
+
connectorId: string;
|
|
5
|
+
shardId: string;
|
|
6
|
+
path: string;
|
|
7
|
+
localHash: string;
|
|
8
|
+
remoteHash: string;
|
|
9
|
+
remoteContent?: string | ArrayBuffer;
|
|
10
|
+
baseHash?: string;
|
|
11
|
+
}
|
|
12
|
+
export type ConflictAction = {
|
|
13
|
+
action: 'apply-remote';
|
|
14
|
+
asPath?: string;
|
|
15
|
+
} | {
|
|
16
|
+
action: 'skip';
|
|
17
|
+
} | {
|
|
18
|
+
action: 'conflict';
|
|
19
|
+
resolution: ConflictResolution;
|
|
20
|
+
};
|
|
21
|
+
export declare class ConflictManager {
|
|
22
|
+
private backend;
|
|
23
|
+
private tenantId;
|
|
24
|
+
constructor(backend: DocumentBackend, tenantId: string);
|
|
25
|
+
resolve(policy: ConflictPolicy, input: ConflictInput): Promise<ConflictAction>;
|
|
26
|
+
getBaseHash(connectorId: string, shardId: string, path: string): Promise<string | null>;
|
|
27
|
+
setBaseHash(connectorId: string, shardId: string, path: string, hash: string): Promise<void>;
|
|
28
|
+
listConflicts(shardId: string): Promise<ConflictResolution[]>;
|
|
29
|
+
}
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Conflict resolution — dispatches the caller-supplied (or default)
|
|
3
|
+
* policy, writes .sync-conflict-* artifacts when required, and tracks
|
|
4
|
+
* per-(connectorId, path) base hashes for three-way comparisons.
|
|
5
|
+
*/
|
|
6
|
+
import { readJson, writeJson } from './serialization';
|
|
7
|
+
const BASES_PREFIX = 'bases/';
|
|
8
|
+
function baseKey(connectorId, shardId, path) {
|
|
9
|
+
return `${BASES_PREFIX}${encodeURIComponent(connectorId)}__${shardId}__${encodeURIComponent(path)}.json`;
|
|
10
|
+
}
|
|
11
|
+
function isArtifactName(name) {
|
|
12
|
+
return /\.sync-conflict-[^.]+-\d+$/.test(name);
|
|
13
|
+
}
|
|
14
|
+
export class ConflictManager {
|
|
15
|
+
constructor(backend, tenantId) {
|
|
16
|
+
this.backend = backend;
|
|
17
|
+
this.tenantId = tenantId;
|
|
18
|
+
}
|
|
19
|
+
async resolve(policy, input) {
|
|
20
|
+
const p = typeof policy === 'function' ? await policy({
|
|
21
|
+
path: input.path, shardId: input.shardId,
|
|
22
|
+
localHash: input.localHash, remoteHash: input.remoteHash, baseHash: input.baseHash,
|
|
23
|
+
}) : policy;
|
|
24
|
+
switch (p) {
|
|
25
|
+
case 'remote-wins': return { action: 'apply-remote' };
|
|
26
|
+
case 'local-wins': return { action: 'skip' };
|
|
27
|
+
case 'keep-both': {
|
|
28
|
+
const asPath = `${input.path}.incoming-${input.connectorId}-${Date.now()}`;
|
|
29
|
+
return { action: 'apply-remote', asPath };
|
|
30
|
+
}
|
|
31
|
+
case 'default':
|
|
32
|
+
default: {
|
|
33
|
+
const ts = Date.now();
|
|
34
|
+
const artifact = `${input.path}.sync-conflict-${input.connectorId}-${ts}`;
|
|
35
|
+
if (input.remoteContent !== undefined) {
|
|
36
|
+
await this.backend.write(this.tenantId, input.shardId, artifact, input.remoteContent);
|
|
37
|
+
}
|
|
38
|
+
const resolution = {
|
|
39
|
+
path: input.path,
|
|
40
|
+
shardId: input.shardId,
|
|
41
|
+
localHash: input.localHash,
|
|
42
|
+
remoteHash: input.remoteHash,
|
|
43
|
+
conflictArtifactPath: artifact,
|
|
44
|
+
base: input.baseHash ? { hash: input.baseHash } : undefined,
|
|
45
|
+
};
|
|
46
|
+
return { action: 'conflict', resolution };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async getBaseHash(connectorId, shardId, path) {
|
|
51
|
+
return readJson(this.backend, this.tenantId, baseKey(connectorId, shardId, path));
|
|
52
|
+
}
|
|
53
|
+
async setBaseHash(connectorId, shardId, path, hash) {
|
|
54
|
+
await writeJson(this.backend, this.tenantId, baseKey(connectorId, shardId, path), hash);
|
|
55
|
+
}
|
|
56
|
+
async listConflicts(shardId) {
|
|
57
|
+
const metas = await this.backend.list(this.tenantId, shardId);
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const m of metas) {
|
|
60
|
+
const name = m.path;
|
|
61
|
+
if (!isArtifactName(name))
|
|
62
|
+
continue;
|
|
63
|
+
const m2 = /^(.*)\.sync-conflict-([^-]+)-(\d+)$/.exec(name);
|
|
64
|
+
if (!m2)
|
|
65
|
+
continue;
|
|
66
|
+
const originalPath = m2[1];
|
|
67
|
+
out.push({
|
|
68
|
+
path: originalPath,
|
|
69
|
+
shardId,
|
|
70
|
+
localHash: '',
|
|
71
|
+
remoteHash: '',
|
|
72
|
+
conflictArtifactPath: name,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|