@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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/"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user