Last active
June 13, 2023 12:50
-
-
Save maierfelix/e7492160f2f8f926b9ab9d54c6b397b1 to your computer and use it in GitHub Desktop.
Simple IDB-based virtual file system for binary files. Fast and with support for sorting
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const fs = await new VirtualFileSystem({name: "my-project"}).onCreate(); | |
| const worldCount = await fs.readCount("worlds"); | |
| console.log(`Saved worlds: '${worldCount}'`); | |
| const insertId = await fs.insert("worlds", "hello", inputWorld); | |
| console.log(`Inserted world at '${insertId}'`); | |
| const outputWorld = await fs.readById("worlds", insertId); | |
| console.log(`Output world '${insertId}':`, outputWorld); | |
| console.log("Worlds with name 'hello':", await fs.readByName("worlds", "hello")); | |
| console.log("All Worlds - not sorted:", await fs.readAll("worlds", false)); | |
| console.log("All Worlds - sorted:", await fs.readAll("worlds", true)); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Interface representing a key entry | |
| */ | |
| export interface IKeyEntry { | |
| /** | |
| * The id of the entry | |
| */ | |
| id: number; | |
| /** | |
| * The name of the entry | |
| */ | |
| name: string; | |
| /** | |
| * The sorting index of the entry | |
| */ | |
| sort: number; | |
| /** | |
| * The binary data blob of the entry | |
| */ | |
| data: Blob; | |
| } | |
| /** | |
| * Interface representing a binary data entry | |
| */ | |
| export interface IBinaryDataEntry { | |
| /** | |
| * The name of the entry | |
| */ | |
| name: string; | |
| /** | |
| * The sorting index of the entry | |
| */ | |
| sort: number; | |
| /** | |
| * The binary data of the entry | |
| */ | |
| data: Uint8Array; | |
| } | |
| /** | |
| * The options to construct a virtual file system | |
| */ | |
| export interface IVirtualFileSystemOptions { | |
| /** | |
| * Name of the database | |
| */ | |
| name: string; | |
| } | |
| /** | |
| * The default options used when constructing an editor | |
| */ | |
| export const DEFAULT_OPTIONS_EDITOR: Required<IVirtualFileSystemOptions> = Object.freeze({ | |
| name: null, | |
| }); | |
| /** | |
| * A class that represents a virtual file system | |
| */ | |
| export class VirtualFileSystem { | |
| /** | |
| * The name of the virtual file system | |
| */ | |
| private _name: string = null; | |
| /** | |
| * IDB handle | |
| */ | |
| private _handle: IDBDatabase = null; | |
| /** | |
| * The constructor of this editor | |
| * @param options - The options to construct the editor | |
| */ | |
| public constructor(options: IVirtualFileSystemOptions) { | |
| // Normalize options | |
| const _options = Object.assign({}, DEFAULT_OPTIONS_EDITOR, options); | |
| this._name = _options.name; | |
| } | |
| /** | |
| * Returns the name of the fs | |
| */ | |
| public getName(): string {return this._name;} | |
| /** | |
| * Returns the IDB handle of the fs | |
| */ | |
| public getHandle(): IDBDatabase {return this._handle;} | |
| /** | |
| * Create the fs | |
| */ | |
| public onCreate(): Promise<VirtualFileSystem> { | |
| return new Promise(resolve => { | |
| // Create layout | |
| const request = indexedDB.open(this.getName(), 1); | |
| request.onupgradeneeded = (e): void => { | |
| this._handle = (e.target as any).result || null; | |
| const LAYOUT_NAMES = ["worlds"]; | |
| for (let ii = 0; ii < LAYOUT_NAMES.length; ++ii) { | |
| const layoutName = LAYOUT_NAMES[ii]; | |
| // Create layout | |
| const layout = this._handle.createObjectStore(layoutName, {keyPath: "id", autoIncrement: true}); | |
| layout.createIndex("name", "name", {unique: false}); | |
| layout.createIndex("data", "data", {unique: false}); | |
| layout.createIndex("sort", "sort", {unique: false}); | |
| layout.createIndex("category", "category", {unique: false}); | |
| } | |
| }; | |
| request.onerror = (e): void => { | |
| console.error(e); | |
| }; | |
| request.onsuccess = (): void => { | |
| resolve(this); | |
| }; | |
| }); | |
| } | |
| /** | |
| * Delete all data in the fs | |
| */ | |
| public onDelete(): void { | |
| indexedDB.deleteDatabase(this.getName()); | |
| } | |
| /** | |
| * Insert a new entry into the database | |
| * @param table - The name of the table to insert into | |
| * @param name - The name of the entry to insert | |
| * @param data - The data to insert | |
| */ | |
| public insert(table: string, name: string, data: Uint8Array): Promise<number> { | |
| return new Promise(resolve => { | |
| const request = indexedDB.open(this.getName()); | |
| request.onsuccess = (e): void => { | |
| const handle = (e.target as any).result as IDBDatabase; | |
| const tx = handle.transaction(table, "readwrite").objectStore(table); | |
| const entry: any = {name, data: new Blob([data]), sort: -1, category: ""}; | |
| const query = tx.add(entry); | |
| query.onsuccess = (e): void => { | |
| const target = e.target as any; | |
| if (target && Number.isInteger(target.result)) { | |
| // The auto increment id of the entry we inserted | |
| const id = target.result; | |
| // Sort id is currently -1, update it | |
| const request = indexedDB.open(this.getName()); | |
| request.onsuccess = (e): void => { | |
| const handle = (e.target as any).result as IDBDatabase; | |
| const tx = handle.transaction(table, "readwrite").objectStore(table); | |
| // Update sort id | |
| entry.id = id; | |
| entry.sort = id; | |
| const query = tx.put(entry); | |
| query.onsuccess = (e): void => { | |
| const target = e.target as any; | |
| if (target && Number.isInteger(target.result)) { | |
| resolve(id); | |
| } else { | |
| resolve(-1); | |
| } | |
| }; | |
| query.onerror = (): void => { | |
| resolve(-1); | |
| }; | |
| }; | |
| } else { | |
| resolve(-1); | |
| } | |
| }; | |
| query.onerror = (): void => { | |
| resolve(-1); | |
| }; | |
| }; | |
| }); | |
| } | |
| /** | |
| * Moves an existing entry within the database | |
| * @param table - The name of the table to insert at | |
| * @param updateKeys - The new keys to use | |
| */ | |
| public updateKeys(table: string, updateKeys: number[]): Promise<boolean> { | |
| return new Promise(resolve => { | |
| this.readKeys(table, false).then(currentKeys => { | |
| (async(): Promise<void> => { | |
| for (let ii = 0; ii < currentKeys.length; ++ii) { | |
| const currentKey = currentKeys[ii].id; | |
| const updateKey = updateKeys[ii]; | |
| const isSuccess = await this.updateSortAtId(table, currentKey, updateKey); | |
| // Abort if failed | |
| if (!isSuccess) { | |
| resolve(false); | |
| return; | |
| } | |
| } | |
| resolve(true); | |
| })(); | |
| }); | |
| }); | |
| } | |
| /** | |
| * Write a new entry into the database | |
| * @param table - The name of the table to write into | |
| * @param id - The id of the entry to write | |
| * @param name - The name of the entry to write | |
| * @param data - The data to write | |
| * @param sort - An optional sort id to write | |
| */ | |
| public writeAtId(table: string, id: number, name: string, data: Uint8Array, sort: number = -1): Promise<number> { | |
| return new Promise(resolve => { | |
| const request = indexedDB.open(this.getName()); | |
| request.onsuccess = (e): void => { | |
| const handle = (e.target as any).result as IDBDatabase; | |
| const tx = handle.transaction(table, "readwrite").objectStore(table); | |
| const blob = new Blob([data]); | |
| const query = tx.put({id, name, data: blob, sort: sort !== -1 ? sort : id, category: ""}); | |
| query.onsuccess = (e): void => { | |
| const target = e.target as any; | |
| if (target && Number.isInteger(target.result)) { | |
| const id = target.result; | |
| resolve(id); | |
| } else { | |
| resolve(-1); | |
| } | |
| }; | |
| query.onerror = (): void => { | |
| resolve(-1); | |
| }; | |
| }; | |
| }); | |
| } | |
| /** | |
| * Read an entry from the database, based on the provided id | |
| * @param table - The name of the table to read from | |
| * @param id - The id of the entry to read | |
| * @param raw - Instead of parsing the data, returns the raw data of the entry | |
| */ | |
| public readById(table: string, id: number, raw: boolean = false): Promise<IBinaryDataEntry> { | |
| return new Promise(resolve => { | |
| const request = indexedDB.open(this.getName()); | |
| request.onsuccess = (e): void => { | |
| const handle = (e.target as any).result as IDBDatabase; | |
| const tx = handle.transaction(table, "readonly").objectStore(table); | |
| const query = tx.get(id); | |
| query.onsuccess = (e): void => { | |
| const target = e.target as any; | |
| if (target && target.result && (target.result.data instanceof Blob)) { | |
| // Raw data | |
| if (raw) { | |
| resolve(target.result); | |
| } | |
| // Parse the data | |
| else { | |
| target.result.data.arrayBuffer().then((buffer: ArrayBuffer) => { | |
| resolve({ | |
| name: target.result.name, | |
| sort: target.result.sort, | |
| data: new Uint8Array(buffer), | |
| }); | |
| }); | |
| } | |
| } else { | |
| resolve(null); | |
| } | |
| }; | |
| query.onerror = (): void => { | |
| resolve(null); | |
| }; | |
| }; | |
| }); | |
| } | |
| /** | |
| * Read all entries within the table from the database | |
| * @param table - The table to read from | |
| * @param stored - Optionally sort the results by their sort id | |
| */ | |
| public readAll(table: string, sorted: boolean = false): Promise<IBinaryDataEntry[]> { | |
| return new Promise(resolve => { | |
| this.readKeys(table, sorted).then(async(keys): Promise<void> => { | |
| const out: IBinaryDataEntry[] = []; | |
| for (let ii = 0; ii < keys.length; ++ii) { | |
| out.push(await this.readById(table, keys[ii].id)); | |
| } | |
| resolve(out); | |
| }); | |
| }); | |
| } | |
| /** | |
| * Read all entries with the given name within the table from the database | |
| * @param table - The table to read from | |
| * @param name - The name to query | |
| * @param stored - Optionally sort the results by their sort id | |
| */ | |
| public readByName(table: string, name: string, sorted: boolean = false): Promise<IBinaryDataEntry[]> { | |
| return new Promise(resolve => { | |
| this.readKeys(table, sorted).then(async(entries): Promise<void> => { | |
| const matches = []; | |
| for (let ii = 0; ii < entries.length; ++ii) { | |
| const entry = entries[ii]; | |
| if (entry.name === name) { | |
| const data = await this.readById(table, entry.id); | |
| matches.push(data); | |
| } | |
| } | |
| resolve(matches); | |
| }); | |
| }); | |
| } | |
| /** | |
| * Delete all entries within the table from the database | |
| * @param table - The table to delete at | |
| */ | |
| public deleteAll(table: string): Promise<void> { | |
| return new Promise(resolve => { | |
| const request = indexedDB.open(this.getName()); | |
| request.onsuccess = (e): void => { | |
| const handle = (e.target as any).result as IDBDatabase; | |
| const tx = handle.transaction(table, "readwrite").objectStore(table); | |
| const query = tx.clear(); | |
| query.onsuccess = (): void => { | |
| resolve(); | |
| }; | |
| query.onerror = (): void => { | |
| resolve(); | |
| }; | |
| }; | |
| }); | |
| } | |
| /** | |
| * Returns a list of all keys within a table from the database | |
| * @param table - The table to read the keys from | |
| */ | |
| public readCount(table: string): Promise<number> { | |
| return new Promise(resolve => { | |
| const request = indexedDB.open(this.getName()); | |
| request.onsuccess = (e): void => { | |
| const handle = (e.target as any).result as IDBDatabase; | |
| const tx = handle.transaction(table, "readonly").objectStore(table); | |
| const query = tx.count(); | |
| query.onsuccess = (e): void => { | |
| const target = e.target as any; | |
| resolve(target.result); | |
| }; | |
| query.onerror = (): void => { | |
| resolve(-1); | |
| }; | |
| }; | |
| }); | |
| } | |
| /** | |
| * Delete an entry from the database, based on the provided id | |
| * @param table - The name of the table to delete from | |
| * @param id - The id of the entry to delete | |
| */ | |
| public deleteById(table: string, id: number): Promise<boolean> { | |
| return new Promise(resolve => { | |
| const request = indexedDB.open(this.getName()); | |
| request.onsuccess = (e): void => { | |
| const handle = (e.target as any).result as IDBDatabase; | |
| const tx = handle.transaction(table, "readwrite").objectStore(table); | |
| const query = tx.delete(id); | |
| query.onsuccess = (): void => { | |
| resolve(true); | |
| }; | |
| query.onerror = (): void => { | |
| resolve(false); | |
| }; | |
| }; | |
| }); | |
| } | |
| /** | |
| * Returns a list of all keys within a table from the database | |
| * @param table - The table to read the keys from | |
| * @param stored - Optionally sort the keys by their sort id | |
| */ | |
| public readKeys(table: string, sorted: boolean = false): Promise<IKeyEntry[]> { | |
| return new Promise(resolve => { | |
| const request = indexedDB.open(this.getName()); | |
| request.onsuccess = (e): void => { | |
| const handle = (e.target as any).result as IDBDatabase; | |
| const tx = handle.transaction(table, "readonly").objectStore(table); | |
| const query = tx.getAll(); | |
| query.onsuccess = (e): void => { | |
| const target = e.target as any; | |
| if (target.result) { | |
| const data = target.result as IKeyEntry[]; | |
| if (sorted) { | |
| const sorts = data.map(v => v.sort); | |
| const ids = data.map(v => v.id); | |
| const sortedIds = ids.sort((a: number, b: number) => sorts.indexOf(a) - sorts.indexOf(b)); | |
| const sorted = data.map((_, index) => data.find(v => v.id === sortedIds[index])); | |
| resolve(sorted); | |
| } else { | |
| resolve(data); | |
| } | |
| } else { | |
| resolve([]); | |
| } | |
| }; | |
| query.onerror = (): void => { | |
| resolve([]); | |
| }; | |
| }; | |
| }); | |
| } | |
| /** | |
| * Update the sort id of an existing entry in the database | |
| * @param table - The name of the table to update in | |
| * @param id - The id of the entry to update | |
| * @param sort - The sort id of the entry to write | |
| */ | |
| public updateSortAtId(table: string, id: number, sort: number): Promise<boolean> { | |
| return new Promise(resolve => { | |
| const request = indexedDB.open(this.getName()); | |
| request.onsuccess = (e): void => { | |
| const handle = (e.target as any).result as IDBDatabase; | |
| const tx = handle.transaction(table, "readonly").objectStore(table); | |
| const query = tx.get(id); | |
| query.onsuccess = (e): void => { | |
| const target = e.target as any; | |
| if (target && target.result) { | |
| const current = target.result; | |
| const request = indexedDB.open(this.getName()); | |
| request.onsuccess = (e): void => { | |
| const handle = (e.target as any).result as IDBDatabase; | |
| const tx = handle.transaction(table, "readwrite").objectStore(table); | |
| // Update sort field with new value | |
| current.sort = sort; | |
| // Write new data | |
| const query = tx.put(current); | |
| query.onsuccess = (e): void => { | |
| const target = e.target as any; | |
| if (target && Number.isInteger(target.result)) { | |
| resolve(true); | |
| } else { | |
| resolve(false); | |
| } | |
| }; | |
| query.onerror = (): void => { | |
| resolve(false); | |
| }; | |
| }; | |
| } else { | |
| resolve(false); | |
| } | |
| }; | |
| query.onerror = (): void => { | |
| resolve(false); | |
| }; | |
| }; | |
| }); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment