sh3-core 0.8.2 → 0.9.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 +4 -7
- package/dist/api.js +2 -4
- package/dist/app/store/InstalledView.svelte +55 -1
- package/dist/app/store/PermissionConfirmModal.svelte +232 -0
- package/dist/app/store/PermissionConfirmModal.svelte.d.ts +17 -0
- package/dist/app/store/StoreView.svelte +119 -5
- package/dist/app/store/storeShard.svelte.d.ts +10 -1
- package/dist/app/store/storeShard.svelte.js +51 -7
- package/dist/app/store/storeShard.svelte.test.js +34 -0
- package/dist/apps/types.d.ts +3 -5
- package/dist/documents/backends.d.ts +2 -0
- package/dist/documents/backends.js +6 -0
- package/dist/documents/browse.d.ts +31 -1
- package/dist/documents/browse.js +18 -2
- package/dist/documents/browse.test.js +81 -0
- package/dist/documents/handle.js +13 -5
- package/dist/documents/handle.test.js +55 -0
- package/dist/documents/http-backend.d.ts +11 -4
- package/dist/documents/http-backend.js +37 -11
- package/dist/documents/index.d.ts +2 -1
- package/dist/documents/index.js +1 -1
- package/dist/documents/sync-types.d.ts +45 -0
- package/dist/documents/sync-types.js +11 -0
- package/dist/documents/types.d.ts +69 -2
- package/dist/documents/types.js +32 -2
- package/dist/keys/ConsentDialog.svelte +4 -4
- package/dist/keys/consent.test.js +4 -3
- package/dist/keys/types.d.ts +4 -2
- package/dist/registry/client.js +3 -0
- package/dist/registry/installer.d.ts +4 -1
- package/dist/registry/installer.js +25 -11
- package/dist/registry/permission-descriptions.d.ts +21 -0
- package/dist/registry/permission-descriptions.js +67 -0
- package/dist/registry/permission-descriptions.test.js +86 -0
- package/dist/registry/schema.js +19 -6
- package/dist/registry/types.d.ts +17 -5
- package/dist/server-shard/types.d.ts +55 -8
- package/dist/shards/activate-browse.test.js +87 -3
- package/dist/shards/activate.svelte.js +9 -31
- package/dist/shards/types.d.ts +0 -15
- package/dist/shell/views/KeysAndPeers.svelte +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -10
- package/dist/documents/journal-hook.d.ts +0 -6
- package/dist/documents/journal-hook.js +0 -16
- package/dist/documents/sync/activate-integration.test.js +0 -37
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte +0 -99
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +0 -15
- package/dist/documents/sync/components/SyncGrantPicker.svelte +0 -70
- package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +0 -12
- package/dist/documents/sync/conflicts.d.ts +0 -30
- package/dist/documents/sync/conflicts.js +0 -77
- package/dist/documents/sync/conflicts.test.js +0 -71
- package/dist/documents/sync/engine.d.ts +0 -19
- package/dist/documents/sync/engine.js +0 -188
- package/dist/documents/sync/engine.test.d.ts +0 -1
- package/dist/documents/sync/engine.test.js +0 -169
- package/dist/documents/sync/handle.d.ts +0 -11
- package/dist/documents/sync/handle.js +0 -79
- package/dist/documents/sync/handle.test.js +0 -56
- package/dist/documents/sync/hash.d.ts +0 -1
- package/dist/documents/sync/hash.js +0 -13
- package/dist/documents/sync/hash.test.d.ts +0 -1
- package/dist/documents/sync/hash.test.js +0 -20
- package/dist/documents/sync/index.d.ts +0 -5
- package/dist/documents/sync/index.js +0 -10
- package/dist/documents/sync/journal.d.ts +0 -30
- package/dist/documents/sync/journal.js +0 -179
- package/dist/documents/sync/journal.test.d.ts +0 -1
- package/dist/documents/sync/journal.test.js +0 -87
- package/dist/documents/sync/observer.d.ts +0 -3
- package/dist/documents/sync/observer.js +0 -45
- package/dist/documents/sync/registry.d.ts +0 -13
- package/dist/documents/sync/registry.js +0 -73
- package/dist/documents/sync/registry.test.d.ts +0 -1
- package/dist/documents/sync/registry.test.js +0 -53
- package/dist/documents/sync/serialization.d.ts +0 -5
- package/dist/documents/sync/serialization.js +0 -24
- package/dist/documents/sync/serialization.test.d.ts +0 -1
- package/dist/documents/sync/serialization.test.js +0 -26
- package/dist/documents/sync/singleton.d.ts +0 -11
- package/dist/documents/sync/singleton.js +0 -26
- package/dist/documents/sync/tombstones.d.ts +0 -19
- package/dist/documents/sync/tombstones.js +0 -58
- package/dist/documents/sync/tombstones.test.d.ts +0 -1
- package/dist/documents/sync/tombstones.test.js +0 -37
- package/dist/documents/sync/types.d.ts +0 -116
- package/dist/documents/sync/types.js +0 -27
- package/dist/documents/sync/write-hook.test.d.ts +0 -1
- package/dist/documents/sync/write-hook.test.js +0 -36
- package/dist/server-sync.d.ts +0 -6
- package/dist/server-sync.js +0 -634
- package/dist/server-sync.js.map +0 -7
- package/dist/shards/activate-sync-registry.test.d.ts +0 -1
- package/dist/shards/activate-sync-registry.test.js +0 -42
- package/dist/testing.d.ts +0 -3
- package/dist/testing.js +0 -77
- package/dist/testing.js.map +0 -7
- /package/dist/{documents/sync/activate-integration.test.d.ts → app/store/storeShard.svelte.test.d.ts} +0 -0
- /package/dist/documents/{sync/handle.test.d.ts → handle.test.d.ts} +0 -0
- /package/dist/{documents/sync/conflicts.test.d.ts → registry/permission-descriptions.test.d.ts} +0 -0
|
@@ -16,7 +16,9 @@ import StoreView from './StoreView.svelte';
|
|
|
16
16
|
import InstalledView from './InstalledView.svelte';
|
|
17
17
|
import { fetchRegistries, fetchBundle, fetchServerBundle, buildPackageMeta } from '../../registry/client';
|
|
18
18
|
import { installPackage, listInstalledPackages } from '../../registry/installer';
|
|
19
|
-
import { loadBundle, savePackage } from '../../registry/storage';
|
|
19
|
+
import { loadBundle, loadMeta, savePackage } from '../../registry/storage';
|
|
20
|
+
import { loadBundleModule } from '../../registry/loader';
|
|
21
|
+
import { extractBundlePermissions } from '../../registry/permission-descriptions';
|
|
20
22
|
import { serverInstallPackage, fetchServerPackages } from '../../env/client';
|
|
21
23
|
import { VERSION } from '../../version';
|
|
22
24
|
import { installVerb, uninstallVerb, appinfoVerb } from './verbs';
|
|
@@ -41,6 +43,19 @@ function isNewerVersion(available, installed) {
|
|
|
41
43
|
}
|
|
42
44
|
return false;
|
|
43
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Compute added and removed permissions between two manifest snapshots.
|
|
48
|
+
* Order within each array follows the input order of `newPerms` (for added)
|
|
49
|
+
* and `oldPerms` (for removed). No duplicates — both inputs assumed unique.
|
|
50
|
+
*/
|
|
51
|
+
export function diffPermissions(oldPerms, newPerms) {
|
|
52
|
+
const oldSet = new Set(oldPerms);
|
|
53
|
+
const newSet = new Set(newPerms);
|
|
54
|
+
return {
|
|
55
|
+
added: newPerms.filter((p) => !oldSet.has(p)),
|
|
56
|
+
removed: oldPerms.filter((p) => !newSet.has(p)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
44
59
|
/**
|
|
45
60
|
* Module-level context set during activate(). Imported by the Svelte
|
|
46
61
|
* view components so they can read/write store state and trigger refreshes.
|
|
@@ -105,6 +120,7 @@ export const storeShard = {
|
|
|
105
120
|
sourceRegistry: (_a = p.sourceRegistry) !== null && _a !== void 0 ? _a : '',
|
|
106
121
|
contractVersion: (_b = p.contractVersion) !== null && _b !== void 0 ? _b : '',
|
|
107
122
|
installedAt: (_c = p.installedAt) !== null && _c !== void 0 ? _c : '',
|
|
123
|
+
permissions: [],
|
|
108
124
|
});
|
|
109
125
|
});
|
|
110
126
|
recomputeUpdatable();
|
|
@@ -133,7 +149,7 @@ export const storeShard = {
|
|
|
133
149
|
const registries = env.registries.filter((r) => r !== url);
|
|
134
150
|
await ctx.envUpdate({ registries });
|
|
135
151
|
}
|
|
136
|
-
async function updatePackage(id) {
|
|
152
|
+
async function updatePackage(id, confirmPermissionChange) {
|
|
137
153
|
var _a, _b, _c, _d;
|
|
138
154
|
const catalogEntry = state.ephemeral.updatable[id];
|
|
139
155
|
if (!catalogEntry)
|
|
@@ -148,10 +164,37 @@ export const storeShard = {
|
|
|
148
164
|
serverBundle = await fetchServerBundle(catalogEntry.latest, catalogEntry.sourceRegistry);
|
|
149
165
|
}
|
|
150
166
|
const meta = buildPackageMeta(catalogEntry, catalogEntry.latest);
|
|
151
|
-
// 2.
|
|
167
|
+
// 2. Load the module once for permission extraction and install reuse.
|
|
168
|
+
const loaded = await loadBundleModule(bundle);
|
|
169
|
+
const newPerms = extractBundlePermissions(loaded);
|
|
170
|
+
// 3. Look up the locally persisted old permissions (server-sourced
|
|
171
|
+
// installed list doesn't carry permissions — per spec they live
|
|
172
|
+
// in the local IndexedDB record).
|
|
173
|
+
let oldPerms = [];
|
|
174
|
+
try {
|
|
175
|
+
const localMeta = await loadMeta(id);
|
|
176
|
+
if (localMeta === null || localMeta === void 0 ? void 0 : localMeta.permissions)
|
|
177
|
+
oldPerms = localMeta.permissions;
|
|
178
|
+
}
|
|
179
|
+
catch (_e) {
|
|
180
|
+
// No local record (e.g. installed on a different browser); treat as
|
|
181
|
+
// empty. The diff will show all new permissions as additions.
|
|
182
|
+
}
|
|
183
|
+
// 4. If the permission set changed and a confirmation callback was
|
|
184
|
+
// provided, await the user's decision before touching the server.
|
|
185
|
+
const { added, removed } = diffPermissions(oldPerms, newPerms);
|
|
186
|
+
if ((added.length > 0 || removed.length > 0) && confirmPermissionChange) {
|
|
187
|
+
const ok = await confirmPermissionChange(added, removed);
|
|
188
|
+
if (!ok)
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// 5. Snapshot current state for rollback. Preserve the locally-known
|
|
192
|
+
// permissions so the rollback write still satisfies the InstalledPackage
|
|
193
|
+
// contract (installedRecord came from the server-sourced list which
|
|
194
|
+
// lacks permissions).
|
|
152
195
|
const oldBundle = await loadBundle(id);
|
|
153
|
-
const oldRecord = Object.assign({}, installedRecord);
|
|
154
|
-
//
|
|
196
|
+
const oldRecord = Object.assign(Object.assign({}, installedRecord), { permissions: oldPerms });
|
|
197
|
+
// 6. Push to server.
|
|
155
198
|
const manifest = {
|
|
156
199
|
id: meta.id,
|
|
157
200
|
type: meta.type,
|
|
@@ -171,8 +214,9 @@ export const storeShard = {
|
|
|
171
214
|
}
|
|
172
215
|
throw new Error(message);
|
|
173
216
|
}
|
|
174
|
-
//
|
|
175
|
-
|
|
217
|
+
// 7. Install locally (overwrites IndexedDB + re-registers). Reuse the
|
|
218
|
+
// already-loaded bundle so the ESM is not evaluated twice.
|
|
219
|
+
const result = await installPackage(bundle, meta, { loaded });
|
|
176
220
|
if (!result.success) {
|
|
177
221
|
// Rollback: restore old bundle and metadata.
|
|
178
222
|
if (oldBundle) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { diffPermissions } from './storeShard.svelte';
|
|
3
|
+
describe('diffPermissions', () => {
|
|
4
|
+
it('returns empty added and removed when the sets are identical', () => {
|
|
5
|
+
expect(diffPermissions(['a', 'b'], ['a', 'b'])).toEqual({
|
|
6
|
+
added: [],
|
|
7
|
+
removed: [],
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
it('detects permissions added by the new version', () => {
|
|
11
|
+
expect(diffPermissions(['a'], ['a', 'b'])).toEqual({
|
|
12
|
+
added: ['b'],
|
|
13
|
+
removed: [],
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
it('detects permissions removed by the new version', () => {
|
|
17
|
+
expect(diffPermissions(['a', 'b'], ['a'])).toEqual({
|
|
18
|
+
added: [],
|
|
19
|
+
removed: ['b'],
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
it('reports both added and removed when the set changes in both directions', () => {
|
|
23
|
+
expect(diffPermissions(['a', 'b'], ['b', 'c'])).toEqual({
|
|
24
|
+
added: ['c'],
|
|
25
|
+
removed: ['a'],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it('treats an empty old set as "everything is new"', () => {
|
|
29
|
+
expect(diffPermissions([], ['a', 'b'])).toEqual({
|
|
30
|
+
added: ['a', 'b'],
|
|
31
|
+
removed: [],
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
package/dist/apps/types.d.ts
CHANGED
|
@@ -41,7 +41,9 @@ export interface AppManifest {
|
|
|
41
41
|
* Declared in the manifest and surfaced to the user at install time
|
|
42
42
|
* by the store app. Currently recognized:
|
|
43
43
|
* - 'state:manage' — cross-shard zone access.
|
|
44
|
-
*
|
|
44
|
+
*
|
|
45
|
+
* Sync-related permissions (`sync:policy`, `sync:peer`) are introduced
|
|
46
|
+
* in a later plan alongside the server-side sync runtime.
|
|
45
47
|
*/
|
|
46
48
|
permissions?: string[];
|
|
47
49
|
}
|
|
@@ -62,10 +64,6 @@ export interface AppContext {
|
|
|
62
64
|
* Cross-shard zone management API. Only present when the app's
|
|
63
65
|
* manifest declares the `'state:manage'` permission. Check with
|
|
64
66
|
* `if (ctx.zones)` before use.
|
|
65
|
-
*
|
|
66
|
-
* Related permissions also recognized by the framework:
|
|
67
|
-
* - 'documents:sync' — cross-shard document sync API (exposed on
|
|
68
|
-
* shard contexts as `ctx.sync()`, not on app contexts).
|
|
69
67
|
*/
|
|
70
68
|
zones?: ZoneManager;
|
|
71
69
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DocumentBackend, DocumentMeta } from './types';
|
|
2
|
+
import type { DocStatus } from './sync-types';
|
|
2
3
|
export declare class MemoryDocumentBackend implements DocumentBackend {
|
|
3
4
|
#private;
|
|
4
5
|
read(tenantId: string, shardId: string, path: string): Promise<string | ArrayBuffer | null>;
|
|
@@ -10,6 +11,7 @@ export declare class MemoryDocumentBackend implements DocumentBackend {
|
|
|
10
11
|
listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
|
|
11
12
|
shardId: string;
|
|
12
13
|
}>>;
|
|
14
|
+
readMeta(tenantId: string, shardId: string, path: string): Promise<DocStatus | null>;
|
|
13
15
|
}
|
|
14
16
|
export declare class IndexedDBDocumentBackend implements DocumentBackend {
|
|
15
17
|
#private;
|
|
@@ -92,6 +92,12 @@ export class MemoryDocumentBackend {
|
|
|
92
92
|
}
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
95
|
+
async readMeta(tenantId, shardId, path) {
|
|
96
|
+
const entry = __classPrivateFieldGet(this, _MemoryDocumentBackend_store, "f").get(compositeKey(tenantId, shardId, path));
|
|
97
|
+
if (!entry)
|
|
98
|
+
return null;
|
|
99
|
+
return { exists: true, version: 1, syncMode: 'sync', syncState: 'synced' };
|
|
100
|
+
}
|
|
95
101
|
}
|
|
96
102
|
_MemoryDocumentBackend_store = new WeakMap();
|
|
97
103
|
// ---------------------------------------------------------------------------
|
|
@@ -8,5 +8,35 @@ export interface BrowseCapability {
|
|
|
8
8
|
watchDocuments(callback: (change: DocumentChange) => void): () => void;
|
|
9
9
|
/** Enumerate shard ids with at least one document in the tenant. */
|
|
10
10
|
listShards(): Promise<string[]>;
|
|
11
|
+
/**
|
|
12
|
+
* Read content from any shard's document namespace within the active
|
|
13
|
+
* tenant. Available only when the caller declares both
|
|
14
|
+
* `documents:browse` and `documents:read`. Returns the content string
|
|
15
|
+
* (text-format docs), ArrayBuffer (binary-format docs), or null if the
|
|
16
|
+
* document does not exist. Tenant-scoped — cannot cross tenants.
|
|
17
|
+
*
|
|
18
|
+
* Absent (undefined) on the capability object when `documents:read`
|
|
19
|
+
* is not declared; feature-detect with
|
|
20
|
+
* `typeof ctx.browse.readFrom === 'function'`.
|
|
21
|
+
*/
|
|
22
|
+
readFrom?(shardId: string, path: string): Promise<string | ArrayBuffer | null>;
|
|
23
|
+
/**
|
|
24
|
+
* Write content into any shard's document namespace within the active
|
|
25
|
+
* tenant. Available only when the caller declares both
|
|
26
|
+
* `documents:browse` and `documents:write`. Emits a normal
|
|
27
|
+
* `DocumentChange` so other shards and the file-explorer pick up the
|
|
28
|
+
* new or updated document. Tenant-scoped — cannot cross tenants.
|
|
29
|
+
*
|
|
30
|
+
* Absent (undefined) on the capability object when `documents:write`
|
|
31
|
+
* is not declared; feature-detect with
|
|
32
|
+
* `typeof ctx.browse.writeTo === 'function'`.
|
|
33
|
+
*/
|
|
34
|
+
writeTo?(shardId: string, path: string, content: string | ArrayBuffer): Promise<void>;
|
|
11
35
|
}
|
|
12
|
-
export
|
|
36
|
+
export interface BrowseCapabilityOptions {
|
|
37
|
+
/** When true, the returned capability exposes `readFrom`. */
|
|
38
|
+
canRead: boolean;
|
|
39
|
+
/** When true, the returned capability exposes `writeTo`. */
|
|
40
|
+
canWrite: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function createBrowseCapability(tenantId: string, backend: DocumentBackend, options?: BrowseCapabilityOptions): BrowseCapability;
|
package/dist/documents/browse.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* the owning shard's own ctx.documents() handle.
|
|
7
7
|
*/
|
|
8
8
|
import { documentChanges } from './notifications';
|
|
9
|
-
export function createBrowseCapability(tenantId, backend) {
|
|
10
|
-
|
|
9
|
+
export function createBrowseCapability(tenantId, backend, options = { canRead: false, canWrite: false }) {
|
|
10
|
+
const capability = {
|
|
11
11
|
listDocuments: () => backend.listAllDocuments(tenantId),
|
|
12
12
|
listShards: () => backend.listAllShards(tenantId),
|
|
13
13
|
watchDocuments: (callback) => documentChanges.subscribe((change) => {
|
|
@@ -16,4 +16,20 @@ export function createBrowseCapability(tenantId, backend) {
|
|
|
16
16
|
callback(change);
|
|
17
17
|
}),
|
|
18
18
|
};
|
|
19
|
+
if (options.canRead) {
|
|
20
|
+
capability.readFrom = (shardId, path) => backend.read(tenantId, shardId, path);
|
|
21
|
+
}
|
|
22
|
+
if (options.canWrite) {
|
|
23
|
+
capability.writeTo = async (shardId, path, content) => {
|
|
24
|
+
const existed = await backend.exists(tenantId, shardId, path);
|
|
25
|
+
await backend.write(tenantId, shardId, path, content);
|
|
26
|
+
documentChanges.emit({
|
|
27
|
+
type: existed ? 'update' : 'create',
|
|
28
|
+
path,
|
|
29
|
+
tenantId,
|
|
30
|
+
shardId,
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return capability;
|
|
19
35
|
}
|
|
@@ -38,4 +38,85 @@ describe('BrowseCapability', () => {
|
|
|
38
38
|
documentChanges.emit({ type: 'create', path: 'f.txt', tenantId: 't1', shardId: 's1' });
|
|
39
39
|
expect(cb).not.toHaveBeenCalled();
|
|
40
40
|
});
|
|
41
|
+
describe('readFrom (documents:read gate)', () => {
|
|
42
|
+
it('is absent when canRead is false', () => {
|
|
43
|
+
const be = new MemoryDocumentBackend();
|
|
44
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
|
|
45
|
+
expect(browse.readFrom).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
it('is present when canRead is true', () => {
|
|
48
|
+
const be = new MemoryDocumentBackend();
|
|
49
|
+
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
50
|
+
expect(typeof browse.readFrom).toBe('function');
|
|
51
|
+
});
|
|
52
|
+
it('reads content from a foreign shard namespace', async () => {
|
|
53
|
+
const be = new MemoryDocumentBackend();
|
|
54
|
+
await be.write('t1', 'other', 'notes/ideas.md', 'hello');
|
|
55
|
+
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
56
|
+
expect(await browse.readFrom('other', 'notes/ideas.md')).toBe('hello');
|
|
57
|
+
});
|
|
58
|
+
it('returns null when the foreign document does not exist', async () => {
|
|
59
|
+
const be = new MemoryDocumentBackend();
|
|
60
|
+
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
61
|
+
expect(await browse.readFrom('other', 'nope.txt')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
it('never crosses tenants: a t1 capability cannot read t2 docs', async () => {
|
|
64
|
+
const be = new MemoryDocumentBackend();
|
|
65
|
+
await be.write('t2', 's', 'secret.txt', 'hidden');
|
|
66
|
+
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
67
|
+
expect(await browse.readFrom('s', 'secret.txt')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('writeTo (documents:write gate)', () => {
|
|
71
|
+
it('is absent when canWrite is false', () => {
|
|
72
|
+
const be = new MemoryDocumentBackend();
|
|
73
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: false });
|
|
74
|
+
expect(browse.writeTo).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
it('is present when canWrite is true', () => {
|
|
77
|
+
const be = new MemoryDocumentBackend();
|
|
78
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
79
|
+
expect(typeof browse.writeTo).toBe('function');
|
|
80
|
+
});
|
|
81
|
+
it('writes into the target shard namespace and emits a create change', async () => {
|
|
82
|
+
const be = new MemoryDocumentBackend();
|
|
83
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
84
|
+
const events = [];
|
|
85
|
+
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
86
|
+
await browse.writeTo('target-shard', 'notes/ideas.md', 'hello');
|
|
87
|
+
expect(await be.read('t1', 'target-shard', 'notes/ideas.md')).toBe('hello');
|
|
88
|
+
expect(events).toEqual([
|
|
89
|
+
{ type: 'create', path: 'notes/ideas.md', tenantId: 't1', shardId: 'target-shard' },
|
|
90
|
+
]);
|
|
91
|
+
unsub();
|
|
92
|
+
});
|
|
93
|
+
it('emits update (not create) when overwriting an existing document', async () => {
|
|
94
|
+
const be = new MemoryDocumentBackend();
|
|
95
|
+
await be.write('t1', 'target-shard', 'a.txt', 'v1');
|
|
96
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
97
|
+
const events = [];
|
|
98
|
+
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
99
|
+
await browse.writeTo('target-shard', 'a.txt', 'v2');
|
|
100
|
+
expect(await be.read('t1', 'target-shard', 'a.txt')).toBe('v2');
|
|
101
|
+
expect(events).toEqual([
|
|
102
|
+
{ type: 'update', path: 'a.txt', tenantId: 't1', shardId: 'target-shard' },
|
|
103
|
+
]);
|
|
104
|
+
unsub();
|
|
105
|
+
});
|
|
106
|
+
it('never crosses tenants: cannot be tricked into writing into tenant b', async () => {
|
|
107
|
+
const be = new MemoryDocumentBackend();
|
|
108
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
109
|
+
await browse.writeTo('s', 'a.txt', 'x');
|
|
110
|
+
expect(await be.read('t1', 's', 'a.txt')).toBe('x');
|
|
111
|
+
expect(await be.read('t2', 's', 'a.txt')).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('read + write combined', () => {
|
|
115
|
+
it('round-trips content through readFrom after writeTo', async () => {
|
|
116
|
+
const be = new MemoryDocumentBackend();
|
|
117
|
+
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: true });
|
|
118
|
+
await browse.writeTo('shard-x', 'doc.txt', 'roundtrip');
|
|
119
|
+
expect(await browse.readFrom('shard-x', 'doc.txt')).toBe('roundtrip');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
41
122
|
});
|
package/dist/documents/handle.js
CHANGED
|
@@ -18,8 +18,6 @@ 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';
|
|
23
21
|
const DEFAULT_DEBOUNCE_MS = 1000;
|
|
24
22
|
/**
|
|
25
23
|
* Create a document handle scoped to a tenant, shard, and file filter.
|
|
@@ -55,17 +53,27 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
|
|
|
55
53
|
const existed = await backend.exists(tenantId, shardId, path);
|
|
56
54
|
await backend.write(tenantId, shardId, path, content);
|
|
57
55
|
emitChange(existed ? 'update' : 'create', path);
|
|
58
|
-
const hash = await hashContent(content);
|
|
59
|
-
await notifyJournal({ shardId, path, op: 'upsert', hash });
|
|
60
56
|
},
|
|
61
57
|
async delete(path) {
|
|
62
58
|
await backend.delete(tenantId, shardId, path);
|
|
63
59
|
emitChange('delete', path);
|
|
64
|
-
await notifyJournal({ shardId, path, op: 'delete', hash: null });
|
|
65
60
|
},
|
|
66
61
|
async exists(path) {
|
|
67
62
|
return backend.exists(tenantId, shardId, path);
|
|
68
63
|
},
|
|
64
|
+
async status(path) {
|
|
65
|
+
if (!backend.readMeta)
|
|
66
|
+
throw new Error('Backend does not support status()');
|
|
67
|
+
return backend.readMeta(tenantId, shardId, path);
|
|
68
|
+
},
|
|
69
|
+
async resolveConflict(path, choice) {
|
|
70
|
+
if (!backend.resolve)
|
|
71
|
+
throw new Error('Backend does not support resolveConflict()');
|
|
72
|
+
if (typeof choice !== 'string' && !(typeof choice === 'object' && 'origin' in choice)) {
|
|
73
|
+
throw new Error('choice must be a string or { origin } object');
|
|
74
|
+
}
|
|
75
|
+
return backend.resolve(tenantId, shardId, path, choice);
|
|
76
|
+
},
|
|
69
77
|
watch(callback) {
|
|
70
78
|
// Subscribe to global emitter, filtered to this handle's scope.
|
|
71
79
|
const unsub = documentChanges.subscribe((change) => {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from './backends';
|
|
3
|
+
import { createDocumentHandle } from './handle';
|
|
4
|
+
function harness() {
|
|
5
|
+
const backend = new MemoryDocumentBackend();
|
|
6
|
+
const handle = createDocumentHandle('tenant1', 'shard1', backend, { format: 'text' });
|
|
7
|
+
return { backend, handle };
|
|
8
|
+
}
|
|
9
|
+
describe('DocumentHandle.status()', () => {
|
|
10
|
+
it('returns null for a missing doc', async () => {
|
|
11
|
+
const { handle } = harness();
|
|
12
|
+
expect(await handle.status('nope.txt')).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
it('returns DocStatus after a write', async () => {
|
|
15
|
+
const { handle } = harness();
|
|
16
|
+
await handle.write('a.txt', 'hi');
|
|
17
|
+
const s = await handle.status('a.txt');
|
|
18
|
+
expect(s).toMatchObject({ exists: true, version: 1, syncState: 'synced' });
|
|
19
|
+
});
|
|
20
|
+
it('throws if the backend does not implement readMeta', async () => {
|
|
21
|
+
const backend = {
|
|
22
|
+
async read() { return null; },
|
|
23
|
+
async write() { },
|
|
24
|
+
async delete() { },
|
|
25
|
+
async list() { return []; },
|
|
26
|
+
async exists() { return false; },
|
|
27
|
+
async listAllShards() { return []; },
|
|
28
|
+
async listAllDocuments() { return []; },
|
|
29
|
+
};
|
|
30
|
+
const handle = createDocumentHandle('t', 's', backend, { format: 'text' });
|
|
31
|
+
await expect(handle.status('a.txt')).rejects.toThrow(/status/);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('DocumentHandle.resolveConflict()', () => {
|
|
35
|
+
it('delegates to backend.resolve with the chosen origin', async () => {
|
|
36
|
+
const resolved = [];
|
|
37
|
+
const backend = {
|
|
38
|
+
async read() { return null; },
|
|
39
|
+
async write() { },
|
|
40
|
+
async delete() { },
|
|
41
|
+
async list() { return []; },
|
|
42
|
+
async exists() { return false; },
|
|
43
|
+
async listAllShards() { return []; },
|
|
44
|
+
async listAllDocuments() { return []; },
|
|
45
|
+
async resolve(t, s, p, c) { resolved.push({ t, s, p, c }); },
|
|
46
|
+
};
|
|
47
|
+
const handle = createDocumentHandle('tenant1', 'shard1', backend, { format: 'text' });
|
|
48
|
+
await handle.resolveConflict('a.txt', 'local');
|
|
49
|
+
expect(resolved).toEqual([{ t: 'tenant1', s: 'shard1', p: 'a.txt', c: 'local' }]);
|
|
50
|
+
});
|
|
51
|
+
it('throws if the backend does not implement resolve', async () => {
|
|
52
|
+
const { handle } = harness();
|
|
53
|
+
await expect(handle.resolveConflict('a.txt', 'local')).rejects.toThrow(/resolveConflict/);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP document backend — connects to sh3-server's document API.
|
|
3
3
|
*
|
|
4
|
-
* Implements the DocumentBackend interface over HTTP.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Implements the DocumentBackend interface over HTTP. Every request
|
|
5
|
+
* includes credentials (session cookie) so a logged-in user's tenant
|
|
6
|
+
* identity reaches sh3-server; write operations additionally send the
|
|
7
|
+
* API key as a Bearer token. This is the web-hosted equivalent of the
|
|
8
|
+
* Tauri filesystem backend. Server open-mode (auth.required=false)
|
|
9
|
+
* bypasses tenant checks regardless.
|
|
8
10
|
*/
|
|
9
11
|
import type { DocumentBackend, DocumentMeta } from './types';
|
|
12
|
+
import type { DocStatus } from './sync-types';
|
|
10
13
|
export declare class HttpDocumentBackend implements DocumentBackend {
|
|
11
14
|
#private;
|
|
12
15
|
/**
|
|
@@ -23,4 +26,8 @@ export declare class HttpDocumentBackend implements DocumentBackend {
|
|
|
23
26
|
listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
|
|
24
27
|
shardId: string;
|
|
25
28
|
}>>;
|
|
29
|
+
readMeta(tenantId: string, shardId: string, path: string): Promise<DocStatus | null>;
|
|
30
|
+
resolve(tenantId: string, shardId: string, path: string, choice: 'local' | 'remote' | {
|
|
31
|
+
origin: string;
|
|
32
|
+
} | string): Promise<void>;
|
|
26
33
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP document backend — connects to sh3-server's document API.
|
|
3
3
|
*
|
|
4
|
-
* Implements the DocumentBackend interface over HTTP.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Implements the DocumentBackend interface over HTTP. Every request
|
|
5
|
+
* includes credentials (session cookie) so a logged-in user's tenant
|
|
6
|
+
* identity reaches sh3-server; write operations additionally send the
|
|
7
|
+
* API key as a Bearer token. This is the web-hosted equivalent of the
|
|
8
|
+
* Tauri filesystem backend. Server open-mode (auth.required=false)
|
|
9
|
+
* bypasses tenant checks regardless.
|
|
8
10
|
*/
|
|
9
11
|
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
10
12
|
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
@@ -34,7 +36,7 @@ export class HttpDocumentBackend {
|
|
|
34
36
|
async read(tenantId, shardId, path) {
|
|
35
37
|
var _a;
|
|
36
38
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
|
|
37
|
-
const res = await fetch(url);
|
|
39
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
38
40
|
if (res.status === 404)
|
|
39
41
|
return null;
|
|
40
42
|
if (!res.ok)
|
|
@@ -48,42 +50,66 @@ export class HttpDocumentBackend {
|
|
|
48
50
|
async write(tenantId, shardId, path, content) {
|
|
49
51
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
|
|
50
52
|
const headers = Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': typeof content === 'string' ? 'text/plain' : 'application/octet-stream' });
|
|
51
|
-
const res = await fetch(url, { method: 'PUT', headers, body: content });
|
|
53
|
+
const res = await fetch(url, { method: 'PUT', headers, body: content, credentials: 'include' });
|
|
52
54
|
if (!res.ok)
|
|
53
55
|
throw new Error(`Document write failed: ${res.status}`);
|
|
54
56
|
}
|
|
55
57
|
async delete(tenantId, shardId, path) {
|
|
56
58
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
|
|
57
|
-
const res = await fetch(url, { method: 'DELETE', headers: __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this) });
|
|
59
|
+
const res = await fetch(url, { method: 'DELETE', headers: __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this), credentials: 'include' });
|
|
58
60
|
if (!res.ok)
|
|
59
61
|
throw new Error(`Document delete failed: ${res.status}`);
|
|
60
62
|
}
|
|
61
63
|
async list(tenantId, shardId) {
|
|
62
64
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}`;
|
|
63
|
-
const res = await fetch(url);
|
|
65
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
64
66
|
if (!res.ok)
|
|
65
67
|
throw new Error(`Document list failed: ${res.status}`);
|
|
66
68
|
return res.json();
|
|
67
69
|
}
|
|
68
70
|
async exists(tenantId, shardId, path) {
|
|
69
71
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
|
|
70
|
-
const res = await fetch(url, { method: 'HEAD' });
|
|
72
|
+
const res = await fetch(url, { method: 'HEAD', credentials: 'include' });
|
|
71
73
|
return res.ok;
|
|
72
74
|
}
|
|
73
75
|
async listAllShards(tenantId) {
|
|
74
76
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/_shards`;
|
|
75
|
-
const res = await fetch(url);
|
|
77
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
76
78
|
if (!res.ok)
|
|
77
79
|
throw new Error(`listAllShards failed: ${res.status}`);
|
|
78
80
|
return res.json();
|
|
79
81
|
}
|
|
80
82
|
async listAllDocuments(tenantId) {
|
|
81
83
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/_all`;
|
|
82
|
-
const res = await fetch(url);
|
|
84
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
83
85
|
if (!res.ok)
|
|
84
86
|
throw new Error(`listAllDocuments failed: ${res.status}`);
|
|
85
87
|
return res.json();
|
|
86
88
|
}
|
|
89
|
+
async readMeta(tenantId, shardId, path) {
|
|
90
|
+
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}?meta=1`;
|
|
91
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
92
|
+
if (!res.ok)
|
|
93
|
+
throw new Error(`readMeta failed: ${res.status}`);
|
|
94
|
+
const body = await res.json();
|
|
95
|
+
if (!body.exists)
|
|
96
|
+
return null;
|
|
97
|
+
return body;
|
|
98
|
+
}
|
|
99
|
+
async resolve(tenantId, shardId, path, choice) {
|
|
100
|
+
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}/resolve`;
|
|
101
|
+
const body = typeof choice === 'string'
|
|
102
|
+
? { choice }
|
|
103
|
+
: { choice: choice.origin };
|
|
104
|
+
const res = await fetch(url, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
credentials: 'include',
|
|
107
|
+
headers: Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': 'application/json' }),
|
|
108
|
+
body: JSON.stringify(body),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok)
|
|
111
|
+
throw new Error(`resolve failed: ${res.status}`);
|
|
112
|
+
}
|
|
87
113
|
}
|
|
88
114
|
_HttpDocumentBackend_baseUrl = new WeakMap(), _HttpDocumentBackend_apiKey = new WeakMap(), _HttpDocumentBackend_instances = new WeakSet(), _HttpDocumentBackend_authHeaders = function _HttpDocumentBackend_authHeaders() {
|
|
89
115
|
if (!__classPrivateFieldGet(this, _HttpDocumentBackend_apiKey, "f"))
|
|
@@ -4,4 +4,5 @@ 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
|
|
7
|
+
export type { SyncPolicy, SyncPolicyRule, DocStatus, ConflictFile, ConflictBranch, } from './sync-types';
|
|
8
|
+
export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY, } from './sync-types';
|
package/dist/documents/index.js
CHANGED
|
@@ -6,4 +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
|
|
9
|
+
export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY, } from './sync-types';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Server-side sync policy (ADR-019 §9). Lives at {tenant}/__sync__/policy.json. */
|
|
2
|
+
export interface SyncPolicy {
|
|
3
|
+
/** Schema version. Increment when the rule format changes. */
|
|
4
|
+
version: number;
|
|
5
|
+
/** Mode applied when no rule matches. */
|
|
6
|
+
default: 'sync' | 'local-only';
|
|
7
|
+
/** First-match-wins rules. Glob syntax: `*`, `**`, `?`. No regex. */
|
|
8
|
+
rules: SyncPolicyRule[];
|
|
9
|
+
}
|
|
10
|
+
export interface SyncPolicyRule {
|
|
11
|
+
path: string;
|
|
12
|
+
mode: 'sync' | 'local-only';
|
|
13
|
+
}
|
|
14
|
+
/** Per-document status, returned by DocumentHandle.status(). */
|
|
15
|
+
export interface DocStatus {
|
|
16
|
+
exists: boolean;
|
|
17
|
+
version: number;
|
|
18
|
+
syncMode: 'sync' | 'local-only';
|
|
19
|
+
syncState: 'synced' | 'pending' | 'conflict';
|
|
20
|
+
lastSyncedAt?: number;
|
|
21
|
+
origin?: string;
|
|
22
|
+
deleted?: boolean;
|
|
23
|
+
/** Conflict branches, only populated when syncState === 'conflict'. */
|
|
24
|
+
branches?: Array<{
|
|
25
|
+
origin: string;
|
|
26
|
+
version: number;
|
|
27
|
+
at: number;
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
/** Conflict file shape under {tenant}/__sync__/conflicts/<shardId>/<path>.conflict.json. */
|
|
31
|
+
export interface ConflictFile {
|
|
32
|
+
path: string;
|
|
33
|
+
shardId: string;
|
|
34
|
+
branches: ConflictBranch[];
|
|
35
|
+
}
|
|
36
|
+
export interface ConflictBranch {
|
|
37
|
+
origin: string;
|
|
38
|
+
version: number;
|
|
39
|
+
content: string;
|
|
40
|
+
at: number;
|
|
41
|
+
}
|
|
42
|
+
/** Server-shard permission: Mode B writes + reserved __sync__ read access. */
|
|
43
|
+
export declare const PERMISSION_SYNC_PEER = "sync:peer";
|
|
44
|
+
/** Read/write __sync__/policy.json. Grantable to client or server shards. */
|
|
45
|
+
export declare const PERMISSION_SYNC_POLICY = "sync:policy";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Sync vocabulary — type-only contract surface (ADR-019 revised 2026-04-19).
|
|
3
|
+
*
|
|
4
|
+
* The sync runtime lives in the installable sh3-sync shard. sh3-core exposes
|
|
5
|
+
* only the types that client shards and the shard-facing ServerShardContext
|
|
6
|
+
* share with the runtime. No runtime code in this file.
|
|
7
|
+
*/
|
|
8
|
+
/** Server-shard permission: Mode B writes + reserved __sync__ read access. */
|
|
9
|
+
export const PERMISSION_SYNC_PEER = 'sync:peer';
|
|
10
|
+
/** Read/write __sync__/policy.json. Grantable to client or server shards. */
|
|
11
|
+
export const PERMISSION_SYNC_POLICY = 'sync:policy';
|