sh3-core 0.7.5 → 0.8.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 (129) hide show
  1. package/dist/api.d.ts +11 -2
  2. package/dist/api.js +13 -1
  3. package/dist/app/store/StoreView.svelte +36 -7
  4. package/dist/app/store/storeShard.svelte.js +9 -3
  5. package/dist/app/store/verbs.js +8 -2
  6. package/dist/apps/lifecycle.d.ts +11 -0
  7. package/dist/apps/lifecycle.js +21 -1
  8. package/dist/apps/lifecycle.test.js +50 -1
  9. package/dist/apps/types.d.ts +7 -2
  10. package/dist/createShell.d.ts +2 -0
  11. package/dist/createShell.js +9 -7
  12. package/dist/documents/handle.js +5 -0
  13. package/dist/documents/index.d.ts +1 -0
  14. package/dist/documents/index.js +1 -0
  15. package/dist/documents/journal-hook.d.ts +6 -0
  16. package/dist/documents/journal-hook.js +16 -0
  17. package/dist/documents/sync/activate-integration.test.d.ts +1 -0
  18. package/dist/documents/sync/activate-integration.test.js +37 -0
  19. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
  20. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
  21. package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
  22. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
  23. package/dist/documents/sync/conflicts.d.ts +30 -0
  24. package/dist/documents/sync/conflicts.js +77 -0
  25. package/dist/documents/sync/conflicts.test.d.ts +1 -0
  26. package/dist/documents/sync/conflicts.test.js +71 -0
  27. package/dist/documents/sync/engine.d.ts +19 -0
  28. package/dist/documents/sync/engine.js +188 -0
  29. package/dist/documents/sync/engine.test.d.ts +1 -0
  30. package/dist/documents/sync/engine.test.js +169 -0
  31. package/dist/documents/sync/handle.d.ts +11 -0
  32. package/dist/documents/sync/handle.js +79 -0
  33. package/dist/documents/sync/handle.test.d.ts +1 -0
  34. package/dist/documents/sync/handle.test.js +56 -0
  35. package/dist/documents/sync/hash.d.ts +1 -0
  36. package/dist/documents/sync/hash.js +13 -0
  37. package/dist/documents/sync/hash.test.d.ts +1 -0
  38. package/dist/documents/sync/hash.test.js +20 -0
  39. package/dist/documents/sync/index.d.ts +6 -0
  40. package/dist/documents/sync/index.js +12 -0
  41. package/dist/documents/sync/journal.d.ts +30 -0
  42. package/dist/documents/sync/journal.js +179 -0
  43. package/dist/documents/sync/journal.test.d.ts +1 -0
  44. package/dist/documents/sync/journal.test.js +87 -0
  45. package/dist/documents/sync/registry.d.ts +10 -0
  46. package/dist/documents/sync/registry.js +66 -0
  47. package/dist/documents/sync/registry.test.d.ts +1 -0
  48. package/dist/documents/sync/registry.test.js +42 -0
  49. package/dist/documents/sync/serialization.d.ts +5 -0
  50. package/dist/documents/sync/serialization.js +24 -0
  51. package/dist/documents/sync/serialization.test.d.ts +1 -0
  52. package/dist/documents/sync/serialization.test.js +26 -0
  53. package/dist/documents/sync/singleton.d.ts +11 -0
  54. package/dist/documents/sync/singleton.js +26 -0
  55. package/dist/documents/sync/tombstones.d.ts +19 -0
  56. package/dist/documents/sync/tombstones.js +58 -0
  57. package/dist/documents/sync/tombstones.test.d.ts +1 -0
  58. package/dist/documents/sync/tombstones.test.js +37 -0
  59. package/dist/documents/sync/types.d.ts +116 -0
  60. package/dist/documents/sync/types.js +27 -0
  61. package/dist/documents/sync/write-hook.test.d.ts +1 -0
  62. package/dist/documents/sync/write-hook.test.js +36 -0
  63. package/dist/env/client.d.ts +10 -5
  64. package/dist/env/client.js +12 -4
  65. package/dist/registry/installer.d.ts +10 -7
  66. package/dist/registry/installer.js +39 -35
  67. package/dist/registry/register.d.ts +17 -0
  68. package/dist/registry/register.js +22 -0
  69. package/dist/registry/register.test.d.ts +1 -0
  70. package/dist/registry/register.test.js +28 -0
  71. package/dist/shards/activate.svelte.js +23 -2
  72. package/dist/shards/types.d.ts +10 -1
  73. package/dist/shell-shard/Terminal.svelte +140 -33
  74. package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
  75. package/dist/shell-shard/auto-relocate.d.ts +12 -0
  76. package/dist/shell-shard/auto-relocate.js +20 -0
  77. package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
  78. package/dist/shell-shard/auto-relocate.test.js +35 -0
  79. package/dist/shell-shard/dispatch.d.ts +15 -0
  80. package/dist/shell-shard/dispatch.js +56 -0
  81. package/dist/shell-shard/modes/builtin.d.ts +5 -0
  82. package/dist/shell-shard/modes/builtin.js +18 -0
  83. package/dist/shell-shard/modes/prefs.d.ts +5 -0
  84. package/dist/shell-shard/modes/prefs.js +31 -0
  85. package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
  86. package/dist/shell-shard/modes/prefs.test.js +46 -0
  87. package/dist/shell-shard/modes/registry.d.ts +7 -0
  88. package/dist/shell-shard/modes/registry.js +27 -0
  89. package/dist/shell-shard/modes/registry.test.d.ts +1 -0
  90. package/dist/shell-shard/modes/registry.test.js +35 -0
  91. package/dist/shell-shard/modes/types.d.ts +8 -0
  92. package/dist/shell-shard/modes/types.js +1 -0
  93. package/dist/shell-shard/protocol.d.ts +6 -0
  94. package/dist/shell-shard/shellShard.svelte.js +5 -1
  95. package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
  96. package/dist/shell-shard/tenant-fs-client.js +44 -0
  97. package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
  98. package/dist/shell-shard/tenant-fs-client.test.js +49 -0
  99. package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
  100. package/dist/shell-shard/terminal-dispatch.test.js +53 -0
  101. package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
  102. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
  103. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
  104. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
  105. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
  106. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
  107. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
  108. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
  109. package/dist/shell-shard/toolbar/slots.d.ts +17 -0
  110. package/dist/shell-shard/toolbar/slots.js +26 -0
  111. package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
  112. package/dist/shell-shard/toolbar/slots.test.js +28 -0
  113. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  114. package/dist/shell-shard/verbs/cat.js +34 -0
  115. package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
  116. package/dist/shell-shard/verbs/cd.test.js +56 -0
  117. package/dist/shell-shard/verbs/env.d.ts +2 -0
  118. package/dist/shell-shard/verbs/env.js +14 -0
  119. package/dist/shell-shard/verbs/index.js +6 -1
  120. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  121. package/dist/shell-shard/verbs/ls.js +29 -0
  122. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  123. package/dist/shell-shard/verbs/ls.test.js +49 -0
  124. package/dist/shell-shard/verbs/session.d.ts +0 -1
  125. package/dist/shell-shard/verbs/session.js +58 -26
  126. package/dist/verbs/types.d.ts +2 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/package.json +1 -1
