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.
Files changed (83) hide show
  1. package/dist/api.d.ts +3 -6
  2. package/dist/api.js +1 -3
  3. package/dist/apps/types.d.ts +3 -5
  4. package/dist/documents/backends.d.ts +2 -0
  5. package/dist/documents/backends.js +6 -0
  6. package/dist/documents/handle.js +13 -5
  7. package/dist/documents/handle.test.js +55 -0
  8. package/dist/documents/http-backend.d.ts +11 -4
  9. package/dist/documents/http-backend.js +37 -11
  10. package/dist/documents/index.d.ts +2 -1
  11. package/dist/documents/index.js +1 -1
  12. package/dist/documents/sync-types.d.ts +45 -0
  13. package/dist/documents/sync-types.js +11 -0
  14. package/dist/documents/types.d.ts +40 -2
  15. package/dist/documents/types.js +3 -2
  16. package/dist/keys/ConsentDialog.svelte +4 -4
  17. package/dist/keys/consent.test.js +4 -3
  18. package/dist/keys/types.d.ts +4 -2
  19. package/dist/server-shard/types.d.ts +55 -8
  20. package/dist/shards/activate.svelte.js +4 -29
  21. package/dist/shards/types.d.ts +0 -15
  22. package/dist/shell/views/KeysAndPeers.svelte +1 -1
  23. package/dist/version.d.ts +1 -1
  24. package/dist/version.js +1 -1
  25. package/package.json +2 -10
  26. package/dist/documents/journal-hook.d.ts +0 -6
  27. package/dist/documents/journal-hook.js +0 -16
  28. package/dist/documents/sync/activate-integration.test.d.ts +0 -1
  29. package/dist/documents/sync/activate-integration.test.js +0 -37
  30. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +0 -99
  31. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +0 -15
  32. package/dist/documents/sync/components/SyncGrantPicker.svelte +0 -70
  33. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +0 -12
  34. package/dist/documents/sync/conflicts.d.ts +0 -30
  35. package/dist/documents/sync/conflicts.js +0 -77
  36. package/dist/documents/sync/conflicts.test.d.ts +0 -1
  37. package/dist/documents/sync/conflicts.test.js +0 -71
  38. package/dist/documents/sync/engine.d.ts +0 -19
  39. package/dist/documents/sync/engine.js +0 -188
  40. package/dist/documents/sync/engine.test.d.ts +0 -1
  41. package/dist/documents/sync/engine.test.js +0 -169
  42. package/dist/documents/sync/handle.d.ts +0 -11
  43. package/dist/documents/sync/handle.js +0 -79
  44. package/dist/documents/sync/handle.test.js +0 -56
  45. package/dist/documents/sync/hash.d.ts +0 -1
  46. package/dist/documents/sync/hash.js +0 -13
  47. package/dist/documents/sync/hash.test.d.ts +0 -1
  48. package/dist/documents/sync/hash.test.js +0 -20
  49. package/dist/documents/sync/index.d.ts +0 -5
  50. package/dist/documents/sync/index.js +0 -10
  51. package/dist/documents/sync/journal.d.ts +0 -30
  52. package/dist/documents/sync/journal.js +0 -179
  53. package/dist/documents/sync/journal.test.d.ts +0 -1
  54. package/dist/documents/sync/journal.test.js +0 -87
  55. package/dist/documents/sync/observer.d.ts +0 -3
  56. package/dist/documents/sync/observer.js +0 -45
  57. package/dist/documents/sync/registry.d.ts +0 -13
  58. package/dist/documents/sync/registry.js +0 -73
  59. package/dist/documents/sync/registry.test.d.ts +0 -1
  60. package/dist/documents/sync/registry.test.js +0 -53
  61. package/dist/documents/sync/serialization.d.ts +0 -5
  62. package/dist/documents/sync/serialization.js +0 -24
  63. package/dist/documents/sync/serialization.test.d.ts +0 -1
  64. package/dist/documents/sync/serialization.test.js +0 -26
  65. package/dist/documents/sync/singleton.d.ts +0 -11
  66. package/dist/documents/sync/singleton.js +0 -26
  67. package/dist/documents/sync/tombstones.d.ts +0 -19
  68. package/dist/documents/sync/tombstones.js +0 -58
  69. package/dist/documents/sync/tombstones.test.d.ts +0 -1
  70. package/dist/documents/sync/tombstones.test.js +0 -37
  71. package/dist/documents/sync/types.d.ts +0 -116
  72. package/dist/documents/sync/types.js +0 -27
  73. package/dist/documents/sync/write-hook.test.d.ts +0 -1
  74. package/dist/documents/sync/write-hook.test.js +0 -36
  75. package/dist/server-sync.d.ts +0 -6
  76. package/dist/server-sync.js +0 -634
  77. package/dist/server-sync.js.map +0 -7
  78. package/dist/shards/activate-sync-registry.test.d.ts +0 -1
  79. package/dist/shards/activate-sync-registry.test.js +0 -42
  80. package/dist/testing.d.ts +0 -3
  81. package/dist/testing.js +0 -77
  82. package/dist/testing.js.map +0 -7
  83. /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 { SyncHandle, SyncScope, ManifestEntry, ApplyEntry, ApplyOpts, ApplyOutcome, ApplyBatchResult, ConflictPolicy, ConflictResolution, ConflictContext, JournalEntry, ChangePage, GrantRecord, } from './documents/sync/types';
