fsdb/fsdb.ts

255 lines
7.3 KiB
TypeScript

import * as fs from '@std/fs';
import * as path from '@std/path';
import by_lurid from './organizers/by_lurid.ts';
import { Optional } from './utils/optional.ts';
export type FSDB_COLLECTION_CONFIG = {
name: string;
id_field: string;
indexers?: Record<string, FSDB_INDEXER<any>>;
organize: (id: string) => string[];
root: string;
};
export type FSDB_COLLECTION_CONFIG_INPUT = Optional<FSDB_COLLECTION_CONFIG, 'id_field' | 'organize' | 'root'>;
export type FSDB_SEARCH_OPTIONS = {
limit: number;
offset?: number;
};
export interface FSDB_INDEXER<T> {
set_fsdb_root(root: string): void;
index(item: T, authoritative_path: string): Promise<string[]>;
remove(item: T, authoritative_path: string): Promise<string[]>;
lookup(value: string, options?: FSDB_SEARCH_OPTIONS): Promise<string[]>;
}
export class FSDB_COLLECTION<T extends Record<string, any>> {
private config: FSDB_COLLECTION_CONFIG;
public INDEX: Record<string, FSDB_INDEXER<any>>;
constructor(input_config: FSDB_COLLECTION_CONFIG_INPUT) {
this.config = {
...{
id_field: 'id',
organize: by_lurid,
root: `./.fsdb/${input_config?.name ?? 'unknown'}`
},
...(input_config ?? {})
};
this.INDEX = this.config.indexers ?? {};
for (const indexer of Object.values(this.INDEX)) {
indexer.set_fsdb_root(this.config.root);
}
let existing_collection_info: any = undefined;
try {
const existing_collection_info_content: string = Deno.readTextFileSync(
path.resolve(path.join(this.config.root), '.fsdb.collection.json')
);
existing_collection_info = JSON.parse(existing_collection_info_content);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) {
throw error;
}
}
if (existing_collection_info) {
if (this.config.name !== existing_collection_info.name) {
console.warn('Mismatching collection name, maybe the collection was renamed? Be cautious.');
}
if (this.config.root !== existing_collection_info.root) {
console.warn('Mismatching collection root, maybe the collection was moved on disk? Be cautious.');
}
if (this.config.id_field !== existing_collection_info.id_field) {
console.warn('Mismatching collection id field, maybe the data format has changed? Be cautious.');
}
if (
Object.keys(this.config.indexers ?? {}).sort().join('|') !==
Object.keys(existing_collection_info.indexers ?? {}).sort().join('|')
) {
console.warn('Mismatching collection indexes, maybe the code was updated to add or drop an index? Be cautious.');
}
}
const collection_info_file_path: string = path.resolve(path.join(this.config.root, '.fsdb.collection.json'));
const collection_info_json: string = JSON.stringify(this.config, null, 4);
Deno.mkdirSync(path.dirname(collection_info_file_path), {
recursive: true
});
Deno.writeTextFileSync(collection_info_file_path, collection_info_json);
}
public get_organized_item_path(item: any, id_field?: string): string {
const id: string = item[id_field ?? this.config.id_field];
const path_elements: string[] = this.config.organize(id);
const resolved_item_path = path.resolve(path.join(this.config.root, ...path_elements));
return resolved_item_path;
}
public get_organized_id_path(id: string): string {
return this.get_organized_item_path({ id }, 'id');
}
private async ensure_item_path(item: any, id_field?: string): Promise<string> {
const organized_item_path: string = this.get_organized_item_path(item, id_field);
const organized_item_dir: string = path.dirname(organized_item_path);
await fs.ensureDir(organized_item_dir);
return organized_item_path;
}
private async write_item(item: T, override_path?: string): Promise<void> {
const item_path: string = override_path ?? await this.ensure_item_path(item, this.config.id_field);
Deno.writeTextFileSync(item_path, JSON.stringify(item, null, 1));
if (this.config.indexers) {
for (const indexer of Object.values(this.config.indexers)) {
await (indexer as FSDB_INDEXER<T>).index(item, item_path);
}
}
}
async get(id: string): Promise<T | null> {
const id_path: string = this.get_organized_id_path(id);
const item_exists: boolean = await fs.exists(id_path);
if (!item_exists) {
return null;
}
const content: string = await Deno.readTextFile(id_path);
return JSON.parse(content);
}
async create(item: T): Promise<T> {
const item_path: string = this.get_organized_item_path(item);
const item_exists: boolean = await fs.exists(item_path);
if (item_exists) {
throw new Error('item already exists', {
cause: 'item_exists'
});
}
await this.write_item(item);
return item;
}
async update(item: T): Promise<T> {
const item_path: string = this.get_organized_item_path(item);
const item_exists: boolean = await fs.exists(item_path);
if (!item_exists) {
throw new Error('item does not exist', {
cause: 'item_does_not_exist'
});
}
await this.write_item(item, item_path);
return item;
}
async delete(item: T): Promise<T | null> {
const item_path = this.get_organized_item_path(item);
const item_exists = await fs.exists(item_path);
if (!item_exists) {
return null;
}
if (this.config.indexers) {
for (const indexer of Object.values(this.config.indexers)) {
await (indexer as FSDB_INDEXER<any>).remove(item, item_path);
}
}
await Deno.remove(item_path);
let dir = path.dirname(item_path);
do {
const files = Deno.readDirSync(dir);
let has_files = false;
for (const _ of files) {
has_files = true;
break;
}
if (has_files) {
dir = '';
break;
}
await Deno.remove(dir);
dir = path.dirname(dir);
} while (dir.length);
return item;
}
async find(criteria: Record<string, any>, input_options?: FSDB_SEARCH_OPTIONS): Promise<T[]> {
if (Deno.env.get('FSDB_PERF')) performance.mark('fsdb_find_begin');
const options: FSDB_SEARCH_OPTIONS = {
...{
limit: 100,
offset: 0
},
...(input_options ?? {})
};
const results: T[] = [];
const item_paths: string[] = [];
// for each key in the search
// see if we have an index for it
// if we have an index, use that index to put any search path right at the beginning of the list
//
// once we have a list of items to search
// apply offset
// for each item, load it
// let matched = false;
// for each key in search
// if the item matches this key/value, matched = true; break;
// if limit reached, break;
//
// return matched items
for (const search_key of Object.keys(criteria)) {
const indexer_for_search_key: FSDB_INDEXER<T> | undefined = this.INDEX[search_key];
const value: string = criteria[search_key];
if (indexer_for_search_key) {
item_paths.push(...await indexer_for_search_key.lookup(value, input_options));
}
}
const limit = options?.limit ?? 100;
const offset = options?.offset ?? 0;
let counter = 0;
for await (const item_path of item_paths) {
if (counter < offset) {
++counter;
continue;
}
const content = await Deno.readTextFile(item_path);
results.push(JSON.parse(content));
++counter;
if (counter >= (offset + limit)) {
break;
}
}
if (Deno.env.get('FSDB_PERF')) performance.mark('fsdb_find_end');
if (Deno.env.get('FSDB_PERF')) console.dir(performance.measure('fsdb find time', 'fsdb_find_begin', 'fsdb_find_end'));
return results;
}
}