mirror of
https://github.com/ichaly/cohere.git
synced 2026-07-22 07:47:20 +00:00
Polish sync protocol and status UI
This commit is contained in:
parent
c6622b2c06
commit
1411a9dd19
7 changed files with 276 additions and 175 deletions
|
|
@ -118,8 +118,76 @@ describe("auto sync scheduling", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("connection config import and export", () => {
|
||||
test("exports shareable fields without access credentials", () => {
|
||||
const plugin = createPlugin({
|
||||
accessKeyId: "AKIA_TEST",
|
||||
secretAccessKey: "SECRET_TEST",
|
||||
rootPrefix: "obsync/v2",
|
||||
accountKey: "team",
|
||||
vaultKey: "notes",
|
||||
vaultId: "vlt_notes",
|
||||
});
|
||||
|
||||
expect(plugin.getConnectionConfig()).toEqual({
|
||||
schemaVersion: 1,
|
||||
endpoint: "https://example.com",
|
||||
bucket: "vault",
|
||||
region: "auto",
|
||||
rootPrefix: "obsync/v2",
|
||||
accountKey: "team",
|
||||
vaultKey: "notes",
|
||||
vaultId: "vlt_notes",
|
||||
});
|
||||
expect(JSON.stringify(plugin.getConnectionConfig())).not.toContain("AKIA_TEST");
|
||||
expect(JSON.stringify(plugin.getConnectionConfig())).not.toContain("SECRET_TEST");
|
||||
});
|
||||
|
||||
test("imports connection config without replacing access credentials", async () => {
|
||||
const plugin = createPlugin({
|
||||
accessKeyId: "existing-key",
|
||||
secretAccessKey: "existing-secret",
|
||||
vaultId: "old-vault",
|
||||
});
|
||||
|
||||
await plugin.importConnectionConfig(JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
endpoint: "https://r2.example.com",
|
||||
bucket: "shared-vault",
|
||||
region: "auto",
|
||||
rootPrefix: "obsync/v2",
|
||||
accountKey: "team",
|
||||
vaultKey: "product",
|
||||
vaultId: "ignored-remote-id",
|
||||
}));
|
||||
|
||||
expect(plugin.settings).toMatchObject({
|
||||
endpoint: "https://r2.example.com",
|
||||
bucket: "shared-vault",
|
||||
region: "auto",
|
||||
rootPrefix: "obsync/v2",
|
||||
accountKey: "team",
|
||||
vaultKey: "product",
|
||||
accessKeyId: "existing-key",
|
||||
secretAccessKey: "existing-secret",
|
||||
});
|
||||
expect(plugin.settings.vaultId).not.toBe("old-vault");
|
||||
expect(plugin.settings.vaultId).not.toBe("ignored-remote-id");
|
||||
expect(plugin.saveSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rejects invalid connection config text", async () => {
|
||||
const plugin = createPlugin();
|
||||
|
||||
await expect(plugin.importConnectionConfig("not json")).rejects.toThrow("连接配置不是有效 JSON。");
|
||||
await expect(plugin.importConnectionConfig(JSON.stringify({ schemaVersion: 2 }))).rejects.toThrow("连接配置版本不支持。");
|
||||
await expect(plugin.importConnectionConfig(JSON.stringify({ schemaVersion: 1, endpoint: "x" }))).rejects.toThrow("连接配置缺少 bucket。");
|
||||
});
|
||||
});
|
||||
|
||||
function createPlugin(settings: Partial<TestSettings> = {}): TestPlugin {
|
||||
const plugin = Object.create(ObsyncPlugin.prototype) as TestPlugin;
|
||||
plugin.app = { vault: { getName: () => "ichaly" } };
|
||||
plugin.settings = {
|
||||
endpoint: "https://example.com",
|
||||
bucket: "vault",
|
||||
|
|
@ -137,12 +205,17 @@ function createPlugin(settings: Partial<TestSettings> = {}): TestPlugin {
|
|||
syncState: { files: {} },
|
||||
...settings,
|
||||
};
|
||||
plugin.saveSettings = vi.fn(async () => {});
|
||||
plugin.syncNow = vi.fn(async () => {});
|
||||
return plugin;
|
||||
}
|
||||
|
||||
type TestPlugin = {
|
||||
app: { vault: { getName: () => string } };
|
||||
settings: TestSettings;
|
||||
getConnectionConfig: () => Record<string, string | number>;
|
||||
importConnectionConfig: (text: string) => Promise<void>;
|
||||
saveSettings: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
queueAutoSync: (delayMs?: number) => void;
|
||||
runAutoSync: () => Promise<void>;
|
||||
syncNow: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
|
|
|
|||
67
src/main.ts
67
src/main.ts
|
|
@ -62,9 +62,13 @@ export default class ObsyncPlugin extends Plugin {
|
|||
private autoSyncTimer: number | null = null;
|
||||
private autoSyncRunning = false;
|
||||
private autoSyncQueued = false;
|
||||
private statusBarItem: HTMLElement | null = null;
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
this.statusBarItem = this.addStatusBarItem();
|
||||
this.statusBarItem.classList.add("obsync-status-bar-item");
|
||||
this.clearOperationStatus();
|
||||
|
||||
this.addCommand({
|
||||
id: "manual-sync",
|
||||
|
|
@ -104,14 +108,7 @@ export default class ObsyncPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async syncNow(): Promise<void> {
|
||||
if (!this.settings.endpoint || !this.settings.bucket || !this.settings.accessKeyId || !this.settings.secretAccessKey) {
|
||||
new Notice("请先填写端点、Bucket、Access Key ID 和 Secret Access Key。");
|
||||
return;
|
||||
}
|
||||
|
||||
const syncingNotice = new Notice("Obsync 正在同步...", 0);
|
||||
|
||||
try {
|
||||
await this.runConfiguredOperation("同步中...", "Obsync 同步失败", async () => {
|
||||
const store = this.createObjectStore();
|
||||
const result = await syncOnce({
|
||||
vault: new ObsidianVaultIO(this.app),
|
||||
|
|
@ -123,7 +120,6 @@ export default class ObsyncPlugin extends Plugin {
|
|||
});
|
||||
|
||||
await this.saveSettings();
|
||||
syncingNotice.hide();
|
||||
|
||||
if (result.locked) {
|
||||
new Notice("另一台设备正在同步,本次已跳过。");
|
||||
|
|
@ -131,11 +127,7 @@ export default class ObsyncPlugin extends Plugin {
|
|||
}
|
||||
|
||||
new Notice(`Obsync 同步完成:上传 ${result.uploaded},下载 ${result.downloaded},冲突 ${result.conflicts}。`);
|
||||
} catch (error) {
|
||||
syncingNotice.hide();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
new Notice(`Obsync 同步失败:${message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private moveRibbonIconToBottom(iconEl: HTMLElement): void {
|
||||
|
|
@ -214,17 +206,8 @@ export default class ObsyncPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async releaseDeletedContentNow(): Promise<void> {
|
||||
if (!this.hasConnectionSettings()) {
|
||||
new Notice("请先填写端点、Bucket、Access Key ID 和 Secret Access Key。");
|
||||
return;
|
||||
}
|
||||
|
||||
const notice = new Notice("Obsync 正在释放已删除内容...", 0);
|
||||
|
||||
try {
|
||||
const store = this.createObjectStore();
|
||||
const result = await releaseDeletedContent({ store, now: () => Date.now() });
|
||||
notice.hide();
|
||||
await this.runConfiguredOperation("清理中...", "释放已删除内容失败", async () => {
|
||||
const result = await releaseDeletedContent({ store: this.createObjectStore(), now: () => Date.now() });
|
||||
|
||||
if (result.locked) {
|
||||
new Notice("另一台设备正在同步,本次释放空间已跳过。");
|
||||
|
|
@ -234,11 +217,37 @@ export default class ObsyncPlugin extends Plugin {
|
|||
this.pruneLocalDeletedState();
|
||||
await this.saveSettings();
|
||||
new Notice(`Obsync 已释放:清理删除记录 ${result.deletedTombstones},删除 Blob ${result.deletedBlobs}。`);
|
||||
} catch (error) {
|
||||
notice.hide();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
new Notice(`释放已删除内容失败:${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async runConfiguredOperation(progressText: string, failurePrefix: string, operation: () => Promise<void>): Promise<void> {
|
||||
if (!this.hasConnectionSettings()) {
|
||||
new Notice("请先填写端点、Bucket、Access Key ID 和 Secret Access Key。");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setOperationStatus(progressText);
|
||||
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
new Notice(`${failurePrefix}:${message}`);
|
||||
} finally {
|
||||
this.clearOperationStatus();
|
||||
}
|
||||
}
|
||||
|
||||
private setOperationStatus(text: string): void {
|
||||
this.statusBarItem?.classList.remove("is-hidden");
|
||||
this.statusBarItem?.setText(text);
|
||||
this.statusBarItem?.setAttribute("title", text);
|
||||
}
|
||||
|
||||
private clearOperationStatus(): void {
|
||||
this.statusBarItem?.classList.add("is-hidden");
|
||||
this.statusBarItem?.setText("");
|
||||
this.statusBarItem?.removeAttribute("title");
|
||||
}
|
||||
|
||||
private createObjectStore(): S3ObjectStore {
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ watchEffect(() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="obsync-row">
|
||||
<div class="obsync-row obsync-toggle-row">
|
||||
<div class="obsync-row-copy">
|
||||
<h3>自动同步</h3>
|
||||
<p>启动、恢复前台和文件变化时自动同步。</p>
|
||||
|
|
|
|||
|
|
@ -236,13 +236,7 @@ function formatAmzDate(timestamp: number): string {
|
|||
|
||||
function createCanonicalQueryString(url: URL): string {
|
||||
return Array.from(url.searchParams.entries())
|
||||
.sort(([leftKey, leftValue], [rightKey, rightValue]) => {
|
||||
if (leftKey === rightKey) {
|
||||
return leftValue.localeCompare(rightValue);
|
||||
}
|
||||
|
||||
return leftKey.localeCompare(rightKey);
|
||||
})
|
||||
.sort(([leftKey, leftValue], [rightKey, rightValue]) => leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue))
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
}
|
||||
|
|
@ -262,15 +256,7 @@ function safeParseJson(value: string): unknown {
|
|||
}
|
||||
|
||||
function parseListBucketKeys(xml: string): string[] {
|
||||
const keys: string[] = [];
|
||||
const keyPattern = /<Key>([^<]*)<\/Key>/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = keyPattern.exec(xml)) !== null) {
|
||||
keys.push(decodeXml(match[1]));
|
||||
}
|
||||
|
||||
return keys;
|
||||
return readXmlTags(xml, "Key");
|
||||
}
|
||||
|
||||
function readListContinuationToken(xml: string): string | undefined {
|
||||
|
|
@ -278,8 +264,19 @@ function readListContinuationToken(xml: string): string | undefined {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const match = xml.match(/<NextContinuationToken>([^<]*)<\/NextContinuationToken>/);
|
||||
return match ? decodeXml(match[1]) : undefined;
|
||||
return readXmlTags(xml, "NextContinuationToken")[0];
|
||||
}
|
||||
|
||||
function readXmlTags(xml: string, tag: string): string[] {
|
||||
const values: string[] = [];
|
||||
const pattern = new RegExp(`<${tag}>([^<]*)<\\/${tag}>`, "g");
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(xml)) !== null) {
|
||||
values.push(decodeXml(match[1]));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function decodeXml(value: string): string {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@
|
|||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.obsync-status-bar-item {
|
||||
@apply flex items-center gap-1 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.obsync-status-bar-item.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.obsync-panel {
|
||||
@apply w-full max-w-3xl pb-6;
|
||||
color: var(--text-normal);
|
||||
|
|
@ -232,6 +241,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
.obsync-toggle-row {
|
||||
@apply flex items-center justify-between gap-5;
|
||||
|
||||
.obsync-row-copy {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
.obsync-row-block {
|
||||
@apply block;
|
||||
}
|
||||
|
|
@ -282,6 +299,14 @@
|
|||
@apply mb-2;
|
||||
}
|
||||
|
||||
.obsync-toggle-row {
|
||||
@apply flex gap-4;
|
||||
|
||||
.obsync-row-copy {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
.obsync-row-title {
|
||||
@apply items-start;
|
||||
}
|
||||
|
|
@ -309,7 +334,7 @@
|
|||
@apply mt-2;
|
||||
}
|
||||
|
||||
.obsync-switch {
|
||||
.obsync-row:not(.obsync-toggle-row) .obsync-switch {
|
||||
@apply mt-2;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ describe("sync engine", () => {
|
|||
const store = new FakeObjectStore();
|
||||
const state: LocalSyncState = { files: {} };
|
||||
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
|
||||
const result = await sync(vault, store, state, 1000);
|
||||
const hash = await hashText("hello");
|
||||
|
||||
expect(result.uploaded).toBe(1);
|
||||
|
|
@ -21,9 +21,9 @@ describe("sync engine", () => {
|
|||
const store = new FakeObjectStore();
|
||||
const state: LocalSyncState = { files: {} };
|
||||
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
|
||||
await sync(vault, store, state, 1000);
|
||||
vault.readCount = 0;
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
|
||||
const result = await sync(vault, store, state, 2000);
|
||||
|
||||
expect(result.uploaded).toBe(0);
|
||||
expect(result.downloaded).toBe(0);
|
||||
|
|
@ -35,11 +35,11 @@ describe("sync engine", () => {
|
|||
const store = new FakeObjectStore();
|
||||
const state: LocalSyncState = { files: {} };
|
||||
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
|
||||
await sync(vault, store, state, 1000);
|
||||
store.acquireLockCount = 0;
|
||||
store.writeManifestCount = 0;
|
||||
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
|
||||
const result = await sync(vault, store, state, 2000);
|
||||
|
||||
expect(result.locked).toBe(false);
|
||||
expect(store.acquireLockCount).toBe(0);
|
||||
|
|
@ -69,7 +69,7 @@ describe("sync engine", () => {
|
|||
version: "ver_remote",
|
||||
};
|
||||
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
|
||||
const result = await sync(vault, store, state, 2000);
|
||||
|
||||
expect(result.downloaded).toBe(1);
|
||||
expect(await vault.readText("notes/today.md")).toBe("remote");
|
||||
|
|
@ -157,7 +157,7 @@ describe("sync engine", () => {
|
|||
const store = new FakeObjectStore();
|
||||
const state: LocalSyncState = { files: {} };
|
||||
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
|
||||
await sync(vault, store, state, 1000);
|
||||
|
||||
const hash = await hashText("hello");
|
||||
expect(await store.getText(`blobs/sha256/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`)).toBe("hello");
|
||||
|
|
@ -176,7 +176,7 @@ describe("sync engine", () => {
|
|||
createdAt: 1000,
|
||||
};
|
||||
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
|
||||
await sync(vault, store, state, 2000);
|
||||
|
||||
expect(await store.getText(blobObjectKey(hash))).toBe("hello");
|
||||
});
|
||||
|
|
@ -186,10 +186,10 @@ describe("sync engine", () => {
|
|||
const store = new FakeObjectStore();
|
||||
const state: LocalSyncState = { files: {} };
|
||||
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
|
||||
await sync(vault, store, state, 1000);
|
||||
const hash = await hashText("hello");
|
||||
await vault.delete("notes/today.md");
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
|
||||
const result = await sync(vault, store, state, 2000);
|
||||
|
||||
expect(result.deletedRemote).toBe(1);
|
||||
expect(result.conflicts).toBe(0);
|
||||
|
|
@ -222,7 +222,7 @@ describe("sync engine", () => {
|
|||
version: "ver_remote",
|
||||
};
|
||||
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
|
||||
const result = await sync(vault, store, state, 2000);
|
||||
|
||||
expect(result.deletedRemote).toBe(1);
|
||||
expect(result.conflicts).toBe(0);
|
||||
|
|
@ -252,7 +252,7 @@ describe("sync engine", () => {
|
|||
deletedBy: "dev_other",
|
||||
};
|
||||
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 });
|
||||
const result = await sync(vault, store, state, 3000);
|
||||
|
||||
expect(result.deletedLocal).toBe(1);
|
||||
await expect(vault.readText("notes/today.md")).resolves.toBe("");
|
||||
|
|
@ -281,7 +281,7 @@ describe("sync engine", () => {
|
|||
deletedBy: "dev_other",
|
||||
};
|
||||
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 });
|
||||
const result = await sync(vault, store, state, 3000);
|
||||
|
||||
expect(result.conflicts).toBe(1);
|
||||
expect(result.deletedLocal).toBe(1);
|
||||
|
|
@ -303,7 +303,7 @@ describe("sync engine", () => {
|
|||
deletedBy: "dev_other",
|
||||
};
|
||||
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 });
|
||||
const result = await sync(vault, store, state, 3000);
|
||||
|
||||
expect(result.conflicts).toBe(1);
|
||||
expect(result.uploaded).toBe(0);
|
||||
|
|
@ -334,7 +334,7 @@ describe("sync engine", () => {
|
|||
};
|
||||
store.writeManifestCount = 0;
|
||||
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 + 29 * 24 * 60 * 60 * 1000 });
|
||||
await sync(vault, store, state, 1000 + 29 * 24 * 60 * 60 * 1000);
|
||||
|
||||
expect(store.manifest.deleted["notes/today.md"]).toBeDefined();
|
||||
expect(state.files["notes/today.md"]?.deleted).toBe(true);
|
||||
|
|
@ -362,7 +362,7 @@ describe("sync engine", () => {
|
|||
deletedBy: "dev_other",
|
||||
};
|
||||
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 + 30 * 24 * 60 * 60 * 1000 });
|
||||
await sync(vault, store, state, 1000 + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
expect(store.manifest.deleted["notes/today.md"]).toBeUndefined();
|
||||
expect(state.files["notes/today.md"]).toBeUndefined();
|
||||
|
|
@ -402,7 +402,7 @@ describe("sync engine", () => {
|
|||
deletedBy: "dev_mac",
|
||||
};
|
||||
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 + 30 * 24 * 60 * 60 * 1000 });
|
||||
const result = await sync(vault, store, state, 1000 + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
expect(result.uploaded).toBe(0);
|
||||
expect(store.manifest.deleted["notes/today.md"]).toBeUndefined();
|
||||
|
|
@ -416,13 +416,13 @@ describe("sync engine", () => {
|
|||
const store = new FakeObjectStore();
|
||||
const state: LocalSyncState = { files: {} };
|
||||
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
|
||||
await sync(vault, store, state, 1000);
|
||||
const oldVersion = store.manifest.paths["notes/today.md"]?.version;
|
||||
await vault.delete("notes/today.md");
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
|
||||
await sync(vault, store, state, 2000);
|
||||
await vault.write("notes/today.md", new TextEncoder().encode("new"));
|
||||
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 });
|
||||
const result = await sync(vault, store, state, 3000);
|
||||
|
||||
expect(result.uploaded).toBe(1);
|
||||
expect(store.manifest.paths["notes/today.md"]?.contentHash).toBe(await hashText("new"));
|
||||
|
|
@ -436,13 +436,13 @@ describe("sync engine", () => {
|
|||
const store = new FakeObjectStore();
|
||||
const state: LocalSyncState = { files: {} };
|
||||
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
|
||||
await sync(vault, store, state, 1000);
|
||||
const oldVersion = store.manifest.paths["notes/today.md"]?.version;
|
||||
await vault.delete("notes/today.md");
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
|
||||
await sync(vault, store, state, 2000);
|
||||
await vault.write("notes/today.md", new TextEncoder().encode("same"));
|
||||
|
||||
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 });
|
||||
await sync(vault, store, state, 3000);
|
||||
|
||||
expect(store.manifest.paths["notes/today.md"]?.contentHash).toBe(await hashText("same"));
|
||||
expect(store.manifest.paths["notes/today.md"]?.version).not.toBe(oldVersion);
|
||||
|
|
@ -480,7 +480,7 @@ describe("sync engine", () => {
|
|||
deletedBy: "dev_other",
|
||||
};
|
||||
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 4000 });
|
||||
const result = await sync(vault, store, state, 4000);
|
||||
|
||||
expect(result.conflicts).toBe(1);
|
||||
expect(await vault.readText("notes/today.md")).toBe("local-new");
|
||||
|
|
@ -512,8 +512,8 @@ describe("sync engine", () => {
|
|||
version: "ver_remote",
|
||||
};
|
||||
|
||||
const firstResult = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 });
|
||||
const secondResult = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 4000 });
|
||||
const firstResult = await sync(vault, store, state, 3000);
|
||||
const secondResult = await sync(vault, store, state, 4000);
|
||||
|
||||
expect(firstResult.conflicts).toBe(1);
|
||||
expect(secondResult.conflicts).toBe(0);
|
||||
|
|
@ -603,7 +603,7 @@ describe("sync engine", () => {
|
|||
version: "ver_remote",
|
||||
};
|
||||
|
||||
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
|
||||
const result = await sync(vault, store, state, 2000);
|
||||
|
||||
expect(result.conflicts).toBe(1);
|
||||
expect(await vault.readText("notes/today.md")).toBe("local");
|
||||
|
|
@ -704,6 +704,10 @@ class FakeObjectStore implements ObjectStore {
|
|||
}
|
||||
}
|
||||
|
||||
function sync(vault: VaultIO, store: ObjectStore, state: LocalSyncState, timestamp: number) {
|
||||
return syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => timestamp });
|
||||
}
|
||||
|
||||
async function hashText(value: string): Promise<string> {
|
||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
||||
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
|
|
|
|||
|
|
@ -143,104 +143,7 @@ export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
|
|||
}
|
||||
|
||||
try {
|
||||
await mapLimit(actions, SYNC_CONCURRENCY, async (item) => {
|
||||
if (item.action === "upload" && item.local) {
|
||||
await writeBlob(input.store, manifest, item.local.hash, item.local.bytes, item.local.size, input.now());
|
||||
manifest.paths[item.path] = {
|
||||
contentHash: item.local.hash,
|
||||
size: item.local.size,
|
||||
updatedAt: input.now(),
|
||||
updatedBy: input.deviceId,
|
||||
revision: nextRevision(manifest),
|
||||
version: createVersion(input.deviceId, input.now(), item.local.hash),
|
||||
};
|
||||
input.state.files[item.path] = syncedState(item.local.hash, item.local, manifest.paths[item.path].version);
|
||||
result.uploaded += 1;
|
||||
}
|
||||
|
||||
if (item.action === "download" && item.remote) {
|
||||
const bytes = await input.store.readObject(blobObjectKey(item.remote.contentHash));
|
||||
await input.vault.write(item.path, bytes);
|
||||
input.state.files[item.path] = syncedState(item.remote.contentHash, { size: bytes.byteLength, mtime: null }, item.remote.version);
|
||||
result.downloaded += 1;
|
||||
}
|
||||
|
||||
if (item.action === "conflict" && item.remote) {
|
||||
const bytes = await input.store.readObject(blobObjectKey(item.remote.contentHash));
|
||||
await input.vault.write(createConflictPath(item.path, input.deviceName, input.now()), bytes);
|
||||
input.state.files[item.path] = {
|
||||
lastSyncedHash: item.lastSyncedHash,
|
||||
remoteHash: item.remote.contentHash,
|
||||
deleted: false,
|
||||
version: item.remote.version,
|
||||
localMtime: null,
|
||||
localSize: null,
|
||||
};
|
||||
result.conflicts += 1;
|
||||
}
|
||||
|
||||
if (item.action === "conflict" && item.deleted && item.local && !item.remote) {
|
||||
await input.vault.write(createConflictPath(item.path, input.deviceName, input.now()), item.local.bytes);
|
||||
await input.vault.delete(item.path);
|
||||
input.state.files[item.path] = {
|
||||
lastSyncedHash: item.deleted.previousContentHash,
|
||||
remoteHash: item.deleted.previousContentHash,
|
||||
deleted: true,
|
||||
version: item.deleted.previousVersion,
|
||||
localMtime: null,
|
||||
localSize: null,
|
||||
};
|
||||
result.conflicts += 1;
|
||||
result.deletedLocal += 1;
|
||||
}
|
||||
|
||||
if (item.action === "mark-remote-deleted") {
|
||||
const remote = manifest.paths[item.path];
|
||||
delete manifest.paths[item.path];
|
||||
manifest.deleted[item.path] = {
|
||||
deletedAt: input.now(),
|
||||
deletedRevision: nextRevision(manifest),
|
||||
previousContentHash: item.lastSyncedHash ?? remote?.contentHash ?? null,
|
||||
previousVersion: item.lastVersion ?? remote?.version ?? null,
|
||||
deletedBy: input.deviceId,
|
||||
};
|
||||
input.state.files[item.path] = {
|
||||
lastSyncedHash: item.lastSyncedHash ?? remote?.contentHash ?? null,
|
||||
remoteHash: item.lastSyncedHash ?? remote?.contentHash ?? null,
|
||||
deleted: true,
|
||||
version: item.lastVersion ?? remote?.version ?? null,
|
||||
localMtime: null,
|
||||
localSize: null,
|
||||
};
|
||||
result.deletedRemote += 1;
|
||||
}
|
||||
|
||||
if (item.action === "delete-local" && item.remote) {
|
||||
await input.vault.delete(item.path);
|
||||
input.state.files[item.path] = {
|
||||
lastSyncedHash: item.remote.contentHash,
|
||||
remoteHash: item.remote.contentHash,
|
||||
deleted: true,
|
||||
version: item.remote.version,
|
||||
localMtime: null,
|
||||
localSize: null,
|
||||
};
|
||||
result.deletedLocal += 1;
|
||||
}
|
||||
|
||||
if (item.action === "delete-local" && item.deleted) {
|
||||
await input.vault.delete(item.path);
|
||||
input.state.files[item.path] = {
|
||||
lastSyncedHash: item.deleted.previousContentHash,
|
||||
remoteHash: item.deleted.previousContentHash,
|
||||
deleted: true,
|
||||
version: item.deleted.previousVersion,
|
||||
localMtime: null,
|
||||
localSize: null,
|
||||
};
|
||||
result.deletedLocal += 1;
|
||||
}
|
||||
});
|
||||
await mapLimit(actions, SYNC_CONCURRENCY, (item) => applyAction(item, input, manifest, result));
|
||||
|
||||
pruneTombstones(manifest, input.state, expiredTombstones);
|
||||
updateDeviceCheckpoint(manifest, input);
|
||||
|
|
@ -259,6 +162,64 @@ export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
|
|||
}
|
||||
}
|
||||
|
||||
async function applyAction(item: PlannedFile, input: SyncOnceInput, manifest: RemoteManifest, result: SyncResult): Promise<void> {
|
||||
if (item.action === "upload" && item.local) {
|
||||
await writeBlob(input.store, manifest, item.local.hash, item.local.bytes, item.local.size, input.now());
|
||||
manifest.paths[item.path] = activeEntry(item.local, input.deviceId, input.now(), nextRevision(manifest));
|
||||
input.state.files[item.path] = syncedState(item.local.hash, item.local, manifest.paths[item.path].version);
|
||||
result.uploaded += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.action === "download" && item.remote) {
|
||||
const bytes = await input.store.readObject(blobObjectKey(item.remote.contentHash));
|
||||
await input.vault.write(item.path, bytes);
|
||||
input.state.files[item.path] = syncedState(item.remote.contentHash, { size: bytes.byteLength, mtime: null }, item.remote.version);
|
||||
result.downloaded += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.action === "conflict") {
|
||||
await applyConflict(item, input, result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.action === "mark-remote-deleted") {
|
||||
const remote = manifest.paths[item.path];
|
||||
delete manifest.paths[item.path];
|
||||
manifest.deleted[item.path] = tombstoneEntry(item, remote, input.deviceId, input.now(), nextRevision(manifest));
|
||||
input.state.files[item.path] = deletedState(manifest.deleted[item.path].previousContentHash, manifest.deleted[item.path].previousVersion);
|
||||
result.deletedRemote += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.action === "delete-local") {
|
||||
await input.vault.delete(item.path);
|
||||
input.state.files[item.path] = deletedState(item.remote?.contentHash ?? item.deleted?.previousContentHash ?? null, item.remote?.version ?? item.deleted?.previousVersion ?? null);
|
||||
result.deletedLocal += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyConflict(item: PlannedFile, input: SyncOnceInput, result: SyncResult): Promise<void> {
|
||||
if (item.remote) {
|
||||
const bytes = await input.store.readObject(blobObjectKey(item.remote.contentHash));
|
||||
await input.vault.write(createConflictPath(item.path, input.deviceName, input.now()), bytes);
|
||||
input.state.files[item.path] = {
|
||||
...syncedState(item.lastSyncedHash, { mtime: null, size: null }, item.remote.version),
|
||||
remoteHash: item.remote.contentHash,
|
||||
};
|
||||
result.conflicts += 1;
|
||||
}
|
||||
|
||||
if (item.deleted && item.local && !item.remote) {
|
||||
await input.vault.write(createConflictPath(item.path, input.deviceName, input.now()), item.local.bytes);
|
||||
await input.vault.delete(item.path);
|
||||
input.state.files[item.path] = deletedState(item.deleted.previousContentHash, item.deleted.previousVersion);
|
||||
result.conflicts += 1;
|
||||
result.deletedLocal += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReleaseDeletedContentResult {
|
||||
deletedTombstones: number;
|
||||
deletedBlobs: number;
|
||||
|
|
@ -401,7 +362,28 @@ export function blobObjectKey(hash: string): string {
|
|||
return `blobs/sha256/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`;
|
||||
}
|
||||
|
||||
function syncedState(hash: string, local: { mtime: number | null; size: number | null }, version: string): LocalFileState {
|
||||
function activeEntry(local: { hash: string; size: number }, deviceId: string, now: number, revision: number): ManifestFileEntry {
|
||||
return {
|
||||
contentHash: local.hash,
|
||||
size: local.size,
|
||||
updatedAt: now,
|
||||
updatedBy: deviceId,
|
||||
revision,
|
||||
version: createVersion(deviceId, now, local.hash),
|
||||
};
|
||||
}
|
||||
|
||||
function tombstoneEntry(item: PlannedFile, remote: ManifestFileEntry | undefined, deviceId: string, now: number, revision: number): DeletedPathEntry {
|
||||
return {
|
||||
deletedAt: now,
|
||||
deletedRevision: revision,
|
||||
previousContentHash: item.lastSyncedHash ?? remote?.contentHash ?? null,
|
||||
previousVersion: item.lastVersion ?? remote?.version ?? null,
|
||||
deletedBy: deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
function syncedState(hash: string | null, local: { mtime: number | null; size: number | null }, version: string | null): LocalFileState {
|
||||
return {
|
||||
lastSyncedHash: hash,
|
||||
remoteHash: hash,
|
||||
|
|
@ -412,6 +394,17 @@ function syncedState(hash: string, local: { mtime: number | null; size: number |
|
|||
};
|
||||
}
|
||||
|
||||
function deletedState(hash: string | null, version: string | null): LocalFileState {
|
||||
return {
|
||||
lastSyncedHash: hash,
|
||||
remoteHash: hash,
|
||||
deleted: true,
|
||||
version,
|
||||
localMtime: null,
|
||||
localSize: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeBlob(store: ObjectStore, manifest: RemoteManifest, hash: string, bytes: Uint8Array, size: number, now: number): Promise<void> {
|
||||
const key = blobObjectKey(hash);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue