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; date: Date; }; // TODOs: // - HEAD locks // - HEAD/Tree Cache // - Remote synchronisation // - Add DataStore Locking for access from multiple sources export default class Repository { #store: IDataStore; constructor(store: IDataStore) { this.#store = store; } 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.delimiter); } private getHeadLock() {} private async writeObject(data: Uint8Array | string): Promise { if (typeof data == "string") { data = new TextEncoder().encode(data); } const objectID = this.sha1(data); await this.#store.set("objects/" + objectID, data); return objectID; } private async hasObject(id: string): Promise { return this.#store.has("objects/" + id); } private async readObject(id: string, string: true): Promise; private async readObject(id: string, string?: false): Promise; private async readObject(id: string, string = false): Promise { let data = await this.#store.get("objects/" + id); if (string) { return new TextDecoder().decode(data); } else { return data; } } async clean() { // TODO: Cleanup broken things } async readdir(path: string): Promise { const parts = this.splitPath(path); console.log({ parts }); const head = await this.readHead(); if (!head) return undefined; const treeID = await this.treeFindObjectID(head.root, parts, "tree"); if (!treeID) return undefined; 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 write(path: string, data: Uint8Array) { const parts = this.splitPath(path); const objectID = await this.writeObject(data); const lock = await this.#store.getLock(); try { //TODO: Improve need of locking. 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; if (parts.length == 1) { entry = ["blob", objectID, current]; } else { entry = [ "tree", await makeTree(existing >= 0 ? tree[existing][1] : undefined, parts.slice(1)), current, ]; } if (existing >= 0) { let ex = tree[existing]; if (parts.length == 1 && ex[0] == "tree") throw new Error("This change would overwrite a folder!"); tree[existing] = entry; } else { tree.push(entry); } let treeString = tree.map(([type, hash, name]) => `${type} ${hash} ${name}`).join("\n"); let newTreeID = await this.writeObject(treeString); return newTreeID; }; let newTree = await makeTree(head?.root, parts); let commit = await this.makeCommit(newTree, head); await this.writeHead(commit); } finally { await lock(); } } 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); } } async readTree(id: string): Promise { const tree = await this.readObject(id, true); return tree.split("\n").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; }); } async makeCommit(treeID: string, old?: Commit) { if (!old) { // Could be called once more than necessary, if no HEAD exists. old = await this.readHead(); } const commitStr = `tree ${treeID}\ndate ${new Date().toISOString()}\n` + (old ? `before ${old?.id}\n` : ""); return await this.writeObject(commitStr); } async readCommit(id: string): Promise { if (!(await this.hasObject(id))) throw new Error(`Commit with id ${id} not found!`); const commitStr = await this.readObject(id, true); 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; case "date": commit.date = new Date(value); } } 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 writeHead(commitID: string): Promise { await this.#store.set("HEAD", new TextEncoder().encode(commitID)); } async close() { if (this.#store.close) return this.#store?.close(); } }