sh3-core 0.8.0 → 0.8.2

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 (78) hide show
  1. package/dist/Shell.svelte +19 -0
  2. package/dist/api.d.ts +5 -1
  3. package/dist/api.js +6 -1
  4. package/dist/app/admin/ApiKeysView.svelte +16 -27
  5. package/dist/app/admin/SystemView.svelte +149 -11
  6. package/dist/documents/backends.d.ts +8 -0
  7. package/dist/documents/backends.js +87 -0
  8. package/dist/documents/backends.test.d.ts +1 -0
  9. package/dist/documents/backends.test.js +33 -0
  10. package/dist/documents/browse.d.ts +12 -0
  11. package/dist/documents/browse.js +19 -0
  12. package/dist/documents/browse.test.d.ts +1 -0
  13. package/dist/documents/browse.test.js +41 -0
  14. package/dist/documents/http-backend.d.ts +4 -0
  15. package/dist/documents/http-backend.js +14 -0
  16. package/dist/documents/sync/index.d.ts +1 -2
  17. package/dist/documents/sync/index.js +0 -2
  18. package/dist/documents/sync/observer.d.ts +3 -0
  19. package/dist/documents/sync/observer.js +45 -0
  20. package/dist/documents/sync/registry.d.ts +3 -0
  21. package/dist/documents/sync/registry.js +8 -1
  22. package/dist/documents/sync/registry.test.js +11 -0
  23. package/dist/documents/types.d.ts +18 -0
  24. package/dist/documents/types.js +6 -1
  25. package/dist/keys/ConsentDialog.svelte +176 -0
  26. package/dist/keys/ConsentDialog.svelte.d.ts +3 -0
  27. package/dist/keys/client.d.ts +13 -0
  28. package/dist/keys/client.js +65 -0
  29. package/dist/keys/client.test.d.ts +1 -0
  30. package/dist/keys/client.test.js +44 -0
  31. package/dist/keys/consent.svelte.d.ts +16 -0
  32. package/dist/keys/consent.svelte.js +29 -0
  33. package/dist/keys/consent.test.d.ts +1 -0
  34. package/dist/keys/consent.test.js +53 -0
  35. package/dist/keys/revocation-bus.svelte.d.ts +35 -0
  36. package/dist/keys/revocation-bus.svelte.js +92 -0
  37. package/dist/keys/revocation-bus.test.d.ts +1 -0
  38. package/dist/keys/revocation-bus.test.js +95 -0
  39. package/dist/keys/types.d.ts +32 -0
  40. package/dist/keys/types.js +13 -0
  41. package/dist/layout/inspection.d.ts +17 -0
  42. package/dist/layout/inspection.js +53 -0
  43. package/dist/server-shard/types.d.ts +21 -2
  44. package/dist/server-sync.d.ts +6 -0
  45. package/dist/server-sync.js +634 -0
  46. package/dist/server-sync.js.map +7 -0
  47. package/dist/sh3core-shard/ShellHome.svelte +140 -63
  48. package/dist/sh3core-shard/sh3coreShard.svelte.js +12 -1
  49. package/dist/shards/activate-browse.test.d.ts +1 -0
  50. package/dist/shards/activate-browse.test.js +36 -0
  51. package/dist/shards/activate-on-key-revoked.test.d.ts +1 -0
  52. package/dist/shards/activate-on-key-revoked.test.js +60 -0
  53. package/dist/shards/activate-sync-registry.test.d.ts +1 -0
  54. package/dist/shards/activate-sync-registry.test.js +42 -0
  55. package/dist/shards/activate-tenantid.test.d.ts +1 -0
  56. package/dist/shards/activate-tenantid.test.js +21 -0
  57. package/dist/shards/activate.svelte.d.ts +12 -0
  58. package/dist/shards/activate.svelte.js +55 -3
  59. package/dist/shards/types.d.ts +42 -0
  60. package/dist/shards/types.js +1 -1
  61. package/dist/shell/views/KeysAndPeers.svelte +110 -0
  62. package/dist/shell/views/KeysAndPeers.svelte.d.ts +3 -0
  63. package/dist/shell-shard/Terminal.svelte +0 -11
  64. package/dist/shell-shard/manifest.js +1 -1
  65. package/dist/shell-shard/shellShard.svelte.js +52 -4
  66. package/dist/shell-shard/toolbar/Toolbar.svelte +11 -32
  67. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +0 -2
  68. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +29 -62
  69. package/dist/shell-shard/verbs/index.js +3 -1
  70. package/dist/shell-shard/verbs/views.d.ts +2 -0
  71. package/dist/shell-shard/verbs/views.js +103 -2
  72. package/dist/testing.d.ts +3 -0
  73. package/dist/testing.js +77 -0
  74. package/dist/testing.js.map +7 -0
  75. package/dist/verbs/types.d.ts +19 -0
  76. package/dist/version.d.ts +1 -1
  77. package/dist/version.js +1 -1
  78. package/package.json +10 -2
