sh3-core 0.8.1 → 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 (98) hide show
  1. package/dist/Shell.svelte +19 -0
  2. package/dist/api.d.ts +6 -6
  3. package/dist/api.js +6 -3
  4. package/dist/app/admin/ApiKeysView.svelte +16 -27
  5. package/dist/apps/types.d.ts +3 -5
  6. package/dist/documents/backends.d.ts +2 -0
  7. package/dist/documents/backends.js +6 -0
  8. package/dist/documents/handle.js +13 -5
  9. package/dist/documents/handle.test.js +55 -0
  10. package/dist/documents/http-backend.d.ts +11 -4
  11. package/dist/documents/http-backend.js +37 -11
  12. package/dist/documents/index.d.ts +2 -1
  13. package/dist/documents/index.js +1 -1
  14. package/dist/documents/sync-types.d.ts +45 -0
  15. package/dist/documents/sync-types.js +11 -0
  16. package/dist/documents/types.d.ts +40 -2
  17. package/dist/documents/types.js +3 -2
  18. package/dist/keys/ConsentDialog.svelte +176 -0
  19. package/dist/keys/ConsentDialog.svelte.d.ts +3 -0
  20. package/dist/keys/client.d.ts +13 -0
  21. package/dist/keys/client.js +65 -0
  22. package/dist/keys/client.test.js +44 -0
  23. package/dist/keys/consent.svelte.d.ts +16 -0
  24. package/dist/keys/consent.svelte.js +29 -0
  25. package/dist/keys/consent.test.js +54 -0
  26. package/dist/keys/revocation-bus.svelte.d.ts +35 -0
  27. package/dist/keys/revocation-bus.svelte.js +92 -0
  28. package/dist/keys/revocation-bus.test.js +95 -0
  29. package/dist/keys/types.d.ts +34 -0
  30. package/dist/keys/types.js +13 -0
  31. package/dist/server-shard/types.d.ts +68 -2
  32. package/dist/sh3core-shard/ShellHome.svelte +140 -63
  33. package/dist/sh3core-shard/sh3coreShard.svelte.js +12 -1
  34. package/dist/shards/activate-on-key-revoked.test.js +60 -0
  35. package/dist/shards/activate.svelte.js +21 -24
  36. package/dist/shards/types.d.ts +7 -13
  37. package/dist/shards/types.js +1 -1
  38. package/dist/shell/views/KeysAndPeers.svelte +110 -0
  39. package/dist/shell/views/KeysAndPeers.svelte.d.ts +3 -0
  40. package/dist/shell-shard/Terminal.svelte +0 -11
  41. package/dist/shell-shard/toolbar/Toolbar.svelte +11 -32
  42. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +0 -2
  43. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +29 -62
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/package.json +1 -1
  47. package/dist/documents/journal-hook.d.ts +0 -6
  48. package/dist/documents/journal-hook.js +0 -16
  49. package/dist/documents/sync/activate-integration.test.js +0 -37
  50. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +0 -99
  51. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +0 -15
  52. package/dist/documents/sync/components/SyncGrantPicker.svelte +0 -70
  53. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +0 -12
  54. package/dist/documents/sync/conflicts.d.ts +0 -30
  55. package/dist/documents/sync/conflicts.js +0 -77
  56. package/dist/documents/sync/conflicts.test.js +0 -71
  57. package/dist/documents/sync/engine.d.ts +0 -19
  58. package/dist/documents/sync/engine.js +0 -188
  59. package/dist/documents/sync/engine.test.js +0 -169
  60. package/dist/documents/sync/handle.d.ts +0 -11
  61. package/dist/documents/sync/handle.js +0 -79
  62. package/dist/documents/sync/handle.test.js +0 -56
  63. package/dist/documents/sync/hash.d.ts +0 -1
  64. package/dist/documents/sync/hash.js +0 -13
  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/shards/activate-sync-registry.test.d.ts +0 -1
  93. package/dist/shards/activate-sync-registry.test.js +0 -42
  94. /package/dist/documents/{sync/handle.test.d.ts → handle.test.d.ts} +0 -0
  95. /package/dist/{documents/sync/activate-integration.test.d.ts → keys/client.test.d.ts} +0 -0
  96. /package/dist/{documents/sync/conflicts.test.d.ts → keys/consent.test.d.ts} +0 -0
  97. /package/dist/{documents/sync/engine.test.d.ts → keys/revocation-bus.test.d.ts} +0 -0
  98. /package/dist/{documents/sync/hash.test.d.ts → shards/activate-on-key-revoked.test.d.ts} +0 -0
