import SHA from "jssha"; import Path from "./helper/path"; export interface IDataStore { get(key: string): Promise; set(key: string, data: Uint8Array): Promise; has(key: string): Promise; close?: () => Promise; getLock: () => Promise<() => Promise>; } export type NodeType = "tree" | "blob"; export type NodeHash = string; export type NodeFilename = string; export type TreeEntry = [NodeType, NodeHash, NodeFilename]; export type Commit = { id: string; root: string; before: string; merge: string; date: Date; }; export type NodeLog = { /** * ObjectID of the data. Can be undefined if element was deleted! */ id: string | undefined; commit: Commit } export type ISyncTransferStream = { on(type: "data", func: (data: Uint8Array) => void): void; send(data: Uint8Array): void; } // TODOs: // - HEAD locks // - HEAD/Tree Cache // - Remote synchronisation // - Add DataStore Locking for access from multiple sources export const ObjectTypes = { blob: new Uint8Array([98, 108, 111, 98, 10]), tree: new Uint8Array([116, 114, 101, 101, 10]), comm: new Uint8Array([99, 111, 109, 109, 10]), } export type ObjectTypeNames = keyof typeof ObjectTypes; export default class Repository { //#region local variables #store: IDataStore; //#endregion constructor(store: IDataStore) { this.#store = store; } //#region private private sha1(data: Uint8Array) { const s = new SHA("SHA-1", "UINT8ARRAY"); s.update(data); return s.getHash("HEX"); } private sha256(data: Uint8Array) { const s = new SHA("SHA3-256", "UINT8ARRAY"); s.update(data); return s.getHash("HEX"); } private splitPath(path: string) { const resolved = Path.resolve(path).slice(1); if (resolved == "") return []; return resolved.split(Path.sep); } private async treeFindObjectID( treeID: string, parts: string[], type: NodeType ): Promise { if (parts.length == 0) { if (type == "tree") return treeID; else throw new Error("Cannot open root as file!"); } const tree = await this.readTree(treeID); const current = parts[0]; let entry = tree.find(([type, id, name]) => name == current); if (!entry) { return undefined; } if (parts.length == 1) { if (entry[0] != type) throw new Error("Expected 'blob' found 'tree'!"); return entry[1]; } else { if (entry[0] != "tree") throw new Error("Expected 'tree' found 'blob'!"); return this.treeFindObjectID(entry[1], parts.splice(1), type); } } private async readTree(id: string): Promise { const tree = await this.readObject(id, true); if (tree == undefined) throw new Error("Invalid treeID"); return this.parseTree(tree); } private async makeCommit(treeID: string, old?: Commit, merge?: Commit) { if (!old) { // Could be called once more than necessary, if no HEAD exists. old = await this.readHead(); } let commitStr = `tree ${treeID}\ndate ${new Date().toISOString()}\n`; if (old) { commitStr += `before ${old?.id}\n`; if (merge) { commitStr += `merge ${old?.id}\n`; } } return await this.writeObject(commitStr, "comm"); } //#endregion //#region public async clean() { // TODO: Cleanup broken things } async writeHead(commitID: string): Promise { await this.#store.set("HEAD", new TextEncoder().encode(commitID)); } public async writeObject(data: Uint8Array | string, type: ObjectTypeNames): Promise { if (typeof data == "string") { data = new TextEncoder().encode(data); } const objectID = this.sha1(data); let merged = new Uint8Array(5 + data.length); merged.set(ObjectTypes[type], 0) merged.set(data, 5); await this.#store.set("objects/" + objectID, merged); return objectID; } public async hasObject(id: string): Promise { return this.#store.has("objects/" + id); } public async readObjectRaw(id: string) { return await this.#store.get("objects/" + id); } public async readObjectTyped(id: String) { let data = await this.#store.get("objects/" + id); if (!data) return undefined; let type = new TextDecoder().decode(data.slice(0, 4)) return { type: type as ObjectTypeNames, data: data.slice(5) } } public async readObject(id: string, string: true): Promise; public async readObject(id: string, string?: false): Promise; public async readObject(id: string, string = false): Promise { const res = await this.readObjectTyped(id); if (!res) return undefined; const { data } = res; if (string) { return new TextDecoder().decode(data); } else { return data; } } public async readObjectType(id: string): Promise { const res = await this.readObjectTyped(id); if (!res) return undefined; return res.type; } async hasCommit(id: string) { let type = await this.readObjectType(id) return type == "comm"; } parseTree(treeStr: string | Uint8Array): TreeEntry[] { if (typeof treeStr !== "string") treeStr = new TextDecoder().decode(treeStr); return treeStr.split("\n").filter(e => e !== "").map((e) => { const entry = e.split(" ") as TreeEntry; const [type] = entry; switch (type) { case "blob": case "tree": break; default: throw new Error("Invalid tree type."); //Might be a newer version or so } return entry; }); } parseCommit(id: string, commitStr: string | Uint8Array,) { if (typeof commitStr !== "string") commitStr = new TextDecoder().decode(commitStr); let commit: Commit = { id } as any; for (const entry of commitStr.split("\n")) { const [type] = entry.split(" ", 1); const value = entry.slice(type.length + 1); switch (type) { case "tree": // TODO: Simple validity checks commit.root = value; break; case "before": // TODO: Simple validity checks commit.before = value; break; case "merge": // TODO: Simple validity checks commit.merge = value; break; case "date": commit.date = new Date(value); break; } } return commit; } async readCommit(id: string): Promise { const commitStr = await this.readObject(id, true); if (!commitStr) throw new Error(`Commit with id ${id} not found!`); let commit = this.parseCommit(id, commitStr); if (!commit.root) { throw new Error("No tree defined in this commit!"); } return commit; } async readHead(): Promise { if (!(await this.#store.has("HEAD"))) return undefined; const head = new TextDecoder().decode(await this.#store.get("HEAD")); return this.readCommit(head); } async mergeCommits(commit1: string, commit2: string): Promise { throw new Error("WIP"); // let newCommit = this.makeCommit() } async readCommitLog(till?: string, merges?: boolean): Promise { let head = await this.readHead(); if (!head) return []; let log: Commit[] = [head]; let current = head; while (current.before || (till && current.id == till)) { current = await this.readCommit(current.before); if (!current) throw new Error("Repository sems damaged! Can't read commit!"); log.push(current) } return log; } async readdir(path: string): Promise { const parts = this.splitPath(path); const head = await this.readHead(); if (!head) return []; const treeID = await this.treeFindObjectID(head.root, parts, "tree"); if (!treeID) return []; return this.readTree(treeID); } async read(path: string): Promise { const parts = this.splitPath(path); const head = await this.readHead(); if (!head) return undefined; const objectID = await this.treeFindObjectID(head.root, parts, "blob"); if (!objectID) return undefined; return this.readObject(objectID); } async readByID(id: string) { return this.readObject(id); } async fileLog(path: string): Promise { const parts = this.splitPath(path); const head = await this.readHead(); if (!head) return []; let currObjectID = await this.treeFindObjectID(head.root, parts, "blob"); let history: NodeLog[] = []; if (currObjectID) { history.push({ id: currObjectID, commit: head }) } let currCommit = head.before; while (currCommit !== undefined) { try { let commit = await this.readCommit(currCommit); let res = await this.treeFindObjectID(commit.root, parts, "blob"); if (res !== currObjectID) { history.push({ id: res, commit, }); } currObjectID = res; currCommit = commit.before; } catch (err) { break; } } return history; } async delete(path: string) { return this.write(path, undefined); } async write(path: string, data: Uint8Array | undefined) { const parts = this.splitPath(path); let objectID: string | undefined = undefined; if (data) { objectID = await this.writeObject(data, "blob"); } const lock = await this.#store.getLock(); //TODO: Improve need of locking. try { const head = await this.readHead(); const makeTree = async (treeID: string | undefined, parts: string[]) => { let tree: TreeEntry[]; if (treeID) { tree = await this.readTree(treeID); } else { tree = []; } const current = parts[0]; let existing = tree.findIndex(([, , name]) => name == current); let entry: TreeEntry | undefined = undefined; if (parts.length == 1) { if (objectID) { entry = ["blob", objectID, current]; } } else { let newTreeID = await makeTree(existing >= 0 ? tree[existing][1] : undefined, parts.slice(1)); if (newTreeID) { entry = [ "tree", newTreeID, current, ]; } } if (existing >= 0) { let ex = tree[existing]; if (parts.length == 1 && ex[0] == "tree") throw new Error("This change would overwrite a folder!"); if (entry) tree[existing] = entry; else { tree = [...tree.slice(0, existing), ...tree.slice(existing + 1)]; } } else { if (entry) tree.push(entry); } if (tree.length > 0) { let treeString = tree.map(([type, hash, name]) => `${type} ${hash} ${name}`).join("\n"); let newTreeID = await this.writeObject(treeString, "tree"); return newTreeID; } else { return undefined; } }; let newTree = await makeTree(head?.root, parts); if (!newTree) { //TODO: Is this what i want? newTree = await this.writeObject("", "tree"); } let commit = await this.makeCommit(newTree, head); await this.writeHead(commit); } finally { await lock(); } } async close() { if (this.#store.close) return this.#store?.close(); } //#endregion }