sh3-core 0.8.2 → 0.9.0
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 +3 -6
- package/dist/api.js +1 -3
- 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/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 +40 -2
- package/dist/documents/types.js +3 -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/server-shard/types.d.ts +55 -8
- package/dist/shards/activate.svelte.js +4 -29
- 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.d.ts +0 -1
- 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.d.ts +0 -1
- 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/handle.test.d.ts → handle.test.d.ts} +0 -0
package/dist/api.d.ts
CHANGED
|
@@ -19,11 +19,8 @@ export { inspectActiveLayout, spliceIntoActiveLayout, dockIntoActiveLayout, focu
|
|
|
19
19
|
export type { DocumentHandle, DocumentHandleOptions, DocumentFormat, DocumentMeta, DocumentChange, AutosaveController, } from './documents/types';
|
|
20
20
|
export { PERMISSION_DOCUMENTS_BROWSE } from './documents/types';
|
|
21
21
|
export type { BrowseCapability } from './documents/browse';
|
|
22
|
-
export type {
|
|
23
|
-
export {
|
|
24
|
-
export type { SyncRegistry } from './documents/sync/registry';
|
|
25
|
-
export { default as SyncGrantPicker } from './documents/sync/components/SyncGrantPicker.svelte';
|
|
26
|
-
export { default as DocumentSyncExplorer } from './documents/sync/components/DocumentSyncExplorer.svelte';
|
|
22
|
+
export type { SyncPolicy, SyncPolicyRule, DocStatus, ConflictFile, ConflictBranch, } from './documents/sync-types';
|
|
23
|
+
export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY } from './documents/sync-types';
|
|
27
24
|
export { registeredShards, activeShards } from './shards/activate.svelte';
|
|
28
25
|
export type { RegistryIndex, PackageEntry, PackageVersion, RequiredDependency, InstalledPackage, InstallResult, PackageMeta, } from './registry/types';
|
|
29
26
|
export type { ResolvedPackage } from './registry/client';
|
|
@@ -39,7 +36,7 @@ export declare const capabilities: {
|
|
|
39
36
|
/** Whether this target supports hot-installing packages via dynamic import from blob URL. */
|
|
40
37
|
readonly hotInstall: boolean;
|
|
41
38
|
};
|
|
42
|
-
export type { ServerShard, ServerShardContext } from './server-shard/types';
|
|
39
|
+
export type { ServerShard, ServerShardContext, TenantDocumentAPI } from './server-shard/types';
|
|
43
40
|
export type { Verb, VerbContext, ShellApi } from './verbs/types';
|
|
44
41
|
export type { Scrollback } from './shell-shard/scrollback.svelte';
|
|
45
42
|
export type { SessionClient } from './shell-shard/session-client.svelte';
|
package/dist/api.js
CHANGED
|
@@ -30,9 +30,7 @@ export { launchApp, returnToHome, unregisterApp } from './apps/lifecycle';
|
|
|
30
30
|
// Layout inspection / mutation for advanced shards (diagnostic, etc.).
|
|
31
31
|
export { inspectActiveLayout, spliceIntoActiveLayout, dockIntoActiveLayout, focusTab, focusView, collapseChild, expandChild, closeTab, } from './layout/inspection';
|
|
32
32
|
export { PERMISSION_DOCUMENTS_BROWSE } from './documents/types';
|
|
33
|
-
export {
|
|
34
|
-
export { default as SyncGrantPicker } from './documents/sync/components/SyncGrantPicker.svelte';
|
|
35
|
-
export { default as DocumentSyncExplorer } from './documents/sync/components/DocumentSyncExplorer.svelte';
|
|
33
|
+
export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY } from './documents/sync-types';
|
|
36
34
|
// Shard introspection — read-only reactive maps exposing which shards are
|
|
37
35
|
// known to the host and which are currently active. Intended for diagnostic
|
|
38
36
|
// and tooling shards that need to visualize framework state. Phase 9
|
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
|
// ---------------------------------------------------------------------------
|
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';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Manifest permission string: grants tenant-wide document observation
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* via `ctx.browse` (read-only enumeration and change subscription across
|
|
4
|
+
* every shard's documents for the active tenant). Writes still flow
|
|
5
|
+
* through the owning shard's own `ctx.documents()` handle.
|
|
5
6
|
*/
|
|
6
7
|
export declare const PERMISSION_DOCUMENTS_BROWSE = "documents:browse";
|
|
7
8
|
/**
|
|
@@ -25,6 +26,20 @@ export interface DocumentMeta {
|
|
|
25
26
|
size: number;
|
|
26
27
|
/** Last modified timestamp in epoch milliseconds. */
|
|
27
28
|
lastModified: number;
|
|
29
|
+
/** Monotonic per-document version; primary-authoritative. */
|
|
30
|
+
version?: number;
|
|
31
|
+
/** 'sync' or 'local-only', cached from policy.json at write time. */
|
|
32
|
+
syncMode?: 'sync' | 'local-only';
|
|
33
|
+
/** 'synced' | 'pending' | 'conflict'. */
|
|
34
|
+
syncState?: 'synced' | 'pending' | 'conflict';
|
|
35
|
+
/** Replica's view of primary's version at last confirmed sync. */
|
|
36
|
+
lastKnownVersion?: number;
|
|
37
|
+
/** Epoch ms; when replica/primary last agreed on content. */
|
|
38
|
+
lastSyncedAt?: number;
|
|
39
|
+
/** Peer id that produced this content (set by Mode B). */
|
|
40
|
+
origin?: string;
|
|
41
|
+
/** Tombstone marker for deleted docs. */
|
|
42
|
+
deleted?: boolean;
|
|
28
43
|
}
|
|
29
44
|
/** Change notification payload delivered to watch callbacks. */
|
|
30
45
|
export interface DocumentChange {
|
|
@@ -33,6 +48,7 @@ export interface DocumentChange {
|
|
|
33
48
|
tenantId: string;
|
|
34
49
|
shardId: string;
|
|
35
50
|
}
|
|
51
|
+
import type { DocStatus } from './sync-types';
|
|
36
52
|
/**
|
|
37
53
|
* File-oriented backend for the document zone.
|
|
38
54
|
*
|
|
@@ -68,6 +84,19 @@ export interface DocumentBackend {
|
|
|
68
84
|
listAllDocuments(tenantId: string): Promise<Array<DocumentMeta & {
|
|
69
85
|
shardId: string;
|
|
70
86
|
}>>;
|
|
87
|
+
/**
|
|
88
|
+
* Read sync-state metadata for a doc without fetching content.
|
|
89
|
+
* Returns null if the doc does not exist. Optional because only
|
|
90
|
+
* sync-aware backends (HttpDocumentBackend) support it.
|
|
91
|
+
*/
|
|
92
|
+
readMeta?(tenantId: string, shardId: string, path: string): Promise<DocStatus | null>;
|
|
93
|
+
/**
|
|
94
|
+
* Resolve a conflict by picking a branch or supplying fresh content.
|
|
95
|
+
* Optional; only supported by HttpDocumentBackend in v1.
|
|
96
|
+
*/
|
|
97
|
+
resolve?(tenantId: string, shardId: string, path: string, choice: 'local' | 'remote' | {
|
|
98
|
+
origin: string;
|
|
99
|
+
} | string): Promise<void>;
|
|
71
100
|
}
|
|
72
101
|
/**
|
|
73
102
|
* Shard-facing document handle returned by `ctx.documents()`. Binds
|
|
@@ -84,6 +113,15 @@ export interface DocumentHandle {
|
|
|
84
113
|
delete(path: string): Promise<void>;
|
|
85
114
|
/** Check existence without reading content. */
|
|
86
115
|
exists(path: string): Promise<boolean>;
|
|
116
|
+
/** Fetch sync-state metadata for a path. Null if the doc does not exist. */
|
|
117
|
+
status(path: string): Promise<DocStatus | null>;
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a conflict. `'local'` keeps the local branch; any other
|
|
120
|
+
* origin string picks the named branch from the conflict bucket.
|
|
121
|
+
*/
|
|
122
|
+
resolveConflict(path: string, choice: 'local' | 'remote' | {
|
|
123
|
+
origin: string;
|
|
124
|
+
} | string): Promise<void>;
|
|
87
125
|
/**
|
|
88
126
|
* Subscribe to change notifications within this handle's scope.
|
|
89
127
|
* Returns an unsubscribe function.
|
package/dist/documents/types.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
/**
|
|
13
13
|
* Manifest permission string: grants tenant-wide document observation
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* via `ctx.browse` (read-only enumeration and change subscription across
|
|
15
|
+
* every shard's documents for the active tenant). Writes still flow
|
|
16
|
+
* through the owning shard's own `ctx.documents()` handle.
|
|
16
17
|
*/
|
|
17
18
|
export const PERMISSION_DOCUMENTS_BROWSE = 'documents:browse';
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Mounted once by the shell at boot; never by shards.
|
|
6
6
|
|
|
7
|
-
Security: all shard-provided strings (shardId, label, scope,
|
|
7
|
+
Security: all shard-provided strings (shardId, label, scope, peerId)
|
|
8
8
|
are rendered as plain text via Svelte's default interpolation — no @html.
|
|
9
9
|
-->
|
|
10
10
|
<script lang="ts">
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
<code class="sh3-consent-scope">{s}</code>
|
|
46
46
|
{/each}
|
|
47
47
|
</dd>
|
|
48
|
-
{#if current.
|
|
49
|
-
<dt>
|
|
50
|
-
<dd>{current.
|
|
48
|
+
{#if current.peerRole || current.peerId}
|
|
49
|
+
<dt>Peer</dt>
|
|
50
|
+
<dd>{current.peerRole ?? '—'}{current.peerId ? ` · ${current.peerId}` : ''}</dd>
|
|
51
51
|
{/if}
|
|
52
52
|
{#if current.expiresIn}
|
|
53
53
|
<dt>Expires in</dt>
|
|
@@ -14,13 +14,14 @@ describe('consent runtime', () => {
|
|
|
14
14
|
queueMicrotask(() => resolveConsent(req.requestId, true));
|
|
15
15
|
});
|
|
16
16
|
cleanups.push(off);
|
|
17
|
-
await requestConsent('shard-a', { label: 'My key', scopes: ['
|
|
17
|
+
await requestConsent('shard-a', { label: 'My key', scopes: ['sync:peer'], peerRole: 'replica', peerId: 'peer-x' });
|
|
18
18
|
expect(received).toHaveLength(1);
|
|
19
19
|
const req = received[0];
|
|
20
20
|
expect(req.shardId).toBe('shard-a');
|
|
21
21
|
expect(req.label).toBe('My key');
|
|
22
|
-
expect(req.scopes).toEqual(['
|
|
23
|
-
expect(req.
|
|
22
|
+
expect(req.scopes).toEqual(['sync:peer']);
|
|
23
|
+
expect(req.peerRole).toBe('replica');
|
|
24
|
+
expect(req.peerId).toBe('peer-x');
|
|
24
25
|
expect(typeof req.requestId).toBe('string');
|
|
25
26
|
});
|
|
26
27
|
it('resolves true when approved', async () => {
|
package/dist/keys/types.d.ts
CHANGED
|
@@ -6,14 +6,16 @@ export interface ApiKeyPublic {
|
|
|
6
6
|
ownerUserId: string | null;
|
|
7
7
|
mintedByShardId: string | null;
|
|
8
8
|
scopes: string[];
|
|
9
|
-
|
|
9
|
+
peerRole?: 'primary' | 'replica';
|
|
10
|
+
peerId?: string;
|
|
10
11
|
createdAt: string;
|
|
11
12
|
expiresAt?: string;
|
|
12
13
|
}
|
|
13
14
|
export interface MintOpts {
|
|
14
15
|
label: string;
|
|
15
16
|
scopes: string[];
|
|
16
|
-
|
|
17
|
+
peerRole?: 'primary' | 'replica';
|
|
18
|
+
peerId?: string;
|
|
17
19
|
expiresIn?: number;
|
|
18
20
|
}
|
|
19
21
|
export interface ShardContextKeys {
|
|
@@ -10,8 +10,51 @@
|
|
|
10
10
|
* The server bundle is a separate ESM file whose default export conforms
|
|
11
11
|
* to the `ServerShard` interface.
|
|
12
12
|
*/
|
|
13
|
-
import type {
|
|
14
|
-
import type {
|
|
13
|
+
import type { DocumentMeta } from '../documents/types';
|
|
14
|
+
import type { SyncPolicy, ConflictFile } from '../documents/sync-types';
|
|
15
|
+
/**
|
|
16
|
+
* Per-tenant document API exposed to server shards via
|
|
17
|
+
* `ServerShardContext.documents(tenantId)`. Every method is
|
|
18
|
+
* permission-checked by the host at call time.
|
|
19
|
+
*/
|
|
20
|
+
export interface TenantDocumentAPI {
|
|
21
|
+
read(shardId: string, path: string): Promise<string | null>;
|
|
22
|
+
exists(shardId: string, path: string): Promise<boolean>;
|
|
23
|
+
list(shardId: string): Promise<DocumentMeta[]>;
|
|
24
|
+
listAll(): Promise<Array<DocumentMeta & {
|
|
25
|
+
shardId: string;
|
|
26
|
+
}>>;
|
|
27
|
+
write(shardId: string, path: string, content: string | Uint8Array, metadata?: Record<string, unknown>): Promise<{
|
|
28
|
+
version: number;
|
|
29
|
+
syncState: 'synced' | 'pending';
|
|
30
|
+
}>;
|
|
31
|
+
delete(shardId: string, path: string): Promise<void>;
|
|
32
|
+
applyFromPeer(input: {
|
|
33
|
+
shardId: string;
|
|
34
|
+
path: string;
|
|
35
|
+
content: string | Uint8Array;
|
|
36
|
+
incomingVersion: number;
|
|
37
|
+
expectedLocalVersion: number;
|
|
38
|
+
origin: string;
|
|
39
|
+
deleted?: boolean;
|
|
40
|
+
metadata?: Record<string, unknown>;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
applied: true;
|
|
43
|
+
version: number;
|
|
44
|
+
} | {
|
|
45
|
+
applied: false;
|
|
46
|
+
reason: 'stale' | 'conflict' | 'conflict-extended';
|
|
47
|
+
}>;
|
|
48
|
+
getTick(): Promise<number>;
|
|
49
|
+
readPolicy(): Promise<SyncPolicy | null>;
|
|
50
|
+
writePolicy(policy: SyncPolicy): Promise<void>;
|
|
51
|
+
listConflicts(): Promise<Array<{
|
|
52
|
+
shardId: string;
|
|
53
|
+
path: string;
|
|
54
|
+
}>>;
|
|
55
|
+
readConflict(shardId: string, path: string): Promise<ConflictFile | null>;
|
|
56
|
+
resolveConflict(shardId: string, path: string, choice: 'local' | string | Uint8Array): Promise<void>;
|
|
57
|
+
}
|
|
15
58
|
/**
|
|
16
59
|
* Context provided by sh3-server when mounting a server shard's routes.
|
|
17
60
|
*/
|
|
@@ -41,14 +84,16 @@ export interface ServerShardContext {
|
|
|
41
84
|
* WebSocket upgrade registration — unchanged from 0.8.1.
|
|
42
85
|
*/
|
|
43
86
|
wsRegister?: (onConnect: (ws: any, c: any) => void) => any;
|
|
87
|
+
/** Tenant ids that currently have doc content on this server. */
|
|
88
|
+
tenants(): string[];
|
|
89
|
+
/** Per-tenant document API, permission-checked per operation. */
|
|
90
|
+
documents(tenant: string): TenantDocumentAPI;
|
|
44
91
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
92
|
+
* Declare the server's role for a tenant. Called by shards with
|
|
93
|
+
* `sync:peer` permission. No-op unless the caller holds it.
|
|
94
|
+
* Absent => 'primary' behavior at the store.
|
|
48
95
|
*/
|
|
49
|
-
|
|
50
|
-
/** Tenant-scoped SyncRegistry accessor — grant enumeration/listing. */
|
|
51
|
-
syncRegistry: (tenantId: string) => SyncRegistry;
|
|
96
|
+
setPeerRole(tenant: string, role: 'primary' | 'replica'): void;
|
|
52
97
|
}
|
|
53
98
|
/**
|
|
54
99
|
* The interface a server shard bundle must default-export.
|
|
@@ -64,6 +109,8 @@ export interface ServerShard {
|
|
|
64
109
|
* May be async if the shard needs to initialise resources before serving.
|
|
65
110
|
*/
|
|
66
111
|
routes: (router: HonoLike, context: ServerShardContext) => void | Promise<void>;
|
|
112
|
+
/** Optional shutdown hook. Called once on server SIGTERM/SIGINT. */
|
|
113
|
+
teardown?(): void | Promise<void>;
|
|
67
114
|
}
|
|
68
115
|
/**
|
|
69
116
|
* Hono MiddlewareHandler type — duplicated here to avoid importing hono
|