@@ -1,77 +0,0 @@
1
- /*
2
- * Conflict resolution — dispatches the caller-supplied (or default)
3
- * policy, writes .sync-conflict-* artifacts when required, and tracks
4
- * per-(connectorId, path) base hashes for three-way comparisons.
5
- */
6
- import { readJson, writeJson } from './serialization';
7
- const BASES_PREFIX = 'bases/';
8
- function baseKey(connectorId, shardId, path) {
9
- return `${BASES_PREFIX}${encodeURIComponent(connectorId)}__${shardId}__${encodeURIComponent(path)}.json`;
10
- }
11
- function isArtifactName(name) {
12
- return /\.sync-conflict-[^.]+-\d+$/.test(name);
13
- }
14
- export class ConflictManager {
15
- constructor(backend, tenantId) {
16
- this.backend = backend;
17
- this.tenantId = tenantId;
18
- }
19
- async resolve(policy, input) {
20
- const p = typeof policy === 'function' ? await policy({
21
- path: input.path, shardId: input.shardId,
22
- localHash: input.localHash, remoteHash: input.remoteHash, baseHash: input.baseHash,
23
- }) : policy;
24
- switch (p) {
25
- case 'remote-wins': return { action: 'apply-remote' };
26
- case 'local-wins': return { action: 'skip' };
27
- case 'keep-both': {
28
- const asPath = `${input.path}.incoming-${input.connectorId}-${Date.now()}`;
29
- return { action: 'apply-remote', asPath };
30
- }
31
- case 'default':
32
- default: {
33
- const ts = Date.now();
34
- const artifact = `${input.path}.sync-conflict-${input.connectorId}-${ts}`;
35
- if (input.remoteContent !== undefined) {
36
- await this.backend.write(this.tenantId, input.shardId, artifact, input.remoteContent);
37
- }
38
- const resolution = {
39
- path: input.path,
40
- shardId: input.shardId,
41
- localHash: input.localHash,
42
- remoteHash: input.remoteHash,
43
- conflictArtifactPath: artifact,
44
- base: input.baseHash ? { hash: input.baseHash } : undefined,
45
- };
46
- return { action: 'conflict', resolution };
47
- }
48
- }
49
- }
50
- async getBaseHash(connectorId, shardId, path) {
51
- return readJson(this.backend, this.tenantId, baseKey(connectorId, shardId, path));
52
- }
53
- async setBaseHash(connectorId, shardId, path, hash) {
54
- await writeJson(this.backend, this.tenantId, baseKey(connectorId, shardId, path), hash);
55
- }
56
- async listConflicts(shardId) {
57
- const metas = await this.backend.list(this.tenantId, shardId);
58
- const out = [];
59
- for (const m of metas) {
60
- const name = m.path;
61
- if (!isArtifactName(name))
62
- continue;
63
- const m2 = /^(.*)\.sync-conflict-([^-]+)-(\d+)$/.exec(name);
64
- if (!m2)
65
- continue;
66
- const originalPath = m2[1];
67
- out.push({
68
- path: originalPath,
69
- shardId,
70
- localHash: '',
71
- remoteHash: '',
72
- conflictArtifactPath: name,
73
- });
74
- }
75
- return out;
76
- }
77
- }
@@ -1,71 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest';
2
- import { MemoryDocumentBackend } from '../backends';
3
- import { ConflictManager } from './conflicts';
4
- describe('ConflictManager', () => {
5
- let backend;
6
- let mgr;
7
- beforeEach(() => {
8
- backend = new MemoryDocumentBackend();
9
- mgr = new ConflictManager(backend, 'tenant-a');
10
- });
11
- it('remote-wins: returns apply-remote=true', async () => {
12
- const r = await mgr.resolve('remote-wins', {
13
- connectorId: 'c1', shardId: 's1', path: 'a.md',
14
- localHash: 'L', remoteHash: 'R',
15
- });
16
- expect(r).toEqual({ action: 'apply-remote' });
17
- });
18
- it('local-wins: returns skip', async () => {
19
- const r = await mgr.resolve('local-wins', {
20
- connectorId: 'c1', shardId: 's1', path: 'a.md',
21
- localHash: 'L', remoteHash: 'R',
22
- });
23
- expect(r).toEqual({ action: 'skip' });
24
- });
25
- it('keep-both: returns apply-remote with renamed path', async () => {
26
- vi.useFakeTimers().setSystemTime(new Date(10000));
27
- const r = await mgr.resolve('keep-both', {
28
- connectorId: 'c1', shardId: 's1', path: 'a.md',
29
- localHash: 'L', remoteHash: 'R',
30
- });
31
- expect(r).toEqual({ action: 'apply-remote', asPath: 'a.md.incoming-c1-10000' });
32
- vi.useRealTimers();
33
- });
34
- it('default: writes .sync-conflict artifact, keeps local, returns resolution', async () => {
35
- vi.useFakeTimers().setSystemTime(new Date(20000));
36
- const r = await mgr.resolve('default', {
37
- connectorId: 'c1', shardId: 's1', path: 'dir/a.md',
38
- localHash: 'L', remoteHash: 'R', remoteContent: 'REMOTE',
39
- });
40
- expect(r.action).toBe('conflict');
41
- if (r.action !== 'conflict')
42
- throw new Error('unreachable');
43
- expect(r.resolution.conflictArtifactPath).toBe('dir/a.md.sync-conflict-c1-20000');
44
- // Artifact was written to the backend under the target shard.
45
- const read = await backend.read('tenant-a', 's1', 'dir/a.md.sync-conflict-c1-20000');
46
- expect(read).toBe('REMOTE');
47
- vi.useRealTimers();
48
- });
49
- it('function policy dispatches to returned action', async () => {
50
- const r = await mgr.resolve(async () => 'remote-wins', {
51
- connectorId: 'c1', shardId: 's1', path: 'a.md',
52
- localHash: 'L', remoteHash: 'R',
53
- });
54
- expect(r).toEqual({ action: 'apply-remote' });
55
- });
56
- it('stores and retrieves base hash per (connector, shard, path)', async () => {
57
- await mgr.setBaseHash('c1', 's1', 'a.md', 'H1');
58
- expect(await mgr.getBaseHash('c1', 's1', 'a.md')).toBe('H1');
59
- expect(await mgr.getBaseHash('c2', 's1', 'a.md')).toBeNull();
60
- });
61
- it('lists .sync-conflict-* artifacts within a shard', async () => {
62
- await backend.write('tenant-a', 's1', 'a.md.sync-conflict-c1-1', 'x');
63
- await backend.write('tenant-a', 's1', 'b.md.sync-conflict-c2-2', 'y');
64
- await backend.write('tenant-a', 's1', 'c.md', 'regular');
65
- const list = await mgr.listConflicts('s1');
66
- expect(list.map((c) => c.conflictArtifactPath).sort()).toEqual([
67
- 'a.md.sync-conflict-c1-1',
68
- 'b.md.sync-conflict-c2-2',
69
- ]);
70
- });
71
- });
@@ -1,19 +0,0 @@
1
- import type { DocumentBackend } from '../types';
2
- import type { ApplyBatchResult, ApplyEntry, ApplyOpts, ApplyOutcome, ChangePage, ManifestEntry, SyncScope } from './types';
3
- import { Journal } from './journal';
4
- export declare class SyncEngine {
5
- #private;
6
- private backend;
7
- private tenantId;
8
- constructor(backend: DocumentBackend, tenantId: string, opts?: {
9
- segmentSize?: number;
10
- });
11
- init(): Promise<void>;
12
- get journal(): Journal;
13
- getManifest(_connectorId: string, scope: SyncScope): Promise<ManifestEntry[]>;
14
- changesSince(scope: SyncScope, cursor?: string): Promise<ChangePage>;
15
- ack(connectorId: string, _scope: SyncScope, cursor: string): Promise<void>;
16
- apply(connectorId: string, scope: SyncScope, entry: ApplyEntry, opts?: ApplyOpts): Promise<ApplyOutcome>;
17
- applyBatch(connectorId: string, scope: SyncScope, manifest: ApplyEntry[], opts?: ApplyOpts): Promise<ApplyBatchResult>;
18
- forget(scope: SyncScope, path: string): Promise<void>;
19
- }
@@ -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,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
- }