Revise sync protocol for hash blobs

This commit is contained in:
ichaly 2026-06-05 00:45:44 +08:00
parent 2ec89b9bd0
commit c6622b2c06
8 changed files with 953 additions and 214 deletions

View file

@ -3,7 +3,7 @@ import { createApp, reactive, type App as VueApp } from "vue";
import { createRandomId, createVaultId, normalizeKey } from "./core/ids";
import SettingsApp from "./settings/SettingsApp.vue";
import "./styles.scss";
import { syncOnce, type LocalSyncState, type VaultIO } from "./sync/engine";
import { releaseDeletedContent, syncOnce, type LocalSyncState, type VaultIO } from "./sync/engine";
import { S3ObjectStore } from "./store/s3";
declare const require: ((id: string) => unknown) | undefined;
@ -42,7 +42,7 @@ const DEFAULT_SETTINGS: ObsyncSettings = {
region: "",
accessKeyId: "",
secretAccessKey: "",
rootPrefix: "obsync/v1",
rootPrefix: "obsync/v2",
accountKey: "default",
vaultKey: "",
vaultId: "",
@ -91,6 +91,14 @@ export default class ObsyncPlugin extends Plugin {
},
});
this.addCommand({
id: "release-deleted-content",
name: "释放已删除内容",
callback: async () => {
await this.releaseDeletedContentNow();
},
});
this.addSettingTab(new ObsyncSettingTab(this.app, this));
this.registerAutoSyncTriggers();
}
@ -104,38 +112,13 @@ export default class ObsyncPlugin extends Plugin {
const syncingNotice = new Notice("Obsync 正在同步...", 0);
try {
const store = new S3ObjectStore({
endpoint: this.settings.endpoint,
bucket: this.settings.bucket,
region: this.settings.region || "auto",
accessKeyId: this.settings.accessKeyId,
secretAccessKey: this.settings.secretAccessKey,
rootPrefix: this.settings.rootPrefix,
vaultId: this.settings.vaultId,
deviceId: this.settings.deviceId,
now: () => Date.now(),
request: async (request) => {
const { host: _host, ...headers } = request.headers;
const response = await requestUrl({
url: request.url,
method: request.method,
headers,
body: request.body,
throw: false,
});
return {
status: response.status,
text: response.text,
arrayBuffer: response.arrayBuffer,
};
},
});
const store = this.createObjectStore();
const result = await syncOnce({
vault: new ObsidianVaultIO(this.app),
store,
state: this.settings.syncState,
deviceName: this.settings.deviceName || this.settings.deviceId,
deviceId: this.settings.deviceId,
now: () => Date.now(),
});
@ -230,6 +213,72 @@ export default class ObsyncPlugin extends Plugin {
return Boolean(this.settings.endpoint && this.settings.bucket && this.settings.accessKeyId && this.settings.secretAccessKey);
}
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();
if (result.locked) {
new Notice("另一台设备正在同步,本次释放空间已跳过。");
return;
}
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 createObjectStore(): S3ObjectStore {
return new S3ObjectStore({
endpoint: this.settings.endpoint,
bucket: this.settings.bucket,
region: this.settings.region || "auto",
accessKeyId: this.settings.accessKeyId,
secretAccessKey: this.settings.secretAccessKey,
rootPrefix: this.settings.rootPrefix,
vaultId: this.settings.vaultId,
deviceId: this.settings.deviceId,
now: () => Date.now(),
request: async (request) => {
const { host: _host, ...headers } = request.headers;
const response = await requestUrl({
url: request.url,
method: request.method,
headers,
body: request.body,
throw: false,
});
return {
status: response.status,
text: response.text,
arrayBuffer: response.arrayBuffer,
};
},
});
}
private pruneLocalDeletedState(): void {
for (const [path, fileState] of Object.entries(this.settings.syncState.files)) {
if (fileState.deleted) {
delete this.settings.syncState.files[path];
}
}
}
async loadSettings(): Promise<void> {
const loaded = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);
@ -238,6 +287,10 @@ export default class ObsyncPlugin extends Plugin {
this.settings.vaultKey = normalizeKey(this.app.vault.getName());
}
if (!loaded?.rootPrefix || loaded.rootPrefix === "obsync/v1") {
this.settings.rootPrefix = "obsync/v2";
}
if (!this.settings.deviceId) {
this.settings.deviceId = createRandomId("dev");
}
@ -504,6 +557,9 @@ class ObsyncSettingTab extends PluginSettingTab {
new Notice(`导入连接配置失败:${message}`);
}
},
onReleaseDeletedContent: async () => {
await this.plugin.releaseDeletedContentNow();
},
});
this.vueApp.mount(mountEl);
}

View file

