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.
Files changed (102) hide show
  1. package/dist/api.d.ts +4 -7
  2. package/dist/api.js +2 -4
  3. package/dist/app/store/InstalledView.svelte +55 -1
  4. package/dist/app/store/PermissionConfirmModal.svelte +232 -0
  5. package/dist/app/store/PermissionConfirmModal.svelte.d.ts +17 -0
  6. package/dist/app/store/StoreView.svelte +119 -5
  7. package/dist/app/store/storeShard.svelte.d.ts +10 -1
  8. package/dist/app/store/storeShard.svelte.js +51 -7
  9. package/dist/app/store/storeShard.svelte.test.js +34 -0
  10. package/dist/apps/types.d.ts +3 -5
  11. package/dist/documents/backends.d.ts +2 -0
  12. package/dist/documents/backends.js +6 -0
  13. package/dist/documents/browse.d.ts +31 -1
  14. package/dist/documents/browse.js +18 -2
  15. package/dist/documents/browse.test.js +81 -0
  16. package/dist/documents/handle.js +13 -5
  17. package/dist/documents/handle.test.js +55 -0
  18. package/dist/documents/http-backend.d.ts +11 -4
  19. package/dist/documents/http-backend.js +37 -11
  20. package/dist/documents/index.d.ts +2 -1
  21. package/dist/documents/index.js +1 -1
  22. package/dist/documents/sync-types.d.ts +45 -0
  23. package/dist/documents/sync-types.js +11 -0
  24. package/dist/documents/types.d.ts +69 -2
  25. package/dist/documents/types.js +32 -2
  26. package/dist/keys/ConsentDialog.svelte +4 -4
  27. package/dist/keys/consent.test.js +4 -3
  28. package/dist/keys/types.d.ts +4 -2
  29. package/dist/registry/client.js +3 -0
  30. package/dist/registry/installer.d.ts +4 -1
  31. package/dist/registry/installer.js +25 -11
  32. package/dist/registry/permission-descriptions.d.ts +21 -0
  33. package/dist/registry/permission-descriptions.js +67 -0
  34. package/dist/registry/permission-descriptions.test.js +86 -0
  35. package/dist/registry/schema.js +19 -6
  36. package/dist/registry/types.d.ts +17 -5
  37. package/dist/server-shard/types.d.ts +55 -8
  38. package/dist/shards/activate-browse.test.js +87 -3
  39. package/dist/shards/activate.svelte.js +9 -31
  40. package/dist/shards/types.d.ts +0 -15
  41. package/dist/shell/views/KeysAndPeers.svelte +1 -1
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/package.json +2 -10
  45. package/dist/documents/journal-hook.d.ts +0 -6
  46. package/dist/documents/journal-hook.js +0 -16
  47. package/dist/documents/sync/activate-integration.test.js +0 -37
  48. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +0 -99
  49. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +0 -15
  50. package/dist/documents/sync/components/SyncGrantPicker.svelte +0 -70
  51. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +0 -12
  52. package/dist/documents/sync/conflicts.d.ts +0 -30
  53. package/dist/documents/sync/conflicts.js +0 -77
  54. package/dist/documents/sync/conflicts.test.js +0 -71
  55. package/dist/documents/sync/engine.d.ts +0 -19
  56. package/dist/documents/sync/engine.js +0 -188
  57. package/dist/documents/sync/engine.test.d.ts +0 -1
  58. package/dist/documents/sync/engine.test.js +0 -169
  59. package/dist/documents/sync/handle.d.ts +0 -11
  60. package/dist/documents/sync/handle.js +0 -79
  61. package/dist/documents/sync/handle.test.js +0 -56
  62. package/dist/documents/sync/hash.d.ts +0 -1
  63. package/dist/documents/sync/hash.js +0 -13
  64. package/dist/documents/sync/hash.test.d.ts +0 -1
  65. package/dist/documents/sync/hash.test.js +0 -20
  66. package/dist/documents/sync/index.d.ts +0 -5
  67. package/dist/documents/sync/index.js +0 -10
  68. package/dist/documents/sync/journal.d.ts +0 -30
  69. package/dist/documents/sync/journal.js +0 -179
  70. package/dist/documents/sync/journal.test.d.ts +0 -1
  71. package/dist/documents/sync/journal.test.js +0 -87
  72. package/dist/documents/sync/observer.d.ts +0 -3
  73. package/dist/documents/sync/observer.js +0 -45
  74. package/dist/documents/sync/registry.d.ts +0 -13
  75. package/dist/documents/sync/registry.js +0 -73
  76. package/dist/documents/sync/registry.test.d.ts +0 -1
  77. package/dist/documents/sync/registry.test.js +0 -53
  78. package/dist/documents/sync/serialization.d.ts +0 -5
  79. package/dist/documents/sync/serialization.js +0 -24
  80. package/dist/documents/sync/serialization.test.d.ts +0 -1
  81. package/dist/documents/sync/serialization.test.js +0 -26
  82. package/dist/documents/sync/singleton.d.ts +0 -11
  83. package/dist/documents/sync/singleton.js +0 -26
  84. package/dist/documents/sync/tombstones.d.ts +0 -19
  85. package/dist/documents/sync/tombstones.js +0 -58
  86. package/dist/documents/sync/tombstones.test.d.ts +0 -1
  87. package/dist/documents/sync/tombstones.test.js +0 -37
  88. package/dist/documents/sync/types.d.ts +0 -116
  89. package/dist/documents/sync/types.js +0 -27
  90. package/dist/documents/sync/write-hook.test.d.ts +0 -1
  91. package/dist/documents/sync/write-hook.test.js +0 -36
  92. package/dist/server-sync.d.ts +0 -6
  93. package/dist/server-sync.js +0 -634
  94. package/dist/server-sync.js.map +0 -7
  95. package/dist/shards/activate-sync-registry.test.d.ts +0 -1
  96. package/dist/shards/activate-sync-registry.test.js +0 -42
  97. package/dist/testing.d.ts +0 -3
  98. package/dist/testing.js +0 -77
  99. package/dist/testing.js.map +0 -7
  100. /package/dist/{documents/sync/activate-integration.test.d.ts → app/store/storeShard.svelte.test.d.ts} +0 -0
  101. /package/dist/documents/{sync/handle.test.d.ts → handle.test.d.ts} +0 -0
  102. /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. Snapshot current state for rollback.
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
- // 3. Push to server.
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
- // 4. Install locally (overwrites IndexedDB + re-registers).
175
- const result = await installPackage(bundle, meta);
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
+ });
@@ -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
  // ---------------------------------------------------------------------------
@@ -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 declare function createBrowseCapability(tenantId: string, backend: DocumentBackend): BrowseCapability;
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;
@@ -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
- return {
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
  });
@@ -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';