ichaly_cohere/src/sync/engine.ts
2026-06-05 01:29:37 +08:00

534 lines
16 KiB
TypeScript

export interface ManifestFileEntry {
contentHash: string;
size: number;
updatedAt: number;
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: 2;
revision: number;
updatedAt: number;
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;
}
export interface LocalSyncState {
files: Record<string, LocalFileState>;
lastSyncAt?: number;
}
export interface VaultIO {
scan(): Promise<Array<{ path: string; mtime: number; size: number; bytes?: Uint8Array }>>;
read(path: string): Promise<Uint8Array>;
write(path: string, bytes: Uint8Array): Promise<void>;
delete(path: string): Promise<void>;
}
export interface ObjectStore {
acquireLock(): Promise<boolean>;
releaseLock(): Promise<void>;
readManifest(): Promise<RemoteManifest>;
writeManifest(manifest: RemoteManifest): Promise<void>;
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 {
uploaded: number;
downloaded: number;
conflicts: number;
deletedLocal: number;
deletedRemote: number;
locked: boolean;
}
interface SyncOnceInput {
vault: VaultIO;
store: ObjectStore;
state: LocalSyncState;
deviceName: string;
deviceId: string;
now(): number;
}
interface PlannedFile {
path: string;
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: 2,
revision: 0,
updatedAt: 0,
paths: {},
deleted: {},
blobs: {},
devices: {},
};
}
export async function syncOnce(input: SyncOnceInput): Promise<SyncResult> {
const result: SyncResult = {
uploaded: 0,
downloaded: 0,
conflicts: 0,
deletedLocal: 0,
deletedRemote: 0,
locked: false,
};
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");
const expiredTombstones = findExpiredTombstones(manifest, input.now());
const writesRemote =
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;
}
let locked = false;
if (writesRemote) {
locked = await input.store.acquireLock();
result.locked = !locked;
if (!locked) {
return result;
}
}
try {
await mapLimit(actions, SYNC_CONCURRENCY, (item) => applyAction(item, input, manifest, result));
pruneTombstones(manifest, input.state, expiredTombstones);
updateDeviceCheckpoint(manifest, input);
input.state.lastSyncAt = input.now();
if (writesRemote) {
manifest.updatedAt = input.now();
await input.store.writeManifest(manifest);
}
return result;
} finally {
if (locked) {
await input.store.releaseLock();
}
}
}
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;
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.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.deleted[path];
if (state.files[path]?.deleted) {
delete state.files[path];
}
}
}
function createPlan(
localFiles: Record<string, { bytes: Uint8Array; hash: string; mtime: number; size: number }>,
manifest: RemoteManifest,
state: LocalSyncState,
): PlannedFile[] {
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.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,
lastVersion,
action,
});
}
return plan;
}
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";
}
export function blobObjectKey(hash: string): string {
return `blobs/sha256/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`;
}
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,
deleted: false,
version,
localMtime: local.mtime,
localSize: local.size,
};
}
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);
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,
): Promise<Record<string, { bytes: Uint8Array; hash: string; mtime: number; size: number }>> {
const files = await vault.scan();
const result: Record<string, { bytes: Uint8Array; hash: string; mtime: number; size: number }> = {};
for (const file of files) {
const previous = state.files[file.path];
const canReuseHash = Boolean(
previous?.lastSyncedHash &&
previous.localMtime === file.mtime &&
previous.localSize === file.size &&
!previous.deleted,
);
const bytes = canReuseHash ? new Uint8Array() : file.bytes ?? (await vault.read(file.path));
result[file.path] = {
bytes,
hash: canReuseHash ? previous!.lastSyncedHash! : await sha256Hex(bytes),
mtime: file.mtime,
size: file.size,
};
}
return result;
}
async function mapLimit<T>(items: T[], limit: number, worker: (item: T) => Promise<void>): Promise<void> {
let nextIndex = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (nextIndex < items.length) {
const item = items[nextIndex];
nextIndex += 1;
await worker(item);
}
});
await Promise.all(workers);
}
async function sha256Hex(bytes: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes));
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
function createConflictPath(path: string, deviceName: string, timestamp: number): string {
const suffix = `conflict.${sanitizePathPart(deviceName)}.${formatTimestamp(timestamp)}`;
const dotIndex = path.lastIndexOf(".");
if (dotIndex <= path.lastIndexOf("/")) {
return `${path}.${suffix}`;
}
return `${path.slice(0, dotIndex)}.${suffix}${path.slice(dotIndex)}`;
}
function sanitizePathPart(value: string): string {
return value.trim().replace(/[^a-zA-Z0-9_-]+/g, "-") || "device";
}
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = pad2(date.getMonth() + 1);
const day = pad2(date.getDate());
const hour = pad2(date.getHours());
const minute = pad2(date.getMinutes());
const second = pad2(date.getSeconds());
return `${year}${month}${day}-${hour}${minute}${second}`;
}
function pad2(value: number): string {
return String(value).padStart(2, "0");
}