@ -28,6 +28,7 @@ const emit = defineEmits<{
copyDeviceId: [];
copyConnectionConfig: [];
pasteConnectionConfig: [];
releaseDeletedContent: [];
}>();
const showSecretAccessKey = ref(false);
@ -105,8 +106,8 @@ watchEffect(() => {
<div class="obsync-control">
<input
:value="props.settings.rootPrefix"
placeholder="obsync/v1"
@input="emit('update', { rootPrefix: ($event.target as HTMLInputElement).value.trim() || 'obsync/v1' })"
placeholder="obsync/v2"
@input="emit('update', { rootPrefix: ($event.target as HTMLInputElement).value.trim() || 'obsync/v2' })"
/>
</div>
</div>
@ -151,6 +152,20 @@ watchEffect(() => {
</div>
</section>
<section class="obsync-card">
<div class="obsync-row">
<div class="obsync-row-copy">
<h3>释放空间</h3>
<p>清理已删除文件的远端记录和不再引用的 Blob</p>
</div>
<div class="obsync-control">
<button type="button" class="obsync-secondary" @click="emit('releaseDeletedContent')">
释放已删除内容
</button>
</div>
</div>
</section>
<section class="obsync-card">
<div class="obsync-row">
<div class="obsync-row-copy">

View file

@ -48,6 +48,69 @@ describe("S3ObjectStore", () => {
expect(requests[0]?.method).toBe("DELETE");
expect(requests[0]?.url).toBe("https://s3.example.com/my-bucket/obsync/v1/vaults/vlt_TEST/files/notes/today.md");
});
test("lists object keys under the vault prefix", async () => {
const requests: HttpRequest[] = [];
const store = createStore(async (request) => {
requests.push(request);
return {
status: 200,
text: [
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<ListBucketResult>",
"<Contents><Key>obsync/v1/vaults/vlt_TEST/blobs/sha256/aa/bb/hash1</Key></Contents>",
"<Contents><Key>obsync/v1/vaults/vlt_TEST/blobs/sha256/cc/dd/hash2</Key></Contents>",
"</ListBucketResult>",
].join(""),
arrayBuffer: new ArrayBuffer(0),
};
});
await expect(store.listObjectKeys("blobs/sha256/")).resolves.toEqual([
"blobs/sha256/aa/bb/hash1",
"blobs/sha256/cc/dd/hash2",
]);
expect(requests[0]?.method).toBe("GET");
expect(requests[0]?.url).toBe(
"https://s3.example.com/my-bucket?list-type=2&prefix=obsync%2Fv1%2Fvaults%2Fvlt_TEST%2Fblobs%2Fsha256%2F",
);
});
test("lists object keys across paginated responses", async () => {
const requests: HttpRequest[] = [];
const responses = [
[
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<ListBucketResult>",
"<IsTruncated>true</IsTruncated>",
"<NextContinuationToken>page 2</NextContinuationToken>",
"<Contents><Key>obsync/v1/vaults/vlt_TEST/blobs/sha256/aa/bb/hash1</Key></Contents>",
"</ListBucketResult>",
].join(""),
[
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<ListBucketResult>",
"<IsTruncated>false</IsTruncated>",
"<Contents><Key>obsync/v1/vaults/vlt_TEST/blobs/sha256/cc/dd/hash2</Key></Contents>",
"</ListBucketResult>",
].join(""),
];
const store = createStore(async (request) => {
requests.push(request);
return {
status: 200,
text: responses.shift() ?? "",
arrayBuffer: new ArrayBuffer(0),
};
});
await expect(store.listObjectKeys("blobs/sha256/")).resolves.toEqual([
"blobs/sha256/aa/bb/hash1",
"blobs/sha256/cc/dd/hash2",
]);
expect(requests).toHaveLength(2);
expect(requests[1]?.url).toContain("continuation-token=page+2");
});
});
function createStore(request: (request: HttpRequest) => Promise<{ status: number; text: string; arrayBuffer: ArrayBuffer }>) {

View file

@ -96,6 +96,28 @@ export class S3ObjectStore implements ObjectStore {
assertOk(response.status, `delete object ${key}`);
}
async listObjectKeys(prefix: string): Promise<string[]> {
const resolvedPrefix = this.resolveKey(prefix);
const keys: string[] = [];
let continuationToken: string | undefined;
do {
const response = await this.request("GET", "", undefined, {
"list-type": "2",
prefix: resolvedPrefix,
...(continuationToken ? { "continuation-token": continuationToken } : {}),
});
assertOk(response.status, `list objects ${prefix}`);
keys.push(...parseListBucketKeys(response.text));
continuationToken = readListContinuationToken(response.text);
} while (continuationToken);
return keys
.filter((key) => key.startsWith(`${this.layout.vaultPrefix}/`))
.map((key) => key.slice(this.layout.vaultPrefix.length + 1));
}
private resolveKey(key: string): string {
if (key === "manifest.json" || key.startsWith("locks/") || key.startsWith("meta/")) {
return `${this.layout.vaultPrefix}/${key}`;
@ -104,8 +126,8 @@ export class S3ObjectStore implements ObjectStore {
return `${this.layout.vaultPrefix}/${key}`;
}
private async request(method: string, key: string, body?: Uint8Array): Promise<HttpResponse> {
const url = createObjectUrl(this.options.endpoint, this.options.bucket, key);
private async request(method: string, key: string, body?: Uint8Array, query?: Record<string, string>): Promise<HttpResponse> {
const url = createObjectUrl(this.options.endpoint, this.options.bucket, key, query);
const bodyBytes = body ?? new Uint8Array();
const headers = await createSignedHeaders({
method,
@ -126,9 +148,11 @@ export class S3ObjectStore implements ObjectStore {
}
}
function createObjectUrl(endpoint: string, bucket: string, key: string): string {
function createObjectUrl(endpoint: string, bucket: string, key: string, query?: Record<string, string>): string {
const base = endpoint.replace(/\/+$/g, "");
return `${base}/${encodePathPart(bucket)}/${encodeKey(key)}`;
const path = key ? `${encodePathPart(bucket)}/${encodeKey(key)}` : encodePathPart(bucket);
const search = query ? `?${new URLSearchParams(query).toString()}` : "";
return `${base}/${path}${search}`;
}
function encodeKey(key: string): string {
@ -165,7 +189,7 @@ async function createSignedHeaders(input: {
const canonicalRequest = [
input.method.toUpperCase(),
url.pathname,
url.searchParams.toString(),
createCanonicalQueryString(url),
canonicalHeaders,
signedHeaders,
bodyHash,
@ -210,6 +234,19 @@ function formatAmzDate(timestamp: number): string {
return new Date(timestamp).toISOString().replace(/[:-]|\.\d{3}/g, "");
}
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);
})
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
@ -224,6 +261,36 @@ 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;
}
function readListContinuationToken(xml: string): string | undefined {
if (!/<IsTruncated>true<\/IsTruncated>/i.test(xml)) {
return undefined;
}
const match = xml.match(/<NextContinuationToken>([^<]*)<\/NextContinuationToken>/);
return match ? decodeXml(match[1]) : undefined;
}
function decodeXml(value: string): string {
return value
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}
function assertOk(status: number, operation: string): void {
if (status < 200 || status >= 300) {
throw new Error(`Failed to ${operation}: HTTP ${status}`);

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { createEmptyManifest, syncOnce, type LocalSyncState, type ObjectStore, type VaultIO } from "./engine";
import { blobObjectKey, createEmptyManifest, releaseDeletedContent, syncOnce, type LocalSyncState, type ObjectStore, type VaultIO } from "./engine";
describe("sync engine", () => {
test("uploads a local file when remote has no version", async () => {
@ -7,12 +7,13 @@ describe("sync engine", () => {
const store = new FakeObjectStore();
const state: LocalSyncState = { files: {} };
const result = await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 1000 });
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
const hash = await hashText("hello");
expect(result.uploaded).toBe(1);
expect(await store.getText("files/notes/today.md")).toBe("hello");
expect(store.manifest.files["notes/today.md"]?.deleted).toBe(false);
expect(state.files["notes/today.md"]?.lastSyncedHash).toBe(store.manifest.files["notes/today.md"]?.hash);
expect(await store.getText(blobObjectKey(hash))).toBe("hello");
expect(store.manifest.paths["notes/today.md"]?.contentHash).toBe(hash);
expect(state.files["notes/today.md"]?.lastSyncedHash).toBe(hash);
});
test("does not read unchanged local files after metadata is cached", async () => {
@ -20,9 +21,9 @@ describe("sync engine", () => {
const store = new FakeObjectStore();
const state: LocalSyncState = { files: {} };
await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 1000 });
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
vault.readCount = 0;
const result = await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 2000 });
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
expect(result.uploaded).toBe(0);
expect(result.downloaded).toBe(0);
@ -34,11 +35,11 @@ describe("sync engine", () => {
const store = new FakeObjectStore();
const state: LocalSyncState = { files: {} };
await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 1000 });
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
store.acquireLockCount = 0;
store.writeManifestCount = 0;
const result = await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 2000 });
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
expect(result.locked).toBe(false);
expect(store.acquireLockCount).toBe(0);
@ -54,37 +55,147 @@ describe("sync engine", () => {
lastSyncedHash: await hashText("base"),
remoteHash: await hashText("base"),
deleted: false,
version: "ver_base",
},
},
};
await store.putText("files/notes/today.md", "remote");
store.manifest.files["notes/today.md"] = {
hash: await hashText("remote"),
await store.putText(blobObjectKey(await hashText("remote")), "remote");
store.manifest.paths["notes/today.md"] = {
contentHash: await hashText("remote"),
size: 6,
updatedAt: 1000,
deleted: false,
updatedBy: "dev_other",
revision: 1,
version: "ver_remote",
};
const result = await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 2000 });
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
expect(result.downloaded).toBe(1);
expect(await vault.readText("notes/today.md")).toBe("remote");
expect(state.files["notes/today.md"]?.lastSyncedHash).toBe(await hashText("remote"));
});
test("deletes the remote object when a synced local file is deleted", async () => {
test("syncs a new file from one device to another device", async () => {
const deviceA = new FakeVault({ "notes/shared.md": "from A" });
const deviceB = new FakeVault({});
const store = new FakeObjectStore();
const stateA: LocalSyncState = { files: {} };
const stateB: LocalSyncState = { files: {} };
await syncOnce({ vault: deviceA, store, state: stateA, deviceName: "Mac", deviceId: "dev_a", now: () => 1000 });
const result = await syncOnce({ vault: deviceB, store, state: stateB, deviceName: "Phone", deviceId: "dev_b", now: () => 2000 });
expect(result.downloaded).toBe(1);
expect(await deviceB.readText("notes/shared.md")).toBe("from A");
expect(stateB.files["notes/shared.md"]?.version).toBe(stateA.files["notes/shared.md"]?.version);
});
test("syncs a deletion from one device to another device", async () => {
const deviceA = new FakeVault({ "notes/shared.md": "base" });
const deviceB = new FakeVault({});
const store = new FakeObjectStore();
const stateA: LocalSyncState = { files: {} };
const stateB: LocalSyncState = { files: {} };
await syncOnce({ vault: deviceA, store, state: stateA, deviceName: "Mac", deviceId: "dev_a", now: () => 1000 });
await syncOnce({ vault: deviceB, store, state: stateB, deviceName: "Phone", deviceId: "dev_b", now: () => 2000 });
await deviceA.delete("notes/shared.md");
await syncOnce({ vault: deviceA, store, state: stateA, deviceName: "Mac", deviceId: "dev_a", now: () => 3000 });
const result = await syncOnce({ vault: deviceB, store, state: stateB, deviceName: "Phone", deviceId: "dev_b", now: () => 4000 });
expect(result.deletedLocal).toBe(1);
expect(await deviceB.readText("notes/shared.md")).toBe("");
expect(stateB.files["notes/shared.md"]?.deleted).toBe(true);
expect(store.manifest.deleted["notes/shared.md"]).toBeDefined();
});
test("syncs delete then same-name recreate as a new version to another device", async () => {
const deviceA = new FakeVault({ "notes/shared.md": "old" });
const deviceB = new FakeVault({});
const store = new FakeObjectStore();
const stateA: LocalSyncState = { files: {} };
const stateB: LocalSyncState = { files: {} };
await syncOnce({ vault: deviceA, store, state: stateA, deviceName: "Mac", deviceId: "dev_a", now: () => 1000 });
await syncOnce({ vault: deviceB, store, state: stateB, deviceName: "Phone", deviceId: "dev_b", now: () => 2000 });
const oldVersion = stateB.files["notes/shared.md"]?.version;
await deviceA.delete("notes/shared.md");
await syncOnce({ vault: deviceA, store, state: stateA, deviceName: "Mac", deviceId: "dev_a", now: () => 3000 });
await syncOnce({ vault: deviceB, store, state: stateB, deviceName: "Phone", deviceId: "dev_b", now: () => 4000 });
await deviceA.write("notes/shared.md", new TextEncoder().encode("new"));
await syncOnce({ vault: deviceA, store, state: stateA, deviceName: "Mac", deviceId: "dev_a", now: () => 5000 });
const result = await syncOnce({ vault: deviceB, store, state: stateB, deviceName: "Phone", deviceId: "dev_b", now: () => 6000 });
expect(result.downloaded).toBe(1);
expect(await deviceB.readText("notes/shared.md")).toBe("new");
expect(stateB.files["notes/shared.md"]?.version).not.toBe(oldVersion);
expect(store.manifest.deleted["notes/shared.md"]?.previousVersion).toBe(oldVersion);
});
test("keeps stale device content as a conflict instead of resurrecting a deleted file", async () => {
const deviceA = new FakeVault({ "notes/shared.md": "base" });
const staleDevice = new FakeVault({ "notes/shared.md": "stale local" });
const store = new FakeObjectStore();
const stateA: LocalSyncState = { files: {} };
const staleState: LocalSyncState = { files: {} };
await syncOnce({ vault: deviceA, store, state: stateA, deviceName: "Mac", deviceId: "dev_a", now: () => 1000 });
await deviceA.delete("notes/shared.md");
await syncOnce({ vault: deviceA, store, state: stateA, deviceName: "Mac", deviceId: "dev_a", now: () => 2000 });
const result = await syncOnce({ vault: staleDevice, store, state: staleState, deviceName: "Phone", deviceId: "dev_b", now: () => 3000 });
expect(result.conflicts).toBe(1);
expect(result.uploaded).toBe(0);
expect(store.manifest.paths["notes/shared.md"]).toBeUndefined();
expect(await staleDevice.readText("notes/shared.md")).toBe("");
expect(await staleDevice.readText("notes/shared.conflict.Phone.19700101-080003.md")).toBe("stale local");
});
test("stores uploaded content under a hash blob instead of the original path", async () => {
const vault = new FakeVault({ "notes/today.md": "hello" });
const store = new FakeObjectStore();
const state: LocalSyncState = { files: {} };
await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 1000 });
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
const hash = await hashText("hello");
expect(await store.getText(`blobs/sha256/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`)).toBe("hello");
expect(store.objects["files/notes/today.md"]).toBeUndefined();
expect(store.manifest.paths["notes/today.md"]?.contentHash).toBe(hash);
});
test("rewrites a hash blob when manifest has a stale blob record but the object is missing", async () => {
const vault = new FakeVault({ "notes/today.md": "hello" });
const store = new FakeObjectStore();
const state: LocalSyncState = { files: {} };
const hash = await hashText("hello");
store.manifest.blobs[hash] = {
key: blobObjectKey(hash),
size: 5,
createdAt: 1000,
};
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
expect(await store.getText(blobObjectKey(hash))).toBe("hello");
});
test("marks a path deleted without immediately deleting the old blob", async () => {
const vault = new FakeVault({ "notes/today.md": "hello" });
const store = new FakeObjectStore();
const state: LocalSyncState = { files: {} };
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 });
const hash = await hashText("hello");
await vault.delete("notes/today.md");
const result = await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 2000 });
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
expect(result.deletedRemote).toBe(1);
expect(result.conflicts).toBe(0);
expect(store.objects["files/notes/today.md"]).toBeUndefined();
expect(store.manifest.files["notes/today.md"]?.deleted).toBe(true);
expect(await store.getText(`blobs/sha256/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`)).toBe("hello");
expect(store.manifest.paths["notes/today.md"]).toBeUndefined();
expect(store.manifest.deleted["notes/today.md"]?.previousContentHash).toBe(hash);
});
test("local deletion wins instead of creating a conflict", async () => {
@ -97,23 +208,108 @@ describe("sync engine", () => {
lastSyncedHash: baseHash,
remoteHash: baseHash,
deleted: false,
version: "ver_base",
},
},
};
await store.putText("files/notes/today.md", "remote");
store.manifest.files["notes/today.md"] = {
hash: await hashText("remote"),
await store.putText(blobObjectKey(await hashText("remote")), "remote");
store.manifest.paths["notes/today.md"] = {
contentHash: await hashText("remote"),
size: 6,
updatedAt: 1000,
deleted: false,
updatedBy: "dev_other",
revision: 1,
version: "ver_remote",
};
const result = await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 2000 });
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
expect(result.deletedRemote).toBe(1);
expect(result.conflicts).toBe(0);
expect(store.objects["files/notes/today.md"]).toBeUndefined();
expect(store.manifest.files["notes/today.md"]?.deleted).toBe(true);
expect(store.manifest.paths["notes/today.md"]).toBeUndefined();
expect(store.manifest.deleted["notes/today.md"]?.previousContentHash).toBe(baseHash);
});
test("deletes local file when another device deleted the path", async () => {
const hash = await hashText("base");
const vault = new FakeVault({ "notes/today.md": "base" });
const store = new FakeObjectStore();
const state: LocalSyncState = {
files: {
"notes/today.md": {
lastSyncedHash: hash,
remoteHash: hash,
deleted: false,
version: "ver_base",
},
},
};
store.manifest.deleted["notes/today.md"] = {
deletedAt: 2000,
deletedRevision: 2,
previousContentHash: hash,
previousVersion: "ver_base",
deletedBy: "dev_other",
};
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 });
expect(result.deletedLocal).toBe(1);
await expect(vault.readText("notes/today.md")).resolves.toBe("");
expect(state.files["notes/today.md"]?.deleted).toBe(true);
});
test("keeps a conflict copy when remote deleted a path that changed locally", async () => {
const hash = await hashText("base");
const vault = new FakeVault({ "notes/today.md": "local changed" });
const store = new FakeObjectStore();
const state: LocalSyncState = {
files: {
"notes/today.md": {
lastSyncedHash: hash,
remoteHash: hash,
deleted: false,
version: "ver_base",
},
},
};
store.manifest.deleted["notes/today.md"] = {
deletedAt: 2000,
deletedRevision: 2,
previousContentHash: hash,
previousVersion: "ver_base",
deletedBy: "dev_other",
};
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 });
expect(result.conflicts).toBe(1);
expect(result.deletedLocal).toBe(1);
expect(await vault.readText("notes/today.md")).toBe("");
expect(await vault.readText("notes/today.conflict.Mac.19700101-080003.md")).toBe("local changed");
expect(state.files["notes/today.md"]?.deleted).toBe(true);
});
test("does not resurrect a tombstoned path when local state is missing", async () => {
const hash = await hashText("deleted");
const vault = new FakeVault({ "notes/today.md": "stale local" });
const store = new FakeObjectStore();
const state: LocalSyncState = { files: {} };
store.manifest.deleted["notes/today.md"] = {
deletedAt: 2000,
deletedRevision: 2,
previousContentHash: hash,
previousVersion: "ver_deleted",
deletedBy: "dev_other",
};
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 });
expect(result.conflicts).toBe(1);
expect(result.uploaded).toBe(0);
expect(store.manifest.paths["notes/today.md"]).toBeUndefined();
expect(await vault.readText("notes/today.md")).toBe("");
expect(await vault.readText("notes/today.conflict.Mac.19700101-080003.md")).toBe("stale local");
});
test("keeps recent tombstones in the manifest", async () => {
@ -125,20 +321,22 @@ describe("sync engine", () => {
lastSyncedHash: "old",
remoteHash: "old",
deleted: true,
version: "ver_old",
},
},
};
store.manifest.files["notes/today.md"] = {
hash: "old",
size: 0,
updatedAt: 1000,
deleted: true,
store.manifest.deleted["notes/today.md"] = {
deletedAt: 1000,
deletedRevision: 1,
previousContentHash: "old",
previousVersion: "ver_old",
deletedBy: "dev_other",
};
store.writeManifestCount = 0;
await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 1000 + 29 * 24 * 60 * 60 * 1000 });
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 + 29 * 24 * 60 * 60 * 1000 });
expect(store.manifest.files["notes/today.md"]?.deleted).toBe(true);
expect(store.manifest.deleted["notes/today.md"]).toBeDefined();
expect(state.files["notes/today.md"]?.deleted).toBe(true);
expect(store.writeManifestCount).toBe(0);
});
@ -152,23 +350,235 @@ describe("sync engine", () => {
lastSyncedHash: "old",
remoteHash: "old",
deleted: true,
version: "ver_old",
},
},
};
store.manifest.files["notes/today.md"] = {
hash: "old",
size: 0,
updatedAt: 1000,
deleted: true,
store.manifest.deleted["notes/today.md"] = {
deletedAt: 1000,
deletedRevision: 1,
previousContentHash: "old",
previousVersion: "ver_old",
deletedBy: "dev_other",
};
await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 1000 + 30 * 24 * 60 * 60 * 1000 });
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 + 30 * 24 * 60 * 60 * 1000 });
expect(store.manifest.files["notes/today.md"]).toBeUndefined();
expect(store.manifest.deleted["notes/today.md"]).toBeUndefined();
expect(state.files["notes/today.md"]).toBeUndefined();
expect(store.writeManifestCount).toBe(1);
});
test("pruning an old tombstone does not remove current state for a same-name recreated file", async () => {
const activeHash = await hashText("new");
const vault = new FakeVault({ "notes/today.md": "new" });
const store = new FakeObjectStore();
const state: LocalSyncState = {
files: {
"notes/today.md": {
lastSyncedHash: activeHash,
remoteHash: activeHash,
deleted: false,
version: "ver_new",
localMtime: 1,
localSize: 3,
},
},
};
await store.putText(blobObjectKey(activeHash), "new");
store.manifest.paths["notes/today.md"] = {
contentHash: activeHash,
size: 3,
updatedAt: 2000,
updatedBy: "dev_mac",
revision: 2,
version: "ver_new",
};
store.manifest.deleted["notes/today.md"] = {
deletedAt: 1000,
deletedRevision: 1,
previousContentHash: await hashText("old"),
previousVersion: "ver_old",
deletedBy: "dev_mac",
};
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 1000 + 30 * 24 * 60 * 60 * 1000 });
expect(result.uploaded).toBe(0);
expect(store.manifest.deleted["notes/today.md"]).toBeUndefined();
expect(store.manifest.paths["notes/today.md"]?.version).toBe("ver_new");
expect(state.files["notes/today.md"]?.version).toBe("ver_new");
expect(state.files["notes/today.md"]?.deleted).toBe(false);
});
test("treats delete then same-name create as a new version", async () => {
const vault = new FakeVault({ "notes/today.md": "old" });
const store = new FakeObjectStore();
const state: LocalSyncState = { files: {} };
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 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 vault.write("notes/today.md", new TextEncoder().encode("new"));
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 });
expect(result.uploaded).toBe(1);
expect(store.manifest.paths["notes/today.md"]?.contentHash).toBe(await hashText("new"));
expect(store.manifest.paths["notes/today.md"]?.version).not.toBe(oldVersion);
expect(store.manifest.deleted["notes/today.md"]?.previousVersion).toBe(oldVersion);
expect(state.files["notes/today.md"]?.deleted).toBe(false);
});
test("same-name create gets a new version even when content matches the deleted file", async () => {
const vault = new FakeVault({ "notes/today.md": "same" });
const store = new FakeObjectStore();
const state: LocalSyncState = { files: {} };
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 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 vault.write("notes/today.md", new TextEncoder().encode("same"));
await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 3000 });
expect(store.manifest.paths["notes/today.md"]?.contentHash).toBe(await hashText("same"));
expect(store.manifest.paths["notes/today.md"]?.version).not.toBe(oldVersion);
});
test("writes a conflict copy when a deleted path was recreated remotely and local also changed", async () => {
const baseHash = await hashText("base");
const remoteHash = await hashText("remote-new");
const vault = new FakeVault({ "notes/today.md": "local-new" });
const store = new FakeObjectStore();
const state: LocalSyncState = {
files: {
"notes/today.md": {
lastSyncedHash: baseHash,
remoteHash: baseHash,
deleted: false,
version: "ver_base",
},
},
};
await store.putText(blobObjectKey(remoteHash), "remote-new");
store.manifest.paths["notes/today.md"] = {
contentHash: remoteHash,
size: 10,
updatedAt: 3000,
updatedBy: "dev_other",
revision: 3,
version: "ver_remote_recreated",
};
store.manifest.deleted["notes/today.md"] = {
deletedAt: 2000,
deletedRevision: 2,
previousContentHash: baseHash,
previousVersion: "ver_base",
deletedBy: "dev_other",
};
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 4000 });
expect(result.conflicts).toBe(1);
expect(await vault.readText("notes/today.md")).toBe("local-new");
expect(await vault.readText("notes/today.conflict.Mac.19700101-080004.md")).toBe("remote-new");
});
test("does not create duplicate conflict copies after the same remote conflict was already recorded", async () => {
const baseHash = await hashText("base");
const remoteHash = await hashText("remote");
const vault = new FakeVault({ "notes/today.md": "local" });
const store = new FakeObjectStore();
const state: LocalSyncState = {
files: {
"notes/today.md": {
lastSyncedHash: baseHash,
remoteHash: baseHash,
deleted: false,
version: "ver_base",
},
},
};
await store.putText(blobObjectKey(remoteHash), "remote");
store.manifest.paths["notes/today.md"] = {
contentHash: remoteHash,
size: 6,
updatedAt: 2000,
updatedBy: "dev_other",
revision: 2,
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 });
expect(firstResult.conflicts).toBe(1);
expect(secondResult.conflicts).toBe(0);
expect(secondResult.uploaded).toBe(2);
expect(await vault.readText("notes/today.conflict.Mac.19700101-080003.md")).toBe("remote");
expect(await vault.readText("notes/today.conflict.Mac.19700101-080004.md")).toBe("");
expect(store.manifest.paths["notes/today.md"]?.contentHash).toBe(await hashText("local"));
});
test("releaseDeletedContent removes deleted tombstones and unreferenced blobs", async () => {
const keptHash = await hashText("kept");
const deletedHash = await hashText("deleted");
const orphanHash = await hashText("orphan");
const store = new FakeObjectStore();
store.manifest.paths["notes/kept.md"] = {
contentHash: keptHash,
size: 4,
updatedAt: 1000,
updatedBy: "dev_mac",
revision: 1,
version: "ver_kept",
};
store.manifest.deleted["notes/deleted.md"] = {
deletedAt: 1000,
deletedRevision: 2,
previousContentHash: deletedHash,
previousVersion: "ver_deleted",
deletedBy: "dev_mac",
};
await store.putText(blobObjectKey(keptHash), "kept");
await store.putText(blobObjectKey(deletedHash), "deleted");
await store.putText(blobObjectKey(orphanHash), "orphan");
const result = await releaseDeletedContent({ store, now: () => 5000 });
expect(result.deletedTombstones).toBe(1);
expect(result.deletedBlobs).toBe(2);
expect(store.manifest.deleted["notes/deleted.md"]).toBeUndefined();
expect(store.objects[blobObjectKey(keptHash)]).toBeDefined();
expect(store.objects[blobObjectKey(deletedHash)]).toBeUndefined();
expect(store.objects[blobObjectKey(orphanHash)]).toBeUndefined();
});
test("releaseDeletedContent skips cleanup when the remote lock is held", async () => {
const deletedHash = await hashText("deleted");
const store = new FakeObjectStore();
store.lockAvailable = false;
store.manifest.deleted["notes/deleted.md"] = {
deletedAt: 1000,
deletedRevision: 2,
previousContentHash: deletedHash,
previousVersion: "ver_deleted",
deletedBy: "dev_mac",
};
await store.putText(blobObjectKey(deletedHash), "deleted");
const result = await releaseDeletedContent({ store, now: () => 5000 });
expect(result.locked).toBe(true);
expect(result.deletedTombstones).toBe(0);
expect(result.deletedBlobs).toBe(0);
expect(store.manifest.deleted["notes/deleted.md"]).toBeDefined();
expect(store.objects[blobObjectKey(deletedHash)]).toBeDefined();
});
test("writes a conflict copy when local and remote both changed", async () => {
const baseHash = await hashText("base");
const vault = new FakeVault({ "notes/today.md": "local" });
@ -179,18 +589,21 @@ describe("sync engine", () => {
lastSyncedHash: baseHash,
remoteHash: baseHash,
deleted: false,
version: "ver_base",
},
},
};
await store.putText("files/notes/today.md", "remote");
store.manifest.files["notes/today.md"] = {
hash: await hashText("remote"),
await store.putText(blobObjectKey(await hashText("remote")), "remote");
store.manifest.paths["notes/today.md"] = {
contentHash: await hashText("remote"),
size: 6,
updatedAt: 1000,
deleted: false,
updatedBy: "dev_other",
revision: 1,
version: "ver_remote",
};
const result = await syncOnce({ vault, store, state, deviceName: "Mac", now: () => 2000 });
const result = await syncOnce({ vault, store, state, deviceName: "Mac", deviceId: "dev_mac", now: () => 2000 });
expect(result.conflicts).toBe(1);
expect(await vault.readText("notes/today.md")).toBe("local");
@ -245,11 +658,12 @@ class FakeObjectStore implements ObjectStore {
manifest = createEmptyManifest();
objects: Record<string, Uint8Array> = {};
acquireLockCount = 0;
lockAvailable = true;
writeManifestCount = 0;
async acquireLock(): Promise<boolean> {
this.acquireLockCount += 1;
return true;
return this.lockAvailable;
}
async releaseLock(): Promise<void> {
@ -277,6 +691,10 @@ class FakeObjectStore implements ObjectStore {
delete this.objects[key];
}
async listObjectKeys(prefix: string): Promise<string[]> {
return Object.keys(this.objects).filter((key) => key.startsWith(prefix));
}
async putText(key: string, value: string): Promise<void> {
await this.writeObject(key, new TextEncoder().encode(value));
}

View file

@ -1,23 +1,41 @@
import { planFileAction } from "./planner";
import type { SyncAction } from "./planner";
export interface ManifestFileEntry {
hash: string;
contentHash: string;
size: number;
updatedAt: number;
deleted: boolean;
updatedBy: string;
revision: number;
version: string;
}
export interface DeletedPathEntry {
deletedAt: number;
deletedRevision: number;
previousContentHash: string | null;
previousVersion: string | null;
deletedBy: string;
}
export interface BlobEntry {
key: string;
size: number;
createdAt: number;
}
export interface RemoteManifest {
schemaVersion: 1;
schemaVersion: 2;
revision: number;
updatedAt: number;
files: Record<string, ManifestFileEntry>;
paths: Record<string, ManifestFileEntry>;
deleted: Record<string, DeletedPathEntry>;
blobs: Record<string, BlobEntry>;
devices: Record<string, { name: string; lastSeenAt: number; lastSeenRevision: number }>;
}
export interface LocalFileState {
lastSyncedHash: string | null;
remoteHash: string | null;
deleted: boolean;
version?: string | null;
localMtime?: number | null;
localSize?: number | null;
}
@ -42,6 +60,7 @@ export interface ObjectStore {
readObject(key: string): Promise<Uint8Array>;
writeObject(key: string, bytes: Uint8Array): Promise<void>;
deleteObject(key: string): Promise<void>;
listObjectKeys?(prefix: string): Promise<string[]>;
}
export interface SyncResult {
@ -58,6 +77,7 @@ interface SyncOnceInput {
store: ObjectStore;
state: LocalSyncState;
deviceName: string;
deviceId: string;
now(): number;
}
@ -66,17 +86,25 @@ interface PlannedFile {
action: SyncAction;
local?: { bytes: Uint8Array; hash: string; mtime: number; size: number };
remote?: ManifestFileEntry;
deleted?: DeletedPathEntry;
lastSyncedHash: string | null;
lastVersion: string | null;
}
type SyncAction = "noop" | "upload" | "download" | "conflict" | "mark-remote-deleted" | "delete-local";
const SYNC_CONCURRENCY = 4;
const TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
export function createEmptyManifest(): RemoteManifest {
return {
schemaVersion: 1,
schemaVersion: 2,
revision: 0,
updatedAt: 0,
files: {},
paths: {},
deleted: {},
blobs: {},
devices: {},
};
}
@ -89,7 +117,7 @@ export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
deletedRemote: 0,
locked: false,
};
const manifest = await input.store.readManifest();
const manifest = normalizeManifest(await input.store.readManifest());
const localFiles = await scanLocalFiles(input.vault, input.state);
const plan = createPlan(localFiles, manifest, input.state);
const actions = plan.filter((item) => item.action !== "noop");
@ -98,6 +126,7 @@ export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
actions.some((item) => item.action === "upload" || item.action === "mark-remote-deleted") || expiredTombstones.length > 0;
if (actions.length === 0 && expiredTombstones.length === 0) {
updateDeviceCheckpoint(manifest, input);
input.state.lastSyncAt = input.now();
return result;
}
@ -116,49 +145,70 @@ export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
try {
await mapLimit(actions, SYNC_CONCURRENCY, async (item) => {
if (item.action === "upload" && item.local) {
await input.store.writeObject(fileObjectKey(item.path), item.local.bytes);
manifest.files[item.path] = {
hash: item.local.hash,
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(),
deleted: false,
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);
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(fileObjectKey(item.path));
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.hash, { size: bytes.byteLength, mtime: null });
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(fileObjectKey(item.path));
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.hash,
remoteHash: item.remote.contentHash,
deleted: false,
localMtime: item.local?.mtime ?? null,
localSize: item.local?.size ?? null,
version: item.remote.version,
localMtime: null,
localSize: null,
};
result.conflicts += 1;
}
if (item.action === "mark-remote-deleted") {
await input.store.deleteObject(fileObjectKey(item.path));
manifest.files[item.path] = {
hash: item.lastSyncedHash ?? "",
size: 0,
updatedAt: input.now(),
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,
remoteHash: item.lastSyncedHash,
lastSyncedHash: item.lastSyncedHash ?? remote?.contentHash ?? null,
remoteHash: item.lastSyncedHash ?? remote?.contentHash ?? null,
deleted: true,
version: item.lastVersion ?? remote?.version ?? null,
localMtime: null,
localSize: null,
};
@ -168,9 +218,23 @@ export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
if (item.action === "delete-local" && item.remote) {
await input.vault.delete(item.path);
input.state.files[item.path] = {
lastSyncedHash: item.remote.hash,
remoteHash: item.remote.hash,
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,
};
@ -179,6 +243,7 @@ export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
});
pruneTombstones(manifest, input.state, expiredTombstones);
updateDeviceCheckpoint(manifest, input);
input.state.lastSyncAt = input.now();
if (writesRemote) {
@ -194,16 +259,62 @@ export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
}
}
export interface ReleaseDeletedContentResult {
deletedTombstones: number;
deletedBlobs: number;
locked: boolean;
}
export async function releaseDeletedContent(input: { store: ObjectStore; now(): number }): Promise<ReleaseDeletedContentResult> {
const locked = await input.store.acquireLock();
if (!locked) {
return { deletedTombstones: 0, deletedBlobs: 0, locked: true };
}
try {
const manifest = normalizeManifest(await input.store.readManifest());
const deletedTombstones = Object.keys(manifest.deleted).length;
manifest.deleted = {};
const referencedHashes = new Set(Object.values(manifest.paths).map((entry) => entry.contentHash));
const knownBlobKeys = new Set(Object.values(manifest.blobs).map((entry) => entry.key));
const listedBlobKeys = input.store.listObjectKeys ? await input.store.listObjectKeys("blobs/sha256/") : [];
let deletedBlobs = 0;
for (const key of new Set([...knownBlobKeys, ...listedBlobKeys])) {
const hash = hashFromBlobKey(key);
if (hash && !referencedHashes.has(hash)) {
await input.store.deleteObject(key);
delete manifest.blobs[hash];
deletedBlobs += 1;
}
}
manifest.revision += 1;
manifest.updatedAt = input.now();
await input.store.writeManifest(manifest);
return { deletedTombstones, deletedBlobs, locked: false };
} finally {
await input.store.releaseLock();
}
}
function findExpiredTombstones(manifest: RemoteManifest, now: number): string[] {
return Object.entries(manifest.files)
.filter(([, entry]) => entry.deleted && now - entry.updatedAt >= TOMBSTONE_RETENTION_MS)
return Object.entries(manifest.deleted)
.filter(([, entry]) => now - entry.deletedAt >= TOMBSTONE_RETENTION_MS)
.map(([path]) => path);
}
function pruneTombstones(manifest: RemoteManifest, state: LocalSyncState, paths: string[]): void {
for (const path of paths) {
delete manifest.files[path];
delete state.files[path];
delete manifest.deleted[path];
if (state.files[path]?.deleted) {
delete state.files[path];
}
}
}
@ -212,47 +323,141 @@ function createPlan(
manifest: RemoteManifest,
state: LocalSyncState,
): PlannedFile[] {
const paths = new Set([...Object.keys(localFiles), ...Object.keys(manifest.files), ...Object.keys(state.files)]);
const paths = new Set([...Object.keys(localFiles), ...Object.keys(manifest.paths), ...Object.keys(manifest.deleted), ...Object.keys(state.files)]);
const plan: PlannedFile[] = [];
for (const path of paths) {
const local = localFiles[path];
const remote = manifest.files[path];
const remote = manifest.paths[path];
const deleted = manifest.deleted[path];
const previous = state.files[path];
const lastSyncedHash = previous?.lastSyncedHash ?? null;
const lastVersion = previous?.version ?? null;
const action = planPathAction({ local, remote, deleted, previous });
plan.push({
path,
local,
remote,
deleted,
lastSyncedHash,
action: planFileAction({
localHash: local?.hash ?? null,
lastSyncedHash,
remoteHash: remote?.hash ?? null,
localDeleted: Boolean(previous) && !local,
remoteDeleted: remote?.deleted ?? false,
}),
lastVersion,
action,
});
}
return plan;
}
function fileObjectKey(path: string): string {
return `files/${path}`;
function planPathAction(input: {
local?: { bytes: Uint8Array; hash: string; mtime: number; size: number };
remote?: ManifestFileEntry;
deleted?: DeletedPathEntry;
previous?: LocalFileState;
}): SyncAction {
if (!input.local) {
if (input.previous && !input.previous.deleted) {
return "mark-remote-deleted";
}
if (input.remote) {
return "download";
}
return "noop";
}
if (input.remote) {
const localChanged = !input.previous || input.previous.deleted || input.local.hash !== input.previous.lastSyncedHash;
const remoteChanged = input.remote.version !== input.previous?.version || input.remote.contentHash !== input.previous?.remoteHash;
if (!localChanged && !remoteChanged) {
return "noop";
}
if (localChanged && !remoteChanged) {
return "upload";
}
if (!localChanged && remoteChanged) {
return "download";
}
return "conflict";
}
if (input.deleted) {
if (input.previous?.deleted) {
return "upload";
}
return input.previous && input.local.hash === input.previous.lastSyncedHash ? "delete-local" : "conflict";
}
return "upload";
}
function syncedState(hash: string, local: { mtime: number | null; size: number | null }): LocalFileState {
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 {
return {
lastSyncedHash: hash,
remoteHash: hash,
deleted: false,
version,
localMtime: local.mtime,
localSize: local.size,
};
}
async function writeBlob(store: ObjectStore, manifest: RemoteManifest, hash: string, bytes: Uint8Array, size: number, now: number): Promise<void> {
const key = blobObjectKey(hash);
await store.writeObject(key, bytes);
manifest.blobs[hash] = {
key,
size,
createdAt: manifest.blobs[hash]?.createdAt ?? now,
};
}
function nextRevision(manifest: RemoteManifest): number {
manifest.revision += 1;
return manifest.revision;
}
function createVersion(deviceId: string, now: number, hash: string): string {
return `ver_${now}_${deviceId}_${hash.slice(0, 12)}`;
}
function updateDeviceCheckpoint(manifest: RemoteManifest, input: SyncOnceInput): void {
manifest.devices[input.deviceId] = {
name: input.deviceName,
lastSeenAt: input.now(),
lastSeenRevision: manifest.revision,
};
}
function normalizeManifest(manifest: RemoteManifest): RemoteManifest {
if (manifest.schemaVersion === 2) {
manifest.paths ??= {};
manifest.deleted ??= {};
manifest.blobs ??= {};
manifest.devices ??= {};
manifest.revision ??= 0;
return manifest;
}
return createEmptyManifest();
}
function hashFromBlobKey(key: string): string | null {
const match = key.match(/blobs\/sha256\/[0-9a-f]{2}\/[0-9a-f]{2}\/([0-9a-f]{64})$/);
return match?.[1] ?? null;
}
async function scanLocalFiles(
vault: VaultIO,
state: LocalSyncState,

View file

@ -1,53 +0,0 @@
import { describe, expect, test } from "vitest";
import { planFileAction } from "./planner";
describe("sync planner", () => {
test("uploads when local changed and remote stayed at the last synced hash", () => {
expect(
planFileAction({
localHash: "local-new",
lastSyncedHash: "base",
remoteHash: "base",
localDeleted: false,
remoteDeleted: false,
}),
).toBe("upload");
});
test("downloads when remote changed and local stayed at the last synced hash", () => {
expect(
planFileAction({
localHash: "base",
lastSyncedHash: "base",
remoteHash: "remote-new",
localDeleted: false,
remoteDeleted: false,
}),
).toBe("download");
});
test("creates a conflict when local and remote changed from the last synced hash", () => {
expect(
planFileAction({
localHash: "local-new",
lastSyncedHash: "base",
remoteHash: "remote-new",
localDeleted: false,
remoteDeleted: false,
}),
).toBe("conflict");
});
test("marks remote deleted when local deletion is the only change", () => {
expect(
planFileAction({
localHash: null,
lastSyncedHash: "base",
remoteHash: "base",
localDeleted: true,
remoteDeleted: false,
}),
).toBe("mark-remote-deleted");
});
});

View file

@ -1,32 +0,0 @@
export type SyncAction = "noop" | "upload" | "download" | "conflict" | "mark-remote-deleted" | "delete-local";
interface PlanFileActionInput {
localHash: string | null;
lastSyncedHash: string | null;
remoteHash: string | null;
localDeleted: boolean;
remoteDeleted: boolean;
}
export function planFileAction(input: PlanFileActionInput): SyncAction {
if (input.localDeleted) {
return input.remoteDeleted ? "noop" : "mark-remote-deleted";
}
const localChanged = input.localDeleted || input.localHash !== input.lastSyncedHash;
const remoteChanged = input.remoteDeleted || input.remoteHash !== input.lastSyncedHash;
if (!localChanged && !remoteChanged) {
return "noop";
}
if (localChanged && !remoteChanged) {
return input.localDeleted ? "mark-remote-deleted" : "upload";
}
if (!localChanged && remoteChanged) {
return input.remoteDeleted ? "delete-local" : "download";
}
return "conflict";
}