23
- export { PERMISSION_DOCUMENTS_SYNC, ScopeNotGrantedError, ScopeRevokedError, TenantMismatchError, } from './documents/sync/types';
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 { PERMISSION_DOCUMENTS_SYNC, ScopeNotGrantedError, ScopeRevokedError, TenantMismatchError, } from './documents/sync/types';
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
@@ -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
- * - 'documents:sync' — cross-shard document sync API.
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
  // ---------------------------------------------------------------------------
@@ -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. Read operations
5
- * are unauthenticated; write operations send the API key as a Bearer
6
- * token. This is the web-hosted equivalent of the Tauri filesystem
7
- * backend.
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. Read operations
5
- * are unauthenticated; write operations send the API key as a Bearer
6
- * token. This is the web-hosted equivalent of the Tauri filesystem
7
- * backend.
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 * from './sync';
7
+ export type { SyncPolicy, SyncPolicyRule, DocStatus, ConflictFile, ConflictBranch, } from './sync-types';
8
+ export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY, } from './sync-types';
@@ -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 * from './sync';
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
- * (`ctx.browse`) and sync registry visibility (`ctx.syncRegistry`).
4
- * Read-only; writes still flow through the shard's own `ctx.documents()`.
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.
@@ -11,7 +11,8 @@
11
11
  */
12
12
  /**
13
13
  * Manifest permission string: grants tenant-wide document observation
14
- * (`ctx.browse`) and sync registry visibility (`ctx.syncRegistry`).
15
- * Read-only; writes still flow through the shard's own `ctx.documents()`.
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, connectorId)
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.connectorId}
49
- <dt>Connector</dt>
50
- <dd>{current.connectorId}</dd>
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: ['documents:sync'], connectorId: 'peer-x' });
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(['documents:sync']);
23
- expect(req.connectorId).toBe('peer-x');
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 () => {
@@ -6,14 +6,16 @@ export interface ApiKeyPublic {
6
6
  ownerUserId: string | null;
7
7
  mintedByShardId: string | null;
8
8
  scopes: string[];
9
- connectorId?: string;
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
- connectorId?: string;
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 { SyncHandle } from '../documents/sync/types';
14
- import type { SyncRegistry } from '../documents/sync/registry';
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
- * Obtain a tenant-scoped SyncHandle from a server-side route handler.
46
- * The handle enforces GrantRecord checks exactly like the client-side
47
- * one. Throws if the shard's manifest does not declare `documents:sync`.
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
- sync: (tenantId: string, connectorId: string) => Promise<SyncHandle>;
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