Sync engine (#298)

Fixes #261
This commit is contained in:
Zef Hemel
2023-01-13 15:41:29 +01:00
committed by GitHub
parent de6f531e91
commit a56e14bff1
51 changed files with 1033 additions and 1418 deletions
@@ -12,7 +12,7 @@ export class AssetBundlePlugSpacePrimitives implements SpacePrimitives {
}
async fetchFileList(): Promise<FileMeta[]> {
const l = await this.wrapped.fetchFileList();
const files = await this.wrapped.fetchFileList();
return this.assetBundle.listFiles().filter((p) => p.startsWith("_plug/"))
.map((p) => ({
name: p,
@@ -20,7 +20,7 @@ export class AssetBundlePlugSpacePrimitives implements SpacePrimitives {
lastModified: bootTime,
perm: "ro",
size: -1,
} as FileMeta)).concat(l);
} as FileMeta)).concat(files);
}
readFile(
@@ -31,7 +31,7 @@ export class AssetBundlePlugSpacePrimitives implements SpacePrimitives {
const data = this.assetBundle.readFileSync(name);
// console.log("Requested encoding", encoding);
return Promise.resolve({
data: encoding === "string" ? new TextDecoder().decode(data) : data,
data: encoding === "utf8" ? new TextDecoder().decode(data) : data,
meta: {
lastModified: bootTime,
size: data.byteLength,
@@ -60,7 +60,7 @@ export class AssetBundlePlugSpacePrimitives implements SpacePrimitives {
name: string,
encoding: FileEncoding,
data: FileData,
selfUpdate?: boolean | undefined,
selfUpdate?: boolean,
): Promise<FileMeta> {
return this.wrapped.writeFile(name, encoding, data, selfUpdate);
}
+9 -3
View File
@@ -15,6 +15,8 @@ function lookupContentType(path: string): string {
return mime.getType(path) || "application/octet-stream";
}
const excludedFiles = ["data.db", "data.db-journal", "sync.json"];
export class DiskSpacePrimitives implements SpacePrimitives {
rootPath: string;
@@ -48,7 +50,7 @@ export class DiskSpacePrimitives implements SpacePrimitives {
let data: FileData | null = null;
const contentType = lookupContentType(name);
switch (encoding) {
case "string":
case "utf8":
data = await Deno.readTextFile(localPath);
break;
case "dataurl":
@@ -98,7 +100,7 @@ export class DiskSpacePrimitives implements SpacePrimitives {
// Actually write the file
switch (encoding) {
case "string":
case "utf8":
await Deno.writeTextFile(`${localPath}`, data as string);
break;
case "dataurl":
@@ -165,8 +167,12 @@ export class DiskSpacePrimitives implements SpacePrimitives {
const fullPath = file.path;
try {
const s = await Deno.stat(fullPath);
const name = fullPath.substring(this.rootPath.length + 1);
if (excludedFiles.includes(name)) {
continue;
}
allFiles.push({
name: fullPath.substring(this.rootPath.length + 1),
name: name,
lastModified: s.mtime!.getTime(),
contentType: mime.getType(fullPath) || "application/octet-stream",
size: s.size,
+2 -2
View File
@@ -35,7 +35,7 @@ export class EventedSpacePrimitives implements SpacePrimitives {
name: string,
encoding: FileEncoding,
data: FileData,
selfUpdate: boolean,
selfUpdate?: boolean,
): Promise<FileMeta> {
const newMeta = await this.wrapped.writeFile(
name,
@@ -48,7 +48,7 @@ export class EventedSpacePrimitives implements SpacePrimitives {
const pageName = name.substring(0, name.length - 3);
let text = "";
switch (encoding) {
case "string":
case "utf8":
text = data as string;
break;
case "arraybuffer":
+3 -3
View File
@@ -12,10 +12,10 @@ export class FileMetaSpacePrimitives implements SpacePrimitives {
}
async fetchFileList(): Promise<FileMeta[]> {
const list = await this.wrapped.fetchFileList();
const files = await this.wrapped.fetchFileList();
// Enrich the file list with custom meta data (for pages)
const allFilesMap: Map<string, any> = new Map(
list.map((fm) => [fm.name, fm]),
files.map((fm) => [fm.name, fm]),
);
for (
const { page, value } of await this.indexSyscalls["index.queryPrefix"](
@@ -53,7 +53,7 @@ export class FileMetaSpacePrimitives implements SpacePrimitives {
name: string,
encoding: FileEncoding,
data: FileData,
selfUpdate?: boolean | undefined,
selfUpdate?: boolean,
): Promise<FileMeta> {
return this.wrapped.writeFile(name, encoding, data, selfUpdate);
}
+63 -24
View File
@@ -3,33 +3,51 @@ import { Plug } from "../../plugos/plug.ts";
import { FileData, FileEncoding, SpacePrimitives } from "./space_primitives.ts";
import {
base64DecodeDataUrl,
base64Encode,
base64EncodedDataUrl,
} from "../../plugos/asset_bundle/base64.ts";
import { mime } from "../../plugos/deps.ts";
export class HttpSpacePrimitives implements SpacePrimitives {
fsUrl: string;
private fsUrl: string;
private plugUrl: string;
constructor(url: string) {
constructor(
url: string,
readonly user?: string,
readonly password?: string,
readonly base64Put?: boolean,
) {
this.fsUrl = url + "/fs";
this.plugUrl = url + "/plug";
}
private async authenticatedFetch(
url: string,
options: any,
options: Record<string, any>,
): Promise<Response> {
if (this.user && this.password) {
// Explicitly set an auth cookie
if (!options.headers) {
options.headers = {};
}
options.headers["cookie"] = `auth=${
btoa(`${this.user}:${this.password}`)
}`;
}
const result = await fetch(url, options);
if (result.status === 401) {
if (result.status === 401 || result.redirected) {
// Invalid credentials, reloading the browser should trigger authentication
location.reload();
if (typeof location !== "undefined") {
location.reload();
}
throw Error("Unauthorized");
}
return result;
}
public async fetchFileList(): Promise<FileMeta[]> {
async fetchFileList(): Promise<FileMeta[]> {
const req = await this.authenticatedFetch(this.fsUrl, {
method: "GET",
});
@@ -41,9 +59,12 @@ export class HttpSpacePrimitives implements SpacePrimitives {
name: string,
encoding: FileEncoding,
): Promise<{ data: FileData; meta: FileMeta }> {
const res = await this.authenticatedFetch(`${this.fsUrl}/${name}`, {
method: "GET",
});
const res = await this.authenticatedFetch(
`${this.fsUrl}/${encodeURI(name)}`,
{
method: "GET",
},
);
if (res.status === 404) {
throw new Error(`Page not found`);
}
@@ -52,7 +73,6 @@ export class HttpSpacePrimitives implements SpacePrimitives {
case "arraybuffer":
{
data = await res.arrayBuffer();
// data = await abBlob.arrayBuffer();
}
break;
case "dataurl":
@@ -63,7 +83,7 @@ export class HttpSpacePrimitives implements SpacePrimitives {
);
}
break;
case "string":
case "utf8":
data = await res.text();
break;
}
@@ -82,37 +102,56 @@ export class HttpSpacePrimitives implements SpacePrimitives {
switch (encoding) {
case "arraybuffer":
case "string":
// actually we want an Uint8Array
body = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
break;
case "utf8":
body = data;
break;
case "dataurl":
data = base64DecodeDataUrl(data as string);
break;
}
const res = await this.authenticatedFetch(`${this.fsUrl}/${name}`, {
method: "PUT",
headers: {
"Content-type": "application/octet-stream",
const headers: Record<string, string> = {
"Content-Type": "application/octet-stream",
};
if (this.base64Put) {
headers["X-Content-Base64"] = "true";
headers["Content-Type"] = "text/plain";
body = base64Encode(body);
}
const res = await this.authenticatedFetch(
`${this.fsUrl}/${encodeURI(name)}`,
{
method: "PUT",
headers,
body,
},
body,
});
);
const newMeta = this.responseToMeta(name, res);
return newMeta;
}
async deleteFile(name: string): Promise<void> {
const req = await this.authenticatedFetch(`${this.fsUrl}/${name}`, {
method: "DELETE",
});
const req = await this.authenticatedFetch(
`${this.fsUrl}/${encodeURI(name)}`,
{
method: "DELETE",
},
);
if (req.status !== 200) {
throw Error(`Failed to delete file: ${req.statusText}`);
}
}
async getFileMeta(name: string): Promise<FileMeta> {
const res = await this.authenticatedFetch(`${this.fsUrl}/${name}`, {
method: "OPTIONS",
});
const res = await this.authenticatedFetch(
`${this.fsUrl}/${encodeURI(name)}`,
{
method: "OPTIONS",
},
);
if (res.status === 404) {
throw new Error(`File not found`);
}
+131
View File
@@ -0,0 +1,131 @@
import { Plug } from "../../plugos/plug.ts";
import {
FileData,
FileEncoding,
SpacePrimitives,
} from "../../common/spaces/space_primitives.ts";
import { FileMeta } from "../../common/types.ts";
import {
NamespaceOperation,
PageNamespaceHook,
} from "../hooks/page_namespace.ts";
import { base64DecodeDataUrl } from "../../plugos/asset_bundle/base64.ts";
export class PlugSpacePrimitives implements SpacePrimitives {
constructor(
private wrapped: SpacePrimitives,
private hook: PageNamespaceHook,
private env?: string,
) {}
performOperation(
type: NamespaceOperation,
pageName: string,
...args: any[]
): Promise<any> | false {
for (
const { operation, pattern, plug, name, env } of this.hook.spaceFunctions
) {
if (
operation === type && pageName.match(pattern) &&
(!this.env || (env && env === this.env))
) {
return plug.invoke(name, [pageName, ...args]);
}
}
return false;
}
async fetchFileList(): Promise<FileMeta[]> {
const allFiles: FileMeta[] = [];
for (const { plug, name, operation } of this.hook.spaceFunctions) {
if (operation === "listFiles") {
try {
for (const pm of await plug.invoke(name, [])) {
allFiles.push(pm);
}
} catch (e) {
console.error("Error listing files", e);
}
}
}
const files = await this.wrapped.fetchFileList();
for (const pm of files) {
allFiles.push(pm);
}
return allFiles;
}
async readFile(
name: string,
encoding: FileEncoding,
): Promise<{ data: FileData; meta: FileMeta }> {
const wantArrayBuffer = encoding === "arraybuffer";
const result: { data: FileData; meta: FileMeta } | false = await this
.performOperation(
"readFile",
name,
wantArrayBuffer ? "dataurl" : encoding,
);
if (result) {
if (wantArrayBuffer) {
return {
data: base64DecodeDataUrl(result.data as string),
meta: result.meta,
};
} else {
return result;
}
}
return this.wrapped.readFile(name, encoding);
}
getFileMeta(name: string): Promise<FileMeta> {
const result = this.performOperation("getFileMeta", name);
if (result) {
return result;
}
return this.wrapped.getFileMeta(name);
}
writeFile(
name: string,
encoding: FileEncoding,
data: FileData,
selfUpdate?: boolean,
): Promise<FileMeta> {
const result = this.performOperation(
"writeFile",
name,
encoding,
data,
selfUpdate,
);
if (result) {
return result;
}
return this.wrapped.writeFile(name, encoding, data, selfUpdate);
}
deleteFile(name: string): Promise<void> {
const result = this.performOperation("deleteFile", name);
if (result) {
return result;
}
return this.wrapped.deleteFile(name);
}
proxySyscall(plug: Plug<any>, name: string, args: any[]): Promise<any> {
return this.wrapped.proxySyscall(plug, name, args);
}
invokeFunction(
plug: Plug<any>,
env: string,
name: string,
args: any[],
): Promise<any> {
return this.wrapped.invokeFunction(plug, env, name, args);
}
}
+55 -17
View File
@@ -1,9 +1,13 @@
import { FileData, FileEncoding, SpacePrimitives } from "./space_primitives.ts";
import { AttachmentMeta, FileMeta, PageMeta } from "../types.ts";
import { AttachmentMeta, PageMeta } from "../types.ts";
import { EventEmitter } from "../../plugos/event.ts";
import { Plug } from "../../plugos/plug.ts";
import { plugPrefix } from "./constants.ts";
import { safeRun } from "../util.ts";
import {
FileMeta,
ProxyFileSystem,
} from "../../plug-api/plugos-syscall/types.ts";
const pageWatchInterval = 2000;
@@ -14,16 +18,42 @@ export type SpaceEvents = {
pageListUpdated: (pages: Set<PageMeta>) => void;
};
export class Space extends EventEmitter<SpaceEvents> {
export class Space extends EventEmitter<SpaceEvents>
implements ProxyFileSystem {
pageMetaCache = new Map<string, PageMeta>();
watchedPages = new Set<string>();
private initialPageListLoad = true;
private saving = false;
constructor(private space: SpacePrimitives) {
constructor(readonly spacePrimitives: SpacePrimitives) {
super();
}
// Filesystem interface implementation
async readFile(path: string, encoding: "dataurl" | "utf8"): Promise<string> {
return (await this.spacePrimitives.readFile(path, encoding)).data as string;
}
getFileMeta(path: string): Promise<FileMeta> {
return this.spacePrimitives.getFileMeta(path);
}
writeFile(
path: string,
text: string,
encoding: "dataurl" | "utf8",
): Promise<FileMeta> {
return this.spacePrimitives.writeFile(path, encoding, text);
}
deleteFile(path: string): Promise<void> {
return this.spacePrimitives.deleteFile(path);
}
async listFiles(path: string): Promise<FileMeta[]> {
return (await this.spacePrimitives.fetchFileList()).filter((f) =>
f.name.startsWith(path)
);
}
// The more domain-specific methods
public async updatePageList() {
const newPageList = await this.fetchPageList();
const deletedPages = new Set<string>(this.pageMetaCache.keys());
@@ -81,7 +111,7 @@ export class Space extends EventEmitter<SpaceEvents> {
async deletePage(name: string): Promise<void> {
await this.getPageMeta(name); // Check if page exists, if not throws Error
await this.space.deleteFile(`${name}.md`);
await this.spacePrimitives.deleteFile(`${name}.md`);
this.pageMetaCache.delete(name);
this.emit("pageDeleted", name);
@@ -91,7 +121,7 @@ export class Space extends EventEmitter<SpaceEvents> {
async getPageMeta(name: string): Promise<PageMeta> {
const oldMeta = this.pageMetaCache.get(name);
const newMeta = fileMetaToPageMeta(
await this.space.getFileMeta(`${name}.md`),
await this.spacePrimitives.getFileMeta(`${name}.md`),
);
if (oldMeta) {
if (oldMeta.lastModified !== newMeta.lastModified) {
@@ -108,7 +138,7 @@ export class Space extends EventEmitter<SpaceEvents> {
name: string,
args: any[],
): Promise<any> {
return this.space.invokeFunction(plug, env, name, args);
return this.spacePrimitives.invokeFunction(plug, env, name, args);
}
listPages(): Set<PageMeta> {
@@ -116,18 +146,21 @@ export class Space extends EventEmitter<SpaceEvents> {
}
async listPlugs(): Promise<string[]> {
const allFiles = await this.space.fetchFileList();
return allFiles
const files = await this.spacePrimitives.fetchFileList();
return files
.filter((fileMeta) => fileMeta.name.endsWith(".plug.json"))
.map((fileMeta) => fileMeta.name);
}
proxySyscall(plug: Plug<any>, name: string, args: any[]): Promise<any> {
return this.space.proxySyscall(plug, name, args);
return this.spacePrimitives.proxySyscall(plug, name, args);
}
async readPage(name: string): Promise<{ text: string; meta: PageMeta }> {
const pageData = await this.space.readFile(`${name}.md`, "string");
const pageData = await this.spacePrimitives.readFile(
`${name}.md`,
"utf8",
);
const previousMeta = this.pageMetaCache.get(name);
const newMeta = fileMetaToPageMeta(pageData.meta);
if (previousMeta) {
@@ -159,7 +192,12 @@ export class Space extends EventEmitter<SpaceEvents> {
try {
this.saving = true;
const pageMeta = fileMetaToPageMeta(
await this.space.writeFile(`${name}.md`, "string", text, selfUpdate),
await this.spacePrimitives.writeFile(
`${name}.md`,
"utf8",
text,
selfUpdate,
),
);
if (!selfUpdate) {
this.emit("pageChanged", pageMeta);
@@ -171,13 +209,13 @@ export class Space extends EventEmitter<SpaceEvents> {
}
async fetchPageList(): Promise<PageMeta[]> {
return (await this.space.fetchFileList())
return (await this.spacePrimitives.fetchFileList())
.filter((fileMeta) => fileMeta.name.endsWith(".md"))
.map(fileMetaToPageMeta);
}
async fetchAttachmentList(): Promise<AttachmentMeta[]> {
return (await this.space.fetchFileList()).filter(
return (await this.spacePrimitives.fetchFileList()).filter(
(fileMeta) =>
!fileMeta.name.endsWith(".md") &&
!fileMeta.name.endsWith(".plug.json") &&
@@ -195,11 +233,11 @@ export class Space extends EventEmitter<SpaceEvents> {
name: string,
encoding: FileEncoding,
): Promise<{ data: FileData; meta: AttachmentMeta }> {
return this.space.readFile(name, encoding);
return this.spacePrimitives.readFile(name, encoding);
}
getAttachmentMeta(name: string): Promise<AttachmentMeta> {
return this.space.getFileMeta(name);
return this.spacePrimitives.getFileMeta(name);
}
writeAttachment(
@@ -208,11 +246,11 @@ export class Space extends EventEmitter<SpaceEvents> {
data: FileData,
selfUpdate?: boolean | undefined,
): Promise<AttachmentMeta> {
return this.space.writeFile(name, encoding, data, selfUpdate);
return this.spacePrimitives.writeFile(name, encoding, data, selfUpdate);
}
deleteAttachment(name: string): Promise<void> {
return this.space.deleteFile(name);
return this.spacePrimitives.deleteFile(name);
}
private metaCacher(name: string, meta: PageMeta): PageMeta {
+3 -2
View File
@@ -1,10 +1,10 @@
import { Plug } from "../../plugos/plug.ts";
import { FileMeta } from "../types.ts";
export type FileEncoding = "string" | "arraybuffer" | "dataurl";
export type FileEncoding = "utf8" | "arraybuffer" | "dataurl";
export type FileData = ArrayBuffer | string;
export interface SpacePrimitives {
// Pages
// Returns a list of file meta data as well as the timestamp of this snapshot
fetchFileList(): Promise<FileMeta[]>;
readFile(
name: string,
@@ -15,6 +15,7 @@ export interface SpacePrimitives {
name: string,
encoding: FileEncoding,
data: FileData,
// Used to decide whether or not to emit change events
selfUpdate?: boolean,
): Promise<FileMeta>;
deleteFile(name: string): Promise<void>;
+143
View File
@@ -0,0 +1,143 @@
import { SpaceSync, SyncStatusItem } from "./sync.ts";
import { DiskSpacePrimitives } from "./disk_space_primitives.ts";
import { assertEquals } from "../../test_deps.ts";
Deno.test("Test store", async () => {
const primaryPath = await Deno.makeTempDir();
const secondaryPath = await Deno.makeTempDir();
console.log("Primary", primaryPath);
console.log("Secondary", secondaryPath);
const primary = new DiskSpacePrimitives(primaryPath);
const secondary = new DiskSpacePrimitives(secondaryPath);
const statusMap = new Map<string, SyncStatusItem>();
const sync = new SpaceSync(primary, secondary, statusMap);
// Write one page to primary
await primary.writeFile("index", "utf8", "Hello");
assertEquals((await secondary.fetchFileList()).length, 0);
console.log("Initial sync ops", await doSync());
assertEquals((await secondary.fetchFileList()).length, 1);
assertEquals((await secondary.readFile("index", "utf8")).data, "Hello");
// Should be a no-op
assertEquals(await doSync(), 0);
// Now let's make a change on the secondary
await secondary.writeFile("index", "utf8", "Hello!!");
await secondary.writeFile("test", "utf8", "Test page");
// And sync it
await doSync();
assertEquals((await primary.fetchFileList()).length, 2);
assertEquals((await secondary.fetchFileList()).length, 2);
assertEquals((await primary.readFile("index", "utf8")).data, "Hello!!");
// Let's make some random edits on both ends
await primary.writeFile("index", "utf8", "1");
await primary.writeFile("index2", "utf8", "2");
await secondary.writeFile("index3", "utf8", "3");
await secondary.writeFile("index4", "utf8", "4");
await doSync();
assertEquals((await primary.fetchFileList()).length, 5);
assertEquals((await secondary.fetchFileList()).length, 5);
assertEquals(await doSync(), 0);
console.log("Deleting pages");
// Delete some pages
await primary.deleteFile("index");
await primary.deleteFile("index3");
await doSync();
assertEquals((await primary.fetchFileList()).length, 3);
assertEquals((await secondary.fetchFileList()).length, 3);
// No-op
assertEquals(await doSync(), 0);
await secondary.deleteFile("index4");
await primary.deleteFile("index2");
await doSync();
// Just "test" left
assertEquals((await primary.fetchFileList()).length, 1);
assertEquals((await secondary.fetchFileList()).length, 1);
// No-op
assertEquals(await doSync(), 0);
await secondary.writeFile("index", "utf8", "I'm back");
await doSync();
assertEquals((await primary.readFile("index", "utf8")).data, "I'm back");
// Cause a conflict
console.log("Introducing a conflict now");
await primary.writeFile("index", "utf8", "Hello 1");
await secondary.writeFile("index", "utf8", "Hello 2");
await doSync();
// Sync conflicting copy back
await doSync();
// Verify that primary won
assertEquals((await primary.readFile("index", "utf8")).data, "Hello 1");
assertEquals((await secondary.readFile("index", "utf8")).data, "Hello 1");
// test + index + index.conflicting copy
assertEquals((await primary.fetchFileList()).length, 3);
assertEquals((await secondary.fetchFileList()).length, 3);
// Introducing a fake conflict (same content, so not really conflicting)
await primary.writeFile("index", "utf8", "Hello 1");
await secondary.writeFile("index", "utf8", "Hello 1");
await doSync();
await doSync();
// test + index + previous index.conflicting copy but nothing more
assertEquals((await primary.fetchFileList()).length, 3);
console.log("Bringing a third device in the mix");
const ternaryPath = await Deno.makeTempDir();
console.log("Ternary", ternaryPath);
const ternary = new DiskSpacePrimitives(ternaryPath);
const sync2 = new SpaceSync(
secondary,
ternary,
new Map<string, SyncStatusItem>(),
);
console.log("N ops", await sync2.syncFiles());
await sleep(2);
assertEquals(await sync2.syncFiles(), 0);
await Deno.remove(primaryPath, { recursive: true });
await Deno.remove(secondaryPath, { recursive: true });
await Deno.remove(ternaryPath, { recursive: true });
async function doSync() {
await sleep();
const r = await sync.syncFiles(
SpaceSync.primaryConflictResolver,
);
await sleep();
return r;
}
});
function sleep(ms = 10): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
+271
View File
@@ -0,0 +1,271 @@
import type { FileMeta } from "../types.ts";
import { SpacePrimitives } from "./space_primitives.ts";
type SyncHash = number;
// Tuple where the first value represents a lastModified timestamp for the primary space
// and the second item the lastModified value of the secondary space
export type SyncStatusItem = [SyncHash, SyncHash];
// Implementation of this algorithm https://unterwaditzer.net/2016/sync-algorithm.html
export class SpaceSync {
constructor(
private primary: SpacePrimitives,
private secondary: SpacePrimitives,
readonly snapshot: Map<string, SyncStatusItem>,
) {}
async syncFiles(
conflictResolver?: (
name: string,
snapshot: Map<string, SyncStatusItem>,
primarySpace: SpacePrimitives,
secondarySpace: SpacePrimitives,
) => Promise<void>,
): Promise<number> {
let operations = 0;
console.log("Fetching snapshot from primary");
const primaryAllPages = this.syncCandidates(
await this.primary.fetchFileList(),
);
console.log("Fetching snapshot from secondary");
try {
const secondaryAllPages = this.syncCandidates(
await this.secondary.fetchFileList(),
);
const primaryFileMap = new Map<string, SyncHash>(
primaryAllPages.map((m) => [m.name, m.lastModified]),
);
const secondaryFileMap = new Map<string, SyncHash>(
secondaryAllPages.map((m) => [m.name, m.lastModified]),
);
const allFilesToProcess = new Set([
...this.snapshot.keys(),
...primaryFileMap.keys(),
...secondaryFileMap.keys(),
]);
console.log("Iterating over all files");
for (const name of allFilesToProcess) {
if (
primaryFileMap.has(name) && !secondaryFileMap.has(name) &&
!this.snapshot.has(name)
) {
// New file, created on primary, copy from primary to secondary
console.log(
"New file created on primary, copying to secondary",
name,
);
const { data } = await this.primary.readFile(name, "arraybuffer");
const writtenMeta = await this.secondary.writeFile(
name,
"arraybuffer",
data,
);
this.snapshot.set(name, [
primaryFileMap.get(name)!,
writtenMeta.lastModified,
]);
operations++;
} else if (
secondaryFileMap.has(name) && !primaryFileMap.has(name) &&
!this.snapshot.has(name)
) {
// New file, created on secondary, copy from secondary to primary
console.log(
"New file created on secondary, copying from secondary to primary",
name,
);
const { data } = await this.secondary.readFile(name, "arraybuffer");
const writtenMeta = await this.primary.writeFile(
name,
"arraybuffer",
data,
);
this.snapshot.set(name, [
writtenMeta.lastModified,
secondaryFileMap.get(name)!,
]);
operations++;
} else if (
primaryFileMap.has(name) && this.snapshot.has(name) &&
!secondaryFileMap.has(name)
) {
// File deleted on B
console.log("File deleted on secondary, deleting from primary", name);
await this.primary.deleteFile(name);
this.snapshot.delete(name);
operations++;
} else if (
secondaryFileMap.has(name) && this.snapshot.has(name) &&
!primaryFileMap.has(name)
) {
// File deleted on A
console.log("File deleted on primary, deleting from secondary", name);
await this.secondary.deleteFile(name);
this.snapshot.delete(name);
operations++;
} else if (
this.snapshot.has(name) && !primaryFileMap.has(name) &&
!secondaryFileMap.has(name)
) {
// File deleted on both sides, :shrug:
console.log("File deleted on both ends, deleting from status", name);
this.snapshot.delete(name);
operations++;
} else if (
primaryFileMap.has(name) && secondaryFileMap.has(name) &&
this.snapshot.get(name) &&
primaryFileMap.get(name) !== this.snapshot.get(name)![0] &&
secondaryFileMap.get(name) === this.snapshot.get(name)![1]
) {
// File has changed on primary, but not secondary: copy from primary to secondary
console.log("File changed on primary, copying to secondary", name);
const { data } = await this.primary.readFile(name, "arraybuffer");
const writtenMeta = await this.secondary.writeFile(
name,
"arraybuffer",
data,
);
this.snapshot.set(name, [
primaryFileMap.get(name)!,
writtenMeta.lastModified,
]);
operations++;
} else if (
primaryFileMap.has(name) && secondaryFileMap.has(name) &&
this.snapshot.get(name) &&
secondaryFileMap.get(name) !== this.snapshot.get(name)![1] &&
primaryFileMap.get(name) === this.snapshot.get(name)![0]
) {
// File has changed on secondary, but not primary: copy from secondary to primary
const { data } = await this.secondary.readFile(name, "arraybuffer");
const writtenMeta = await this.primary.writeFile(
name,
"arraybuffer",
data,
);
this.snapshot.set(name, [
writtenMeta.lastModified,
secondaryFileMap.get(name)!,
]);
operations++;
} else if (
( // File changed on both ends, but we don't have any info in the snapshot (resync scenario?): have to run through conflict handling
primaryFileMap.has(name) && secondaryFileMap.has(name) &&
!this.snapshot.has(name)
) ||
( // File changed on both ends, CONFLICT!
primaryFileMap.has(name) && secondaryFileMap.has(name) &&
this.snapshot.get(name) &&
secondaryFileMap.get(name) !== this.snapshot.get(name)![1] &&
primaryFileMap.get(name) !== this.snapshot.get(name)![0]
)
) {
console.log("File changed on both ends, conflict!", name);
if (conflictResolver) {
await conflictResolver(
name,
this.snapshot,
this.primary,
this.secondary,
);
} else {
throw Error(
`Sync conflict for ${name} with no conflict resolver specified`,
);
}
operations++;
} else {
// Nothing needs to happen
}
}
} catch (e: any) {
console.error("Boom", e.message);
throw e;
}
return operations;
}
// Strategy: Primary wins
public static async primaryConflictResolver(
name: string,
snapshot: Map<string, SyncStatusItem>,
primary: SpacePrimitives,
secondary: SpacePrimitives,
): Promise<void> {
console.log("Hit a conflict for", name);
const filePieces = name.split(".");
const fileNameBase = filePieces.slice(0, -1).join(".");
const fileNameExt = filePieces[filePieces.length - 1];
const pageData1 = await primary.readFile(name, "arraybuffer");
const pageData2 = await secondary.readFile(name, "arraybuffer");
let byteWiseMatch = true;
const arrayBuffer1 = new Uint8Array(pageData1.data as ArrayBuffer);
const arrayBuffer2 = new Uint8Array(pageData2.data as ArrayBuffer);
if (arrayBuffer1.byteLength !== arrayBuffer2.byteLength) {
byteWiseMatch = false;
}
if (byteWiseMatch) {
// Byte-wise comparison
for (let i = 0; i < arrayBuffer1.byteLength; i++) {
if (arrayBuffer1[i] !== arrayBuffer2[i]) {
byteWiseMatch = false;
break;
}
}
// Byte wise they're still the same, so no confict
if (byteWiseMatch) {
snapshot.set(name, [
pageData1.meta.lastModified,
pageData2.meta.lastModified,
]);
return;
}
}
const revisionFileName = filePieces.length === 1
? `${name}.conflicted.${pageData2.meta.lastModified}`
: `${fileNameBase}.conflicted.${pageData2.meta.lastModified}.${fileNameExt}`;
console.log(
"Going to create conflicting copy",
revisionFileName,
);
// Copy secondary to conflict copy
const localConflictMeta = await primary.writeFile(
revisionFileName,
"arraybuffer",
pageData2.data,
);
const remoteConflictMeta = await secondary.writeFile(
revisionFileName,
"arraybuffer",
pageData2.data,
);
// Updating snapshot
snapshot.set(revisionFileName, [
localConflictMeta.lastModified,
remoteConflictMeta.lastModified,
]);
// Write replacement on top
const writeMeta = await secondary.writeFile(
name,
"arraybuffer",
pageData1.data,
true,
);
snapshot.set(name, [pageData1.meta.lastModified, writeMeta.lastModified]);
}
syncCandidates(files: FileMeta[]): FileMeta[] {
return files.filter((f) => !f.name.startsWith("_plug/"));
}
}