Polish sync protocol and status UI

This commit is contained in:
ichaly 2026-06-05 01:29:37 +08:00
parent c6622b2c06
commit 1411a9dd19
7 changed files with 276 additions and 175 deletions

View file

@ -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>>>;

View file

@ -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 {

View file

@ -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>

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -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("");

View file

@ -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);