@@ -0,0 +1,70 @@
1
+ <script lang="ts">
2
+ import { getDocumentBackend, getTenantId } from '../../config';
3
+ import { __grantInternal } from '../registry';
4
+ import type { SyncScope } from '../types';
5
+
6
+ import type { Snippet } from 'svelte';
7
+
8
+ interface Props {
9
+ connectorId: string;
10
+ scope: SyncScope;
11
+ onGranted?: () => void;
12
+ onCancel?: () => void;
13
+ rationale?: Snippet;
14
+ }
15
+
16
+ let { connectorId, scope, onGranted, onCancel, rationale }: Props = $props();
17
+
18
+ let pending = $state(false);
19
+ let error = $state<string | null>(null);
20
+
21
+ function describe(s: SyncScope): string {
22
+ switch (s.kind) {
23
+ case 'tenant': return 'all your documents across every shard';
24
+ case 'shard': return `all documents for shard "${s.shardId}"`;
25
+ case 'path': return `documents under "${s.prefix}" in shard "${s.shardId}"`;
26
+ }
27
+ }
28
+
29
+ async function confirm() {
30
+ pending = true;
31
+ error = null;
32
+ try {
33
+ await __grantInternal(getDocumentBackend(), getTenantId(), connectorId, scope);
34
+ onGranted?.();
35
+ } catch (e) {
36
+ error = e instanceof Error ? e.message : String(e);
37
+ } finally {
38
+ pending = false;
39
+ }
40
+ }
41
+ </script>
42
+
43
+ <section class="sync-grant-picker" part="container">
44
+ <header part="header">
45
+ <h3 part="title">Grant sync access</h3>
46
+ </header>
47
+ <p part="summary">
48
+ <strong>{connectorId}</strong> is requesting access to {describe(scope)}.
49
+ </p>
50
+ {#if rationale}{@render rationale()}{/if}
51
+ {#if error}
52
+ <p class="error" part="error">{error}</p>
53
+ {/if}
54
+ <footer part="actions">
55
+ <button type="button" disabled={pending} onclick={() => onCancel?.()} part="cancel">Cancel</button>
56
+ <button type="button" disabled={pending} onclick={confirm} part="confirm">Grant</button>
57
+ </footer>
58
+ </section>
59
+
60
+ <style>
61
+ .sync-grant-picker {
62
+ display: grid;
63
+ gap: 0.75rem;
64
+ padding: 1rem;
65
+ border: 1px solid var(--sh3-border, #444);
66
+ border-radius: 6px;
67
+ }
68
+ .error { color: var(--sh3-error, #c00); }
69
+ footer { display: flex; gap: 0.5rem; justify-content: flex-end; }
70
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { SyncScope } from '../types';
2
+ import type { Snippet } from 'svelte';
3
+ interface Props {
4
+ connectorId: string;
5
+ scope: SyncScope;
6
+ onGranted?: () => void;
7
+ onCancel?: () => void;
8
+ rationale?: Snippet;
9
+ }
10
+ declare const SyncGrantPicker: import("svelte").Component<Props, {}, "">;
11
+ type SyncGrantPicker = ReturnType<typeof SyncGrantPicker>;
12
+ export default SyncGrantPicker;
@@ -0,0 +1,30 @@
1
+ import type { DocumentBackend } from '../types';
2
+ import type { ConflictPolicy, ConflictResolution } from './types';
3
+ interface ConflictInput {
4
+ connectorId: string;
5
+ shardId: string;
6
+ path: string;
7
+ localHash: string;
8
+ remoteHash: string;
9
+ remoteContent?: string | ArrayBuffer;
10
+ baseHash?: string;
11
+ }
12
+ export type ConflictAction = {
13
+ action: 'apply-remote';
14
+ asPath?: string;
15
+ } | {
16
+ action: 'skip';
17
+ } | {
18
+ action: 'conflict';
19
+ resolution: ConflictResolution;
20
+ };
21
+ export declare class ConflictManager {
22
+ private backend;
23
+ private tenantId;
24
+ constructor(backend: DocumentBackend, tenantId: string);
25
+ resolve(policy: ConflictPolicy, input: ConflictInput): Promise<ConflictAction>;
26
+ getBaseHash(connectorId: string, shardId: string, path: string): Promise<string | null>;
27
+ setBaseHash(connectorId: string, shardId: string, path: string, hash: string): Promise<void>;
28
+ listConflicts(shardId: string): Promise<ConflictResolution[]>;
29
+ }
30
+ export {};
@@ -0,0 +1,77 @@
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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
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
+ });
@@ -0,0 +1,19 @@
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
+ }
@@ -0,0 +1,188 @@
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
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,169 @@
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
+ });