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
@@ -1,188 +0,0 @@
1
- /*
2
- * SyncEngine — orchestrates manifest generation, apply pipeline, and
3
- * change notifications. Composes Journal, TombstoneStore, and
4
- * ConflictManager over a DocumentBackend. One instance per tenant.
5
- */
6
- var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
7
- if (kind === "m") throw new TypeError("Private method is not writable");
8
- if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
9
- if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
10
- return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
11
- };
12
- var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
13
- if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
14
- if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
15
- return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
16
- };
17
- var _SyncEngine_instances, _SyncEngine_journal, _SyncEngine_tombstones, _SyncEngine_conflicts, _SyncEngine_applyUpsert, _SyncEngine_writeAndRecord, _SyncEngine_applyDelete, _SyncEngine_hasDivergedBase, _SyncEngine_shardsInScope, _SyncEngine_emit;
18
- import { documentChanges } from '../notifications';
19
- import { hashContent } from './hash';
20
- import { Journal } from './journal';
21
- import { TombstoneStore } from './tombstones';
22
- import { ConflictManager } from './conflicts';
23
- function scopeCovers(scope, shardId, path) {
24
- switch (scope.kind) {
25
- case 'tenant': return true;
26
- case 'shard': return scope.shardId === shardId;
27
- case 'path': return scope.shardId === shardId && path.startsWith(scope.prefix);
28
- }
29
- }
30
- export class SyncEngine {
31
- constructor(backend, tenantId, opts = {}) {
32
- _SyncEngine_instances.add(this);
33
- this.backend = backend;
34
- this.tenantId = tenantId;
35
- _SyncEngine_journal.set(this, void 0);
36
- _SyncEngine_tombstones.set(this, void 0);
37
- _SyncEngine_conflicts.set(this, void 0);
38
- __classPrivateFieldSet(this, _SyncEngine_journal, new Journal(backend, tenantId, { segmentSize: opts.segmentSize }), "f");
39
- __classPrivateFieldSet(this, _SyncEngine_tombstones, new TombstoneStore(backend, tenantId), "f");
40
- __classPrivateFieldSet(this, _SyncEngine_conflicts, new ConflictManager(backend, tenantId), "f");
41
- }
42
- async init() {
43
- await __classPrivateFieldGet(this, _SyncEngine_journal, "f").init();
44
- }
45
- get journal() { return __classPrivateFieldGet(this, _SyncEngine_journal, "f"); }
46
- async getManifest(_connectorId, scope) {
47
- const shardIds = await __classPrivateFieldGet(this, _SyncEngine_instances, "m", _SyncEngine_shardsInScope).call(this, scope);
48
- const entries = [];
49
- for (const shardId of shardIds) {
50
- const metas = await this.backend.list(this.tenantId, shardId);
51
- for (const m of metas) {
52
- if (!scopeCovers(scope, shardId, m.path))
53
- continue;
54
- if (m.path.startsWith('.') || /\.sync-conflict-/.test(m.path))
55
- continue;
56
- const raw = await this.backend.read(this.tenantId, shardId, m.path);
57
- if (raw === null)
58
- continue;
59
- const hash = await hashContent(raw);
60
- entries.push({ path: m.path, shardId, hash, size: m.size, lastModified: m.lastModified });
61
- }
62
- const tombs = await __classPrivateFieldGet(this, _SyncEngine_tombstones, "f").listByShard(shardId);
63
- for (const t of tombs) {
64
- if (!scopeCovers(scope, shardId, t.path))
65
- continue;
66
- entries.push({
67
- path: t.path, shardId, hash: t.lastHash, size: 0, lastModified: t.deletedAt,
68
- tombstone: { deletedAt: t.deletedAt },
69
- });
70
- }
71
- }
72
- return entries;
73
- }
74
- async changesSince(scope, cursor) {
75
- return __classPrivateFieldGet(this, _SyncEngine_journal, "f").changesSince(scope, cursor);
76
- }
77
- async ack(connectorId, _scope, cursor) {
78
- // Scope is informational — cursors are connector-wide, not scope-specific.
79
- await __classPrivateFieldGet(this, _SyncEngine_journal, "f").ackCursor(connectorId, cursor);
80
- }
81
- async apply(connectorId, scope, entry, opts = {}) {
82
- if (!scopeCovers(scope, entry.shardId, entry.path)) {
83
- throw new Error(`ApplyEntry {${entry.shardId}:${entry.path}} falls outside scope`);
84
- }
85
- if (entry.op === 'upsert')
86
- return __classPrivateFieldGet(this, _SyncEngine_instances, "m", _SyncEngine_applyUpsert).call(this, connectorId, entry, opts);
87
- return __classPrivateFieldGet(this, _SyncEngine_instances, "m", _SyncEngine_applyDelete).call(this, connectorId, entry);
88
- }
89
- async applyBatch(connectorId, scope, manifest, opts = {}) {
90
- const applied = [];
91
- const skipped = [];
92
- const conflicts = [];
93
- for (const e of manifest) {
94
- const out = await this.apply(connectorId, scope, e, opts);
95
- if (out.status === 'applied')
96
- applied.push({ path: e.path, shardId: e.shardId, newHash: out.newHash });
97
- else if (out.status === 'skipped-identical')
98
- skipped.push({ path: e.path, shardId: e.shardId, reason: 'identical' });
99
- else
100
- conflicts.push(out.resolution);
101
- }
102
- return { applied, skipped, conflicts };
103
- }
104
- async forget(scope, path) {
105
- const shardIds = await __classPrivateFieldGet(this, _SyncEngine_instances, "m", _SyncEngine_shardsInScope).call(this, scope);
106
- for (const shardId of shardIds) {
107
- if (!scopeCovers(scope, shardId, path))
108
- continue;
109
- await __classPrivateFieldGet(this, _SyncEngine_tombstones, "f").clear(shardId, path);
110
- }
111
- }
112
- }
113
- _SyncEngine_journal = new WeakMap(), _SyncEngine_tombstones = new WeakMap(), _SyncEngine_conflicts = new WeakMap(), _SyncEngine_instances = new WeakSet(), _SyncEngine_applyUpsert =
114
- // ----- internals -----
115
- async function _SyncEngine_applyUpsert(connectorId, entry, opts) {
116
- var _a, _b, _c;
117
- const existing = await this.backend.read(this.tenantId, entry.shardId, entry.path);
118
- const existed = existing !== null;
119
- const localHash = existed ? await hashContent(existing) : null;
120
- if (localHash !== null && localHash === entry.remoteHash) {
121
- return { status: 'skipped-identical' };
122
- }
123
- const conflictTriggered = existed && ((opts.expectedLocalHash !== undefined && opts.expectedLocalHash !== localHash) ||
124
- (opts.expectedLocalHash === undefined && await __classPrivateFieldGet(this, _SyncEngine_instances, "m", _SyncEngine_hasDivergedBase).call(this, connectorId, entry, localHash)));
125
- if (conflictTriggered) {
126
- const baseHash = (_a = (await __classPrivateFieldGet(this, _SyncEngine_conflicts, "f").getBaseHash(connectorId, entry.shardId, entry.path))) !== null && _a !== void 0 ? _a : undefined;
127
- const action = await __classPrivateFieldGet(this, _SyncEngine_conflicts, "f").resolve((_b = opts.onConflict) !== null && _b !== void 0 ? _b : 'default', {
128
- connectorId,
129
- shardId: entry.shardId,
130
- path: entry.path,
131
- localHash: localHash,
132
- remoteHash: entry.remoteHash,
133
- remoteContent: entry.content,
134
- baseHash,
135
- });
136
- if (action.action === 'skip')
137
- return { status: 'skipped-identical' };
138
- if (action.action === 'conflict')
139
- return { status: 'conflict', resolution: action.resolution };
140
- // apply-remote — fall through to write (possibly under asPath)
141
- const writePath = (_c = action.asPath) !== null && _c !== void 0 ? _c : entry.path;
142
- return __classPrivateFieldGet(this, _SyncEngine_instances, "m", _SyncEngine_writeAndRecord).call(this, connectorId, entry, writePath, existed);
143
- }
144
- return __classPrivateFieldGet(this, _SyncEngine_instances, "m", _SyncEngine_writeAndRecord).call(this, connectorId, entry, entry.path, existed);
145
- }, _SyncEngine_writeAndRecord = async function _SyncEngine_writeAndRecord(connectorId, entry, writePath, existed) {
146
- if (entry.content === undefined) {
147
- throw new Error(`Upsert without content for ${entry.shardId}:${entry.path}`);
148
- }
149
- await this.backend.write(this.tenantId, entry.shardId, writePath, entry.content);
150
- await __classPrivateFieldGet(this, _SyncEngine_tombstones, "f").clear(entry.shardId, writePath);
151
- const newHash = await hashContent(entry.content);
152
- await __classPrivateFieldGet(this, _SyncEngine_conflicts, "f").setBaseHash(connectorId, entry.shardId, writePath, newHash);
153
- await __classPrivateFieldGet(this, _SyncEngine_journal, "f").append({ shardId: entry.shardId, path: writePath, op: 'upsert', hash: newHash });
154
- __classPrivateFieldGet(this, _SyncEngine_instances, "m", _SyncEngine_emit).call(this, { type: existed && writePath === entry.path ? 'update' : 'create', path: writePath, tenantId: this.tenantId, shardId: entry.shardId });
155
- return { status: 'applied', newHash };
156
- }, _SyncEngine_applyDelete = async function _SyncEngine_applyDelete(connectorId, entry) {
157
- const existing = await this.backend.read(this.tenantId, entry.shardId, entry.path);
158
- if (existing === null) {
159
- // Already gone. Record a tombstone anyway so manifests reflect the delete.
160
- await __classPrivateFieldGet(this, _SyncEngine_tombstones, "f").record(entry.shardId, entry.path, entry.remoteHash, Date.now());
161
- await __classPrivateFieldGet(this, _SyncEngine_journal, "f").append({ shardId: entry.shardId, path: entry.path, op: 'delete', hash: null });
162
- return { status: 'applied', newHash: '' };
163
- }
164
- const lastHash = await hashContent(existing);
165
- await this.backend.delete(this.tenantId, entry.shardId, entry.path);
166
- await __classPrivateFieldGet(this, _SyncEngine_tombstones, "f").record(entry.shardId, entry.path, lastHash, Date.now());
167
- await __classPrivateFieldGet(this, _SyncEngine_conflicts, "f").setBaseHash(connectorId, entry.shardId, entry.path, ''); // clear base
168
- await __classPrivateFieldGet(this, _SyncEngine_journal, "f").append({ shardId: entry.shardId, path: entry.path, op: 'delete', hash: null });
169
- __classPrivateFieldGet(this, _SyncEngine_instances, "m", _SyncEngine_emit).call(this, { type: 'delete', path: entry.path, tenantId: this.tenantId, shardId: entry.shardId });
170
- return { status: 'applied', newHash: '' };
171
- }, _SyncEngine_hasDivergedBase = async function _SyncEngine_hasDivergedBase(connectorId, entry, localHash) {
172
- const base = await __classPrivateFieldGet(this, _SyncEngine_conflicts, "f").getBaseHash(connectorId, entry.shardId, entry.path);
173
- if (!base)
174
- return false; // no prior base → treat as first-seen, apply
175
- return base !== localHash; // local moved since we last wrote it
176
- }, _SyncEngine_shardsInScope = async function _SyncEngine_shardsInScope(scope) {
177
- if (scope.kind === 'tenant') {
178
- // Listing all shards under a tenant isn't part of DocumentBackend's
179
- // contract. For now, we rely on callers passing {kind:'shard'} or
180
- // {kind:'path'}; {kind:'tenant'} traversal is driven by registry
181
- // listing at the handle layer. Return empty here as a placeholder
182
- // — the handle expands tenant-scope into per-shard calls.
183
- return [];
184
- }
185
- return [scope.shardId];
186
- }, _SyncEngine_emit = function _SyncEngine_emit(change) {
187
- documentChanges.emit(change);
188
- };
@@ -1 +0,0 @@
1
- export {};
@@ -1,169 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { MemoryDocumentBackend } from '../backends';
3
- import { documentChanges } from '../notifications';
4
- import { SyncEngine } from './engine';
5
- import { hashContent } from './hash';
6
- async function setup() {
7
- const backend = new MemoryDocumentBackend();
8
- const engine = new SyncEngine(backend, 'tenant-a', { segmentSize: 10 });
9
- await engine.init();
10
- return { backend, engine };
11
- }
12
- describe('SyncEngine', () => {
13
- describe('apply upsert', () => {
14
- it('writes when absent and returns applied with newHash', async () => {
15
- const { backend, engine } = await setup();
16
- const hash = await hashContent('hello');
17
- const out = await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
18
- path: 'a.md', shardId: 's1', op: 'upsert',
19
- content: 'hello', remoteHash: hash, remoteMtime: Date.now(),
20
- });
21
- expect(out).toEqual({ status: 'applied', newHash: hash });
22
- expect(await backend.read('tenant-a', 's1', 'a.md')).toBe('hello');
23
- });
24
- it('skipped-identical when local hash already equals remote', async () => {
25
- const { backend, engine } = await setup();
26
- await backend.write('tenant-a', 's1', 'a.md', 'hello');
27
- const hash = await hashContent('hello');
28
- const out = await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
29
- path: 'a.md', shardId: 's1', op: 'upsert',
30
- content: 'hello', remoteHash: hash, remoteMtime: Date.now(),
31
- });
32
- expect(out).toEqual({ status: 'skipped-identical' });
33
- });
34
- it('idempotent: second apply after first returns skipped-identical', async () => {
35
- const { engine } = await setup();
36
- const hash = await hashContent('hi');
37
- const entry = { path: 'a.md', shardId: 's1', op: 'upsert', content: 'hi', remoteHash: hash, remoteMtime: 1 };
38
- const first = await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, entry);
39
- const second = await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, entry);
40
- expect(first.status).toBe('applied');
41
- expect(second.status).toBe('skipped-identical');
42
- });
43
- it('emits DocumentChange on applied upsert', async () => {
44
- const { engine } = await setup();
45
- const seen = [];
46
- const unsub = documentChanges.subscribe((c) => seen.push(c));
47
- const hash = await hashContent('hi');
48
- await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
49
- path: 'a.md', shardId: 's1', op: 'upsert',
50
- content: 'hi', remoteHash: hash, remoteMtime: 1,
51
- });
52
- unsub();
53
- expect(seen.some((c) => c.shardId === 's1' && c.path === 'a.md' && c.type === 'create')).toBe(true);
54
- });
55
- });
56
- describe('conflict', () => {
57
- it('default policy creates .sync-conflict artifact and returns conflict', async () => {
58
- const { backend, engine } = await setup();
59
- await backend.write('tenant-a', 's1', 'a.md', 'local-version');
60
- vi.useFakeTimers().setSystemTime(new Date(1234));
61
- const out = await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
62
- path: 'a.md', shardId: 's1', op: 'upsert',
63
- content: 'remote-version',
64
- remoteHash: await hashContent('remote-version'),
65
- remoteMtime: Date.now(),
66
- }, { expectedLocalHash: 'STALE' });
67
- if (out.status !== 'conflict')
68
- throw new Error('expected conflict');
69
- expect(out.resolution.conflictArtifactPath).toContain('.sync-conflict-conn-A-1234');
70
- expect(await backend.read('tenant-a', 's1', 'a.md')).toBe('local-version');
71
- vi.useRealTimers();
72
- });
73
- it('remote-wins overwrites silently', async () => {
74
- const { backend, engine } = await setup();
75
- await backend.write('tenant-a', 's1', 'a.md', 'local');
76
- const out = await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
77
- path: 'a.md', shardId: 's1', op: 'upsert',
78
- content: 'remote', remoteHash: await hashContent('remote'), remoteMtime: 1,
79
- }, { expectedLocalHash: 'STALE', onConflict: 'remote-wins' });
80
- expect(out.status).toBe('applied');
81
- expect(await backend.read('tenant-a', 's1', 'a.md')).toBe('remote');
82
- });
83
- it('local-wins leaves local untouched', async () => {
84
- const { backend, engine } = await setup();
85
- await backend.write('tenant-a', 's1', 'a.md', 'local');
86
- const out = await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
87
- path: 'a.md', shardId: 's1', op: 'upsert',
88
- content: 'remote', remoteHash: await hashContent('remote'), remoteMtime: 1,
89
- }, { expectedLocalHash: 'STALE', onConflict: 'local-wins' });
90
- expect(out.status).toBe('skipped-identical');
91
- expect(await backend.read('tenant-a', 's1', 'a.md')).toBe('local');
92
- });
93
- });
94
- describe('apply delete & tombstones', () => {
95
- it('delete records tombstone and removes content', async () => {
96
- const { backend, engine } = await setup();
97
- await backend.write('tenant-a', 's1', 'a.md', 'x');
98
- const out = await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
99
- path: 'a.md', shardId: 's1', op: 'delete',
100
- remoteHash: '', remoteMtime: 1,
101
- });
102
- expect(out.status).toBe('applied');
103
- expect(await backend.exists('tenant-a', 's1', 'a.md')).toBe(false);
104
- const manifest = await engine.getManifest('conn-A', { kind: 'shard', shardId: 's1' });
105
- expect(manifest.some((m) => m.path === 'a.md' && m.tombstone)).toBe(true);
106
- });
107
- it('upsert after delete clears the tombstone', async () => {
108
- const { engine } = await setup();
109
- await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
110
- path: 'a.md', shardId: 's1', op: 'upsert',
111
- content: 'v1', remoteHash: await hashContent('v1'), remoteMtime: 1,
112
- });
113
- await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
114
- path: 'a.md', shardId: 's1', op: 'delete',
115
- remoteHash: '', remoteMtime: 2,
116
- });
117
- await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
118
- path: 'a.md', shardId: 's1', op: 'upsert',
119
- content: 'v2', remoteHash: await hashContent('v2'), remoteMtime: 3,
120
- });
121
- const manifest = await engine.getManifest('conn-A', { kind: 'shard', shardId: 's1' });
122
- const entry = manifest.find((m) => m.path === 'a.md');
123
- expect(entry === null || entry === void 0 ? void 0 : entry.tombstone).toBeUndefined();
124
- });
125
- it('forget removes the tombstone', async () => {
126
- const { engine } = await setup();
127
- await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
128
- path: 'a.md', shardId: 's1', op: 'upsert',
129
- content: 'x', remoteHash: await hashContent('x'), remoteMtime: 1,
130
- });
131
- await engine.apply('conn-A', { kind: 'shard', shardId: 's1' }, {
132
- path: 'a.md', shardId: 's1', op: 'delete',
133
- remoteHash: '', remoteMtime: 2,
134
- });
135
- await engine.forget({ kind: 'shard', shardId: 's1' }, 'a.md');
136
- const manifest = await engine.getManifest('conn-A', { kind: 'shard', shardId: 's1' });
137
- expect(manifest.find((m) => m.path === 'a.md')).toBeUndefined();
138
- });
139
- });
140
- describe('applyBatch', () => {
141
- it('returns applied/skipped/conflicts breakdown', async () => {
142
- const { backend, engine } = await setup();
143
- await backend.write('tenant-a', 's1', 'already.md', 'same');
144
- const hSame = await hashContent('same');
145
- const hNew = await hashContent('new');
146
- const hConflict = await hashContent('remote');
147
- await backend.write('tenant-a', 's1', 'conflict.md', 'local');
148
- const res = await engine.applyBatch('conn-A', { kind: 'shard', shardId: 's1' }, [
149
- { path: 'already.md', shardId: 's1', op: 'upsert', content: 'same', remoteHash: hSame, remoteMtime: 1 },
150
- { path: 'new.md', shardId: 's1', op: 'upsert', content: 'new', remoteHash: hNew, remoteMtime: 1 },
151
- { path: 'conflict.md', shardId: 's1', op: 'upsert', content: 'remote', remoteHash: hConflict, remoteMtime: 1 },
152
- ], { expectedLocalHash: undefined });
153
- expect(res.applied.map((a) => a.path).sort()).toEqual(['conflict.md', 'new.md']);
154
- expect(res.skipped.map((s) => s.path)).toEqual(['already.md']);
155
- // First-seen with no expectedLocalHash and no prior base → apply without conflict.
156
- expect(res.conflicts).toEqual([]);
157
- });
158
- });
159
- describe('getManifest', () => {
160
- it('returns live docs with hashes', async () => {
161
- const { backend, engine } = await setup();
162
- await backend.write('tenant-a', 's1', 'a.md', 'hello');
163
- const manifest = await engine.getManifest('conn-A', { kind: 'shard', shardId: 's1' });
164
- const entry = manifest.find((m) => m.path === 'a.md');
165
- expect(entry === null || entry === void 0 ? void 0 : entry.hash).toBe(await hashContent('hello'));
166
- expect(entry === null || entry === void 0 ? void 0 : entry.shardId).toBe('s1');
167
- });
168
- });
169
- });
@@ -1,11 +0,0 @@
1
- import type { SyncEngine } from './engine';
2
- import type { SyncRegistry } from './registry';
3
- import { type SyncHandle } from './types';
4
- interface SyncHandleDeps {
5
- tenantId: string;
6
- connectorId: string;
7
- engine: SyncEngine;
8
- registry: SyncRegistry;
9
- }
10
- export declare function createSyncHandle(deps: SyncHandleDeps): SyncHandle;
11
- export {};
@@ -1,79 +0,0 @@
1
- /*
2
- * SyncHandle factory — binds tenantId + connectorId, validates scope
3
- * grants per call, and fans {kind:'tenant'} scopes across the granted
4
- * shard/path scopes before delegating to the engine.
5
- */
6
- import { ScopeNotGrantedError, } from './types';
7
- function scopeContains(parent, child) {
8
- if (parent.kind === 'tenant')
9
- return true;
10
- if (parent.kind === 'shard') {
11
- if (child.kind === 'shard')
12
- return parent.shardId === child.shardId;
13
- if (child.kind === 'path')
14
- return parent.shardId === child.shardId;
15
- return false;
16
- }
17
- // parent.kind === 'path'
18
- if (child.kind === 'path')
19
- return child.shardId === parent.shardId && child.prefix.startsWith(parent.prefix);
20
- return false;
21
- }
22
- export function createSyncHandle(deps) {
23
- const { connectorId, engine, registry } = deps;
24
- async function currentGrants() {
25
- const records = await registry.list(connectorId);
26
- return records.map((r) => r.scope);
27
- }
28
- async function requireScope(requested) {
29
- const grants = await currentGrants();
30
- const matching = grants.filter((g) => scopeContains(g, requested));
31
- if (matching.length === 0)
32
- throw new ScopeNotGrantedError(requested);
33
- if (requested.kind === 'tenant') {
34
- // Expand into the set of sub-scopes the connector is granted.
35
- const concrete = grants.filter((g) => g.kind === 'shard' || g.kind === 'path');
36
- return concrete.length > 0 ? concrete : [requested];
37
- }
38
- return [requested];
39
- }
40
- return {
41
- connectorId,
42
- async grantedScopes() {
43
- return currentGrants();
44
- },
45
- async getManifest(scope) {
46
- var _a;
47
- const concreteScopes = await requireScope(scope);
48
- const out = [];
49
- if (scope.kind === 'tenant' && ((_a = concreteScopes[0]) === null || _a === void 0 ? void 0 : _a.kind) !== 'tenant') {
50
- for (const s of concreteScopes)
51
- out.push(...await engine.getManifest(connectorId, s));
52
- }
53
- else {
54
- out.push(...await engine.getManifest(connectorId, scope));
55
- }
56
- return out;
57
- },
58
- async changesSince(scope, cursor) {
59
- await requireScope(scope);
60
- return engine.changesSince(scope, cursor);
61
- },
62
- async ack(scope, cursor) {
63
- await requireScope(scope);
64
- await engine.ack(connectorId, scope, cursor);
65
- },
66
- async apply(scope, entry, opts) {
67
- await requireScope(scope);
68
- return engine.apply(connectorId, scope, entry, opts);
69
- },
70
- async applyBatch(scope, manifest, opts) {
71
- await requireScope(scope);
72
- return engine.applyBatch(connectorId, scope, manifest, opts);
73
- },
74
- async forget(scope, path) {
75
- await requireScope(scope);
76
- await engine.forget(scope, path);
77
- },
78
- };
79
- }
@@ -1,56 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { MemoryDocumentBackend } from '../backends';
3
- import { SyncEngine } from './engine';
4
- import { createSyncHandle } from './handle';
5
- import { createSyncRegistry, __grantInternal } from './registry';
6
- import { hashContent } from './hash';
7
- import { ScopeNotGrantedError } from './types';
8
- async function setup() {
9
- const backend = new MemoryDocumentBackend();
10
- const engine = new SyncEngine(backend, 'tenant-a');
11
- await engine.init();
12
- const registry = createSyncRegistry(backend, 'tenant-a');
13
- const handle = createSyncHandle({ tenantId: 'tenant-a', connectorId: 'conn-A', engine, registry });
14
- return { backend, engine, registry, handle };
15
- }
16
- describe('createSyncHandle', () => {
17
- it('grantedScopes returns [] when no grants exist', async () => {
18
- const { handle } = await setup();
19
- expect(await handle.grantedScopes()).toEqual([]);
20
- });
21
- it('allows apply within a granted shard scope', async () => {
22
- const { backend, handle } = await setup();
23
- await __grantInternal(backend, 'tenant-a', 'conn-A', { kind: 'shard', shardId: 's1' });
24
- const h = await hashContent('x');
25
- const out = await handle.apply({ kind: 'shard', shardId: 's1' }, {
26
- path: 'a.md', shardId: 's1', op: 'upsert',
27
- content: 'x', remoteHash: h, remoteMtime: 1,
28
- });
29
- expect(out.status).toBe('applied');
30
- });
31
- it('throws ScopeNotGrantedError when scope is not granted', async () => {
32
- const { handle } = await setup();
33
- await expect(handle.apply({ kind: 'shard', shardId: 's2' }, {
34
- path: 'a.md', shardId: 's2', op: 'upsert',
35
- content: 'x', remoteHash: 'h', remoteMtime: 1,
36
- })).rejects.toBeInstanceOf(ScopeNotGrantedError);
37
- });
38
- it('revocation between calls takes effect immediately', async () => {
39
- const { backend, registry, handle } = await setup();
40
- await __grantInternal(backend, 'tenant-a', 'conn-A', { kind: 'shard', shardId: 's1' });
41
- await handle.getManifest({ kind: 'shard', shardId: 's1' });
42
- await registry.revoke('conn-A', { kind: 'shard', shardId: 's1' });
43
- await expect(handle.getManifest({ kind: 'shard', shardId: 's1' })).rejects.toBeInstanceOf(ScopeNotGrantedError);
44
- });
45
- it('tenant scope expands to granted shard scopes for getManifest', async () => {
46
- const { backend, handle } = await setup();
47
- await __grantInternal(backend, 'tenant-a', 'conn-A', { kind: 'shard', shardId: 's1' });
48
- await __grantInternal(backend, 'tenant-a', 'conn-A', { kind: 'shard', shardId: 's2' });
49
- await backend.write('tenant-a', 's1', 'a.md', 'A');
50
- await backend.write('tenant-a', 's2', 'b.md', 'B');
51
- // Grant also tenant scope itself:
52
- await __grantInternal(backend, 'tenant-a', 'conn-A', { kind: 'tenant' });
53
- const m = await handle.getManifest({ kind: 'tenant' });
54
- expect(m.map((e) => `${e.shardId}:${e.path}`).sort()).toEqual(['s1:a.md', 's2:b.md']);
55
- });
56
- });
@@ -1 +0,0 @@
1
- export declare function hashContent(content: string | ArrayBuffer): Promise<string>;
@@ -1,13 +0,0 @@
1
- /*
2
- * Content hashing for the sync subsystem. sha-256 via SubtleCrypto,
3
- * truncated to 16 hex chars — enough for collision resistance at
4
- * per-user document scale while keeping manifest entries small.
5
- */
6
- export async function hashContent(content) {
7
- const buf = typeof content === 'string' ? new TextEncoder().encode(content) : new Uint8Array(content);
8
- const digest = await crypto.subtle.digest('SHA-256', buf);
9
- const hex = Array.from(new Uint8Array(digest))
10
- .map((b) => b.toString(16).padStart(2, '0'))
11
- .join('');
12
- return hex.slice(0, 16);
13
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,20 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { hashContent } from './hash';
3
- describe('hashContent', () => {
4
- it('produces a 16-char lowercase hex string', async () => {
5
- const h = await hashContent('hello');
6
- expect(h).toMatch(/^[0-9a-f]{16}$/);
7
- });
8
- it('is deterministic for the same input', async () => {
9
- expect(await hashContent('abc')).toBe(await hashContent('abc'));
10
- });
11
- it('differs for different inputs', async () => {
12
- expect(await hashContent('abc')).not.toBe(await hashContent('abcd'));
13
- });
14
- it('handles ArrayBuffer input', async () => {
15
- const buf = new TextEncoder().encode('hello').buffer;
16
- const hStr = await hashContent('hello');
17
- const hBuf = await hashContent(buf);
18
- expect(hStr).toBe(hBuf);
19
- });
20
- });
@@ -1,5 +0,0 @@
1
- export type { SyncScope, SyncHandle, ManifestEntry, ApplyEntry, ApplyOpts, ApplyOutcome, ApplyBatchResult, ConflictPolicy, ConflictResolution, ConflictContext, JournalEntry, ChangePage, GrantRecord, } from './types';
2
- export { PERMISSION_DOCUMENTS_SYNC, SYNC_RESERVED_SHARD_ID, ScopeNotGrantedError, ScopeRevokedError, TenantMismatchError, } from './types';
3
- export type { SyncRegistry } from './registry';
4
- export { default as SyncGrantPicker } from './components/SyncGrantPicker.svelte';
5
- export { default as DocumentSyncExplorer } from './components/DocumentSyncExplorer.svelte';
@@ -1,10 +0,0 @@
1
- /*
2
- * Document Sync API — public surface.
3
- *
4
- * Connector shards consume this module via the main sh3-core barrel.
5
- * The __grantInternal helper is intentionally NOT re-exported here;
6
- * it is imported directly by SyncGrantPicker.svelte only.
7
- */
8
- export { PERMISSION_DOCUMENTS_SYNC, SYNC_RESERVED_SHARD_ID, ScopeNotGrantedError, ScopeRevokedError, TenantMismatchError, } from './types';
9
- export { default as SyncGrantPicker } from './components/SyncGrantPicker.svelte';
10
- export { default as DocumentSyncExplorer } from './components/DocumentSyncExplorer.svelte';
@@ -1,30 +0,0 @@
1
- import type { DocumentBackend } from '../types';
2
- import type { ChangePage, JournalEntry, SyncScope } from './types';
3
- export declare class Journal {
4
- #private;
5
- private backend;
6
- private tenantId;
7
- constructor(backend: DocumentBackend, tenantId: string, opts?: {
8
- segmentSize?: number;
9
- pageSize?: number;
10
- });
11
- init(): Promise<void>;
12
- static encodeCursor(seq: number, version: number): string;
13
- static decodeCursor(cursor: string): {
14
- seq: number;
15
- version: number;
16
- } | null;
17
- append(entry: Omit<JournalEntry, 'seq' | 'ts'>): Promise<JournalEntry>;
18
- oldestRetainedSeq(): Promise<number>;
19
- changesSince(scope: SyncScope, cursor?: string): Promise<ChangePage>;
20
- getCursor(connectorId: string): Promise<string | null>;
21
- ackCursor(connectorId: string, cursor: string): Promise<void>;
22
- dropCursor(connectorId: string): Promise<void>;
23
- listCursors(): Promise<Array<{
24
- connectorId: string;
25
- cursor: string;
26
- }>>;
27
- minSeqAckedByAll(connectorIds: string[]): Promise<number>;
28
- /** Test-only: simulate truncating all segments whose entries are <= uptoSeq. */
29
- __truncateForTest(uptoSeq: number): Promise<void>;
30
- }