@@ -0,0 +1,634 @@
1
+ // src/documents/journal-hook.ts
2
+ var appender = null;
3
+ function setJournalAppender(fn) {
4
+ appender = fn;
5
+ }
6
+
7
+ // src/documents/notifications.ts
8
+ var DocumentChangeEmitter = class {
9
+ #listeners = /* @__PURE__ */ new Set();
10
+ subscribe(fn) {
11
+ this.#listeners.add(fn);
12
+ return () => {
13
+ this.#listeners.delete(fn);
14
+ };
15
+ }
16
+ emit(change) {
17
+ for (const fn of this.#listeners) {
18
+ try {
19
+ fn(change);
20
+ } catch (e) {
21
+ console.error("SH3: document change listener threw", e);
22
+ }
23
+ }
24
+ }
25
+ };
26
+ var documentChanges = new DocumentChangeEmitter();
27
+
28
+ // src/documents/sync/hash.ts
29
+ async function hashContent(content) {
30
+ const buf = typeof content === "string" ? new TextEncoder().encode(content) : new Uint8Array(content);
31
+ const digest = await crypto.subtle.digest("SHA-256", buf);
32
+ const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
33
+ return hex.slice(0, 16);
34
+ }
35
+
36
+ // src/documents/sync/types.ts
37
+ var SYNC_RESERVED_SHARD_ID = "__sync__";
38
+ var ScopeNotGrantedError = class extends Error {
39
+ constructor(scope) {
40
+ super(`Scope not granted: ${JSON.stringify(scope)}`);
41
+ this.scope = scope;
42
+ this.name = "ScopeNotGrantedError";
43
+ }
44
+ };
45
+
46
+ // src/documents/sync/serialization.ts
47
+ async function readJson(backend, tenantId, path) {
48
+ const raw = await backend.read(tenantId, SYNC_RESERVED_SHARD_ID, path);
49
+ if (raw === null) return null;
50
+ const str = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
51
+ return JSON.parse(str);
52
+ }
53
+ async function writeJson(backend, tenantId, path, value) {
54
+ await backend.write(tenantId, SYNC_RESERVED_SHARD_ID, path, JSON.stringify(value));
55
+ }
56
+ async function deletePath(backend, tenantId, path) {
57
+ await backend.delete(tenantId, SYNC_RESERVED_SHARD_ID, path);
58
+ }
59
+ async function listJsonPaths(backend, tenantId, prefix) {
60
+ const all = await backend.list(tenantId, SYNC_RESERVED_SHARD_ID);
61
+ return all.map((m) => m.path).filter((p) => p.startsWith(prefix));
62
+ }
63
+
64
+ // src/documents/sync/journal.ts
65
+ var META_PATH = "journal/meta.json";
66
+ var SEGMENT_PREFIX = "journal/seg-";
67
+ var CURSOR_PREFIX = "cursors/";
68
+ var DEFAULT_SEGMENT_SIZE = 500;
69
+ var DEFAULT_PAGE_SIZE = 100;
70
+ function segmentPath(i) {
71
+ return `${SEGMENT_PREFIX}${i}.json`;
72
+ }
73
+ function cursorPath(connectorId) {
74
+ return `${CURSOR_PREFIX}${encodeURIComponent(connectorId)}.json`;
75
+ }
76
+ function matchesScope(entry, scope) {
77
+ switch (scope.kind) {
78
+ case "tenant":
79
+ return true;
80
+ case "shard":
81
+ return entry.shardId === scope.shardId;
82
+ case "path":
83
+ return entry.shardId === scope.shardId && entry.path.startsWith(scope.prefix);
84
+ }
85
+ }
86
+ var Journal = class _Journal {
87
+ constructor(backend, tenantId, opts = {}) {
88
+ this.backend = backend;
89
+ this.tenantId = tenantId;
90
+ this.#segmentSize = opts.segmentSize ?? DEFAULT_SEGMENT_SIZE;
91
+ this.#pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE;
92
+ }
93
+ #meta = { currentSeq: 0, currentSegment: 0, oldestSegment: 0, version: 0 };
94
+ #segmentSize;
95
+ #pageSize;
96
+ async init() {
97
+ const meta = await readJson(this.backend, this.tenantId, META_PATH);
98
+ if (meta) this.#meta = meta;
99
+ }
100
+ static encodeCursor(seq, version) {
101
+ return `${seq}:${version}`;
102
+ }
103
+ static decodeCursor(cursor) {
104
+ const m = /^(\d+):(\d+)$/.exec(cursor);
105
+ if (!m) return null;
106
+ return { seq: Number(m[1]), version: Number(m[2]) };
107
+ }
108
+ async append(entry) {
109
+ const seq = this.#meta.currentSeq + 1;
110
+ const full = { ...entry, seq, ts: Date.now() };
111
+ const segIdx = this.#meta.currentSegment;
112
+ const current = await readJson(this.backend, this.tenantId, segmentPath(segIdx)) ?? [];
113
+ current.push(full);
114
+ await writeJson(this.backend, this.tenantId, segmentPath(segIdx), current);
115
+ this.#meta.currentSeq = seq;
116
+ if (current.length >= this.#segmentSize) {
117
+ this.#meta.currentSegment = segIdx + 1;
118
+ }
119
+ await writeJson(this.backend, this.tenantId, META_PATH, this.#meta);
120
+ return full;
121
+ }
122
+ async oldestRetainedSeq() {
123
+ const first = await readJson(this.backend, this.tenantId, segmentPath(this.#meta.oldestSegment));
124
+ if (!first || first.length === 0) return 0;
125
+ return first[0].seq;
126
+ }
127
+ async changesSince(scope, cursor) {
128
+ let startSeq = 0;
129
+ if (cursor) {
130
+ const decoded = _Journal.decodeCursor(cursor);
131
+ if (!decoded) return { entries: [], nextCursor: cursor, hasMore: false };
132
+ const oldest = await this.oldestRetainedSeq();
133
+ if (decoded.version < this.#meta.version && decoded.seq < oldest) {
134
+ return { entries: [], nextCursor: cursor, hasMore: false, truncated: true };
135
+ }
136
+ startSeq = decoded.seq;
137
+ }
138
+ const out = [];
139
+ let lastSeq = startSeq;
140
+ let hasMore = false;
141
+ for (let seg = this.#meta.oldestSegment; seg <= this.#meta.currentSegment; seg++) {
142
+ const entries = await readJson(this.backend, this.tenantId, segmentPath(seg)) ?? [];
143
+ for (const e of entries) {
144
+ if (e.seq <= startSeq) continue;
145
+ if (!matchesScope(e, scope)) {
146
+ lastSeq = e.seq;
147
+ continue;
148
+ }
149
+ if (out.length >= this.#pageSize) {
150
+ hasMore = true;
151
+ break;
152
+ }
153
+ out.push(e);
154
+ lastSeq = e.seq;
155
+ }
156
+ if (hasMore) break;
157
+ }
158
+ return {
159
+ entries: out,
160
+ nextCursor: _Journal.encodeCursor(lastSeq, this.#meta.version),
161
+ hasMore
162
+ };
163
+ }
164
+ async getCursor(connectorId) {
165
+ return readJson(this.backend, this.tenantId, cursorPath(connectorId));
166
+ }
167
+ async ackCursor(connectorId, cursor) {
168
+ await writeJson(this.backend, this.tenantId, cursorPath(connectorId), cursor);
169
+ }
170
+ async dropCursor(connectorId) {
171
+ await deletePath(this.backend, this.tenantId, cursorPath(connectorId));
172
+ }
173
+ async listCursors() {
174
+ const paths = await listJsonPaths(this.backend, this.tenantId, CURSOR_PREFIX);
175
+ const out = [];
176
+ for (const p of paths) {
177
+ const cursor = await readJson(this.backend, this.tenantId, p);
178
+ if (cursor === null) continue;
179
+ const id = decodeURIComponent(p.slice(CURSOR_PREFIX.length, -".json".length));
180
+ out.push({ connectorId: id, cursor });
181
+ }
182
+ return out;
183
+ }
184
+ async minSeqAckedByAll(connectorIds) {
185
+ if (connectorIds.length === 0) return 0;
186
+ let min = Infinity;
187
+ for (const id of connectorIds) {
188
+ const c = await this.getCursor(id);
189
+ const decoded = c ? _Journal.decodeCursor(c) : null;
190
+ const seq = decoded ? decoded.seq : 0;
191
+ if (seq < min) min = seq;
192
+ }
193
+ return min === Infinity ? 0 : min;
194
+ }
195
+ /** Test-only: simulate truncating all segments whose entries are <= uptoSeq. */
196
+ async __truncateForTest(uptoSeq) {
197
+ for (let seg = this.#meta.oldestSegment; seg <= this.#meta.currentSegment; seg++) {
198
+ const entries = await readJson(this.backend, this.tenantId, segmentPath(seg)) ?? [];
199
+ const last = entries[entries.length - 1];
200
+ if (last && last.seq <= uptoSeq) {
201
+ await deletePath(this.backend, this.tenantId, segmentPath(seg));
202
+ this.#meta.oldestSegment = seg + 1;
203
+ } else break;
204
+ }
205
+ this.#meta.version += 1;
206
+ await writeJson(this.backend, this.tenantId, META_PATH, this.#meta);
207
+ }
208
+ };
209
+
210
+ // src/documents/sync/tombstones.ts
211
+ var PREFIX = "tombstones/";
212
+ function key(shardId, path) {
213
+ return `${PREFIX}${shardId}__${encodeURIComponent(path)}.json`;
214
+ }
215
+ var TombstoneStore = class {
216
+ constructor(backend, tenantId) {
217
+ this.backend = backend;
218
+ this.tenantId = tenantId;
219
+ }
220
+ async record(shardId, path, lastHash, deletedAt) {
221
+ await writeJson(this.backend, this.tenantId, key(shardId, path), {
222
+ deletedAt,
223
+ lastHash
224
+ });
225
+ }
226
+ async get(shardId, path) {
227
+ return readJson(this.backend, this.tenantId, key(shardId, path));
228
+ }
229
+ async clear(shardId, path) {
230
+ await deletePath(this.backend, this.tenantId, key(shardId, path));
231
+ }
232
+ async listByShard(shardId) {
233
+ const prefix = `${PREFIX}${shardId}__`;
234
+ const paths = await listJsonPaths(this.backend, this.tenantId, prefix);
235
+ const out = [];
236
+ for (const p of paths) {
237
+ const rec = await readJson(this.backend, this.tenantId, p);
238
+ if (!rec) continue;
239
+ const originalPath = decodeURIComponent(p.slice(prefix.length, -".json".length));
240
+ out.push({ shardId, path: originalPath, ...rec });
241
+ }
242
+ return out;
243
+ }
244
+ async listAll() {
245
+ const paths = await listJsonPaths(this.backend, this.tenantId, PREFIX);
246
+ const out = [];
247
+ for (const p of paths) {
248
+ const rec = await readJson(this.backend, this.tenantId, p);
249
+ if (!rec) continue;
250
+ const rest = p.slice(PREFIX.length, -".json".length);
251
+ const sep = rest.indexOf("__");
252
+ if (sep < 0) continue;
253
+ const shardId = rest.slice(0, sep);
254
+ const path = decodeURIComponent(rest.slice(sep + 2));
255
+ out.push({ shardId, path, ...rec });
256
+ }
257
+ return out;
258
+ }
259
+ };
260
+
261
+ // src/documents/sync/conflicts.ts
262
+ var BASES_PREFIX = "bases/";
263
+ function baseKey(connectorId, shardId, path) {
264
+ return `${BASES_PREFIX}${encodeURIComponent(connectorId)}__${shardId}__${encodeURIComponent(path)}.json`;
265
+ }
266
+ function isArtifactName(name) {
267
+ return /\.sync-conflict-[^.]+-\d+$/.test(name);
268
+ }
269
+ var ConflictManager = class {
270
+ constructor(backend, tenantId) {
271
+ this.backend = backend;
272
+ this.tenantId = tenantId;
273
+ }
274
+ async resolve(policy, input) {
275
+ const p = typeof policy === "function" ? await policy({
276
+ path: input.path,
277
+ shardId: input.shardId,
278
+ localHash: input.localHash,
279
+ remoteHash: input.remoteHash,
280
+ baseHash: input.baseHash
281
+ }) : policy;
282
+ switch (p) {
283
+ case "remote-wins":
284
+ return { action: "apply-remote" };
285
+ case "local-wins":
286
+ return { action: "skip" };
287
+ case "keep-both": {
288
+ const asPath = `${input.path}.incoming-${input.connectorId}-${Date.now()}`;
289
+ return { action: "apply-remote", asPath };
290
+ }
291
+ case "default":
292
+ default: {
293
+ const ts = Date.now();
294
+ const artifact = `${input.path}.sync-conflict-${input.connectorId}-${ts}`;
295
+ if (input.remoteContent !== void 0) {
296
+ await this.backend.write(this.tenantId, input.shardId, artifact, input.remoteContent);
297
+ }
298
+ const resolution = {
299
+ path: input.path,
300
+ shardId: input.shardId,
301
+ localHash: input.localHash,
302
+ remoteHash: input.remoteHash,
303
+ conflictArtifactPath: artifact,
304
+ base: input.baseHash ? { hash: input.baseHash } : void 0
305
+ };
306
+ return { action: "conflict", resolution };
307
+ }
308
+ }
309
+ }
310
+ async getBaseHash(connectorId, shardId, path) {
311
+ return readJson(this.backend, this.tenantId, baseKey(connectorId, shardId, path));
312
+ }
313
+ async setBaseHash(connectorId, shardId, path, hash) {
314
+ await writeJson(this.backend, this.tenantId, baseKey(connectorId, shardId, path), hash);
315
+ }
316
+ async listConflicts(shardId) {
317
+ const metas = await this.backend.list(this.tenantId, shardId);
318
+ const out = [];
319
+ for (const m of metas) {
320
+ const name = m.path;
321
+ if (!isArtifactName(name)) continue;
322
+ const m2 = /^(.*)\.sync-conflict-([^-]+)-(\d+)$/.exec(name);
323
+ if (!m2) continue;
324
+ const originalPath = m2[1];
325
+ out.push({
326
+ path: originalPath,
327
+ shardId,
328
+ localHash: "",
329
+ remoteHash: "",
330
+ conflictArtifactPath: name
331
+ });
332
+ }
333
+ return out;
334
+ }
335
+ };
336
+
337
+ // src/documents/sync/engine.ts
338
+ function scopeCovers(scope, shardId, path) {
339
+ switch (scope.kind) {
340
+ case "tenant":
341
+ return true;
342
+ case "shard":
343
+ return scope.shardId === shardId;
344
+ case "path":
345
+ return scope.shardId === shardId && path.startsWith(scope.prefix);
346
+ }
347
+ }
348
+ var SyncEngine = class {
349
+ constructor(backend, tenantId, opts = {}) {
350
+ this.backend = backend;
351
+ this.tenantId = tenantId;
352
+ this.#journal = new Journal(backend, tenantId, { segmentSize: opts.segmentSize });
353
+ this.#tombstones = new TombstoneStore(backend, tenantId);
354
+ this.#conflicts = new ConflictManager(backend, tenantId);
355
+ }
356
+ #journal;
357
+ #tombstones;
358
+ #conflicts;
359
+ async init() {
360
+ await this.#journal.init();
361
+ }
362
+ get journal() {
363
+ return this.#journal;
364
+ }
365
+ async getManifest(_connectorId, scope) {
366
+ const shardIds = await this.#shardsInScope(scope);
367
+ const entries = [];
368
+ for (const shardId of shardIds) {
369
+ const metas = await this.backend.list(this.tenantId, shardId);
370
+ for (const m of metas) {
371
+ if (!scopeCovers(scope, shardId, m.path)) continue;
372
+ if (m.path.startsWith(".") || /\.sync-conflict-/.test(m.path)) continue;
373
+ const raw = await this.backend.read(this.tenantId, shardId, m.path);
374
+ if (raw === null) continue;
375
+ const hash = await hashContent(raw);
376
+ entries.push({ path: m.path, shardId, hash, size: m.size, lastModified: m.lastModified });
377
+ }
378
+ const tombs = await this.#tombstones.listByShard(shardId);
379
+ for (const t of tombs) {
380
+ if (!scopeCovers(scope, shardId, t.path)) continue;
381
+ entries.push({
382
+ path: t.path,
383
+ shardId,
384
+ hash: t.lastHash,
385
+ size: 0,
386
+ lastModified: t.deletedAt,
387
+ tombstone: { deletedAt: t.deletedAt }
388
+ });
389
+ }
390
+ }
391
+ return entries;
392
+ }
393
+ async changesSince(scope, cursor) {
394
+ return this.#journal.changesSince(scope, cursor);
395
+ }
396
+ async ack(connectorId, _scope, cursor) {
397
+ await this.#journal.ackCursor(connectorId, cursor);
398
+ }
399
+ async apply(connectorId, scope, entry, opts = {}) {
400
+ if (!scopeCovers(scope, entry.shardId, entry.path)) {
401
+ throw new Error(`ApplyEntry {${entry.shardId}:${entry.path}} falls outside scope`);
402
+ }
403
+ if (entry.op === "upsert") return this.#applyUpsert(connectorId, entry, opts);
404
+ return this.#applyDelete(connectorId, entry);
405
+ }
406
+ async applyBatch(connectorId, scope, manifest, opts = {}) {
407
+ const applied = [];
408
+ const skipped = [];
409
+ const conflicts = [];
410
+ for (const e of manifest) {
411
+ const out = await this.apply(connectorId, scope, e, opts);
412
+ if (out.status === "applied") applied.push({ path: e.path, shardId: e.shardId, newHash: out.newHash });
413
+ else if (out.status === "skipped-identical") skipped.push({ path: e.path, shardId: e.shardId, reason: "identical" });
414
+ else conflicts.push(out.resolution);
415
+ }
416
+ return { applied, skipped, conflicts };
417
+ }
418
+ async forget(scope, path) {
419
+ const shardIds = await this.#shardsInScope(scope);
420
+ for (const shardId of shardIds) {
421
+ if (!scopeCovers(scope, shardId, path)) continue;
422
+ await this.#tombstones.clear(shardId, path);
423
+ }
424
+ }
425
+ // ----- internals -----
426
+ async #applyUpsert(connectorId, entry, opts) {
427
+ const existing = await this.backend.read(this.tenantId, entry.shardId, entry.path);
428
+ const existed = existing !== null;
429
+ const localHash = existed ? await hashContent(existing) : null;
430
+ if (localHash !== null && localHash === entry.remoteHash) {
431
+ return { status: "skipped-identical" };
432
+ }
433
+ const conflictTriggered = existed && (opts.expectedLocalHash !== void 0 && opts.expectedLocalHash !== localHash || opts.expectedLocalHash === void 0 && await this.#hasDivergedBase(connectorId, entry, localHash));
434
+ if (conflictTriggered) {
435
+ const baseHash = await this.#conflicts.getBaseHash(connectorId, entry.shardId, entry.path) ?? void 0;
436
+ const action = await this.#conflicts.resolve(opts.onConflict ?? "default", {
437
+ connectorId,
438
+ shardId: entry.shardId,
439
+ path: entry.path,
440
+ localHash,
441
+ remoteHash: entry.remoteHash,
442
+ remoteContent: entry.content,
443
+ baseHash
444
+ });
445
+ if (action.action === "skip") return { status: "skipped-identical" };
446
+ if (action.action === "conflict") return { status: "conflict", resolution: action.resolution };
447
+ const writePath = action.asPath ?? entry.path;
448
+ return this.#writeAndRecord(connectorId, entry, writePath, existed);
449
+ }
450
+ return this.#writeAndRecord(connectorId, entry, entry.path, existed);
451
+ }
452
+ async #writeAndRecord(connectorId, entry, writePath, existed) {
453
+ if (entry.content === void 0) {
454
+ throw new Error(`Upsert without content for ${entry.shardId}:${entry.path}`);
455
+ }
456
+ await this.backend.write(this.tenantId, entry.shardId, writePath, entry.content);
457
+ await this.#tombstones.clear(entry.shardId, writePath);
458
+ const newHash = await hashContent(entry.content);
459
+ await this.#conflicts.setBaseHash(connectorId, entry.shardId, writePath, newHash);
460
+ await this.#journal.append({ shardId: entry.shardId, path: writePath, op: "upsert", hash: newHash });
461
+ this.#emit({ type: existed && writePath === entry.path ? "update" : "create", path: writePath, tenantId: this.tenantId, shardId: entry.shardId });
462
+ return { status: "applied", newHash };
463
+ }
464
+ async #applyDelete(connectorId, entry) {
465
+ const existing = await this.backend.read(this.tenantId, entry.shardId, entry.path);
466
+ if (existing === null) {
467
+ await this.#tombstones.record(entry.shardId, entry.path, entry.remoteHash, Date.now());
468
+ await this.#journal.append({ shardId: entry.shardId, path: entry.path, op: "delete", hash: null });
469
+ return { status: "applied", newHash: "" };
470
+ }
471
+ const lastHash = await hashContent(existing);
472
+ await this.backend.delete(this.tenantId, entry.shardId, entry.path);
473
+ await this.#tombstones.record(entry.shardId, entry.path, lastHash, Date.now());
474
+ await this.#conflicts.setBaseHash(connectorId, entry.shardId, entry.path, "");
475
+ await this.#journal.append({ shardId: entry.shardId, path: entry.path, op: "delete", hash: null });
476
+ this.#emit({ type: "delete", path: entry.path, tenantId: this.tenantId, shardId: entry.shardId });
477
+ return { status: "applied", newHash: "" };
478
+ }
479
+ async #hasDivergedBase(connectorId, entry, localHash) {
480
+ const base = await this.#conflicts.getBaseHash(connectorId, entry.shardId, entry.path);
481
+ if (!base) return false;
482
+ return base !== localHash;
483
+ }
484
+ async #shardsInScope(scope) {
485
+ if (scope.kind === "tenant") {
486
+ return [];
487
+ }
488
+ return [scope.shardId];
489
+ }
490
+ #emit(change) {
491
+ documentChanges.emit(change);
492
+ }
493
+ };
494
+
495
+ // src/documents/sync/registry.ts
496
+ var GRANTS_PREFIX = "grants/";
497
+ function grantPath(connectorId) {
498
+ return `${GRANTS_PREFIX}${encodeURIComponent(connectorId)}.json`;
499
+ }
500
+ function scopesEqual(a, b) {
501
+ if (a.kind !== b.kind) return false;
502
+ if (a.kind === "tenant") return true;
503
+ if (a.kind === "shard" && b.kind === "shard") return a.shardId === b.shardId;
504
+ if (a.kind === "path" && b.kind === "path")
505
+ return a.shardId === b.shardId && a.prefix === b.prefix;
506
+ return false;
507
+ }
508
+ function createSyncRegistry(backend, tenantId) {
509
+ const conflicts = new ConflictManager(backend, tenantId);
510
+ async function readGrants(connectorId) {
511
+ return await readJson(backend, tenantId, grantPath(connectorId)) ?? [];
512
+ }
513
+ return {
514
+ async list(connectorId) {
515
+ if (connectorId) return readGrants(connectorId);
516
+ const paths = await listJsonPaths(backend, tenantId, GRANTS_PREFIX);
517
+ const out = [];
518
+ for (const p of paths) {
519
+ const arr = await readJson(backend, tenantId, p);
520
+ if (arr) out.push(...arr);
521
+ }
522
+ return out;
523
+ },
524
+ async revoke(connectorId, scope) {
525
+ const grants = await readGrants(connectorId);
526
+ const next = grants.filter((g) => !scopesEqual(g.scope, scope));
527
+ if (next.length === 0) await deletePath(backend, tenantId, grantPath(connectorId));
528
+ else await writeJson(backend, tenantId, grantPath(connectorId), next);
529
+ },
530
+ async listConflicts(shardId) {
531
+ if (shardId !== void 0) return conflicts.listConflicts(shardId);
532
+ const shards = await backend.listAllShards(tenantId);
533
+ const out = [];
534
+ for (const s of shards) {
535
+ out.push(...await conflicts.listConflicts(s));
536
+ }
537
+ return out;
538
+ },
539
+ async listAllConnectorIds() {
540
+ const paths = await listJsonPaths(backend, tenantId, GRANTS_PREFIX);
541
+ return paths.map(
542
+ (p) => decodeURIComponent(p.slice(GRANTS_PREFIX.length, -".json".length))
543
+ );
544
+ }
545
+ };
546
+ }
547
+
548
+ // src/documents/sync/singleton.ts
549
+ var bundles = /* @__PURE__ */ new Map();
550
+ async function getSyncBundle(backend, tenantId) {
551
+ const existing = bundles.get(tenantId);
552
+ if (existing) return existing;
553
+ const engine = new SyncEngine(backend, tenantId);
554
+ await engine.init();
555
+ const registry = createSyncRegistry(backend, tenantId);
556
+ setJournalAppender(async (e) => {
557
+ await engine.journal.append(e);
558
+ });
559
+ const bundle = { engine, registry };
560
+ bundles.set(tenantId, bundle);
561
+ return bundle;
562
+ }
563
+
564
+ // src/documents/sync/handle.ts
565
+ function scopeContains(parent, child) {
566
+ if (parent.kind === "tenant") return true;
567
+ if (parent.kind === "shard") {
568
+ if (child.kind === "shard") return parent.shardId === child.shardId;
569
+ if (child.kind === "path") return parent.shardId === child.shardId;
570
+ return false;
571
+ }
572
+ if (child.kind === "path")
573
+ return child.shardId === parent.shardId && child.prefix.startsWith(parent.prefix);
574
+ return false;
575
+ }
576
+ function createSyncHandle(deps) {
577
+ const { connectorId, engine, registry } = deps;
578
+ async function currentGrants() {
579
+ const records = await registry.list(connectorId);
580
+ return records.map((r) => r.scope);
581
+ }
582
+ async function requireScope(requested) {
583
+ const grants = await currentGrants();
584
+ const matching = grants.filter((g) => scopeContains(g, requested));
585
+ if (matching.length === 0) throw new ScopeNotGrantedError(requested);
586
+ if (requested.kind === "tenant") {
587
+ const concrete = grants.filter((g) => g.kind === "shard" || g.kind === "path");
588
+ return concrete.length > 0 ? concrete : [requested];
589
+ }
590
+ return [requested];
591
+ }
592
+ return {
593
+ connectorId,
594
+ async grantedScopes() {
595
+ return currentGrants();
596
+ },
597
+ async getManifest(scope) {
598
+ const concreteScopes = await requireScope(scope);
599
+ const out = [];
600
+ if (scope.kind === "tenant" && concreteScopes[0]?.kind !== "tenant") {
601
+ for (const s of concreteScopes) out.push(...await engine.getManifest(connectorId, s));
602
+ } else {
603
+ out.push(...await engine.getManifest(connectorId, scope));
604
+ }
605
+ return out;
606
+ },
607
+ async changesSince(scope, cursor) {
608
+ await requireScope(scope);
609
+ return engine.changesSince(scope, cursor);
610
+ },
611
+ async ack(scope, cursor) {
612
+ await requireScope(scope);
613
+ await engine.ack(connectorId, scope, cursor);
614
+ },
615
+ async apply(scope, entry, opts) {
616
+ await requireScope(scope);
617
+ return engine.apply(connectorId, scope, entry, opts);
618
+ },
619
+ async applyBatch(scope, manifest, opts) {
620
+ await requireScope(scope);
621
+ return engine.applyBatch(connectorId, scope, manifest, opts);
622
+ },
623
+ async forget(scope, path) {
624
+ await requireScope(scope);
625
+ await engine.forget(scope, path);
626
+ }
627
+ };
628
+ }
629
+ export {
630
+ createSyncHandle,
631
+ createSyncRegistry,
632
+ getSyncBundle
633
+ };
634
+ //# sourceMappingURL=server-sync.js.map