fsdb/indexers/symlinks.ts
Andy Burke 05178c924f refactor: make all() take filter and sort options
refactor: return unload item entries from all()/find()
2025-07-02 17:46:04 -07:00

207 lines
6.2 KiB
TypeScript

import * as fs from '@std/fs';
import { FSDB_INDEXER, FSDB_SEARCH_OPTIONS } from '../fsdb.ts';
import * as path from '@std/path';
import sanitize from '../utils/sanitize.ts';
interface FSDB_INDEXER_SYMLINKS_CONFIG_SHARED {
name: string;
root?: string;
id_field?: string;
to_many?: boolean;
organize?: (value: string) => string[];
}
interface FSDB_INDEXER_SYMLINKS_CONFIG_WITH_FIELD extends FSDB_INDEXER_SYMLINKS_CONFIG_SHARED {
field: string;
get_values_to_index?: never;
}
interface FSDB_INDEXER_SYMLINKS_CONFIG_WITH_GET_VALUE<T> extends FSDB_INDEXER_SYMLINKS_CONFIG_SHARED {
field?: never;
get_values_to_index: (item: T) => string[];
}
export type FSDB_INDEXER_SYMLINKS_CONFIG<T> = FSDB_INDEXER_SYMLINKS_CONFIG_WITH_FIELD | FSDB_INDEXER_SYMLINKS_CONFIG_WITH_GET_VALUE<T>;
async function cleanup_empty_directories(initial_path: string): Promise<string[]> {
let current_directory: string = path.dirname(path.resolve(initial_path));
let has_children: boolean = false;
const removed: string[] = [];
do {
for await (const _dir_entry of Deno.readDir(current_directory)) {
has_children = true;
break;
}
if (!has_children) {
const removed_dir = current_directory;
await Deno.remove(removed_dir);
current_directory = path.dirname(removed_dir);
removed.push(removed_dir);
if (current_directory === Deno.cwd()) {
break;
}
}
} while (!has_children && current_directory.length);
return removed;
}
export class FSDB_INDEXER_SYMLINKS<T> implements FSDB_INDEXER<T> {
constructor(private config: FSDB_INDEXER_SYMLINKS_CONFIG<T>) {
this.config.id_field = this.config.id_field ?? 'id';
}
public set_fsdb_root(root: string) {
this.config.root = this.config.root ?? path.resolve(path.join(root, '.indexes/', sanitize(this.config.name)));
}
private get_values_to_index(item: any): string[] {
if (this.config.get_values_to_index) {
return this.config.get_values_to_index(item);
}
const typed_field: any = this.config.field as keyof T;
const value: string = item[typed_field];
return [value];
}
async lookup(value: string, options?: FSDB_SEARCH_OPTIONS<T>): Promise<string[]> {
if (typeof this.config.root !== 'string') {
throw new Error('root should have been set by FSDB instance');
}
if (typeof this.config.organize === 'undefined') {
throw new Error('symlink indexer must have an organizer set!');
}
const results: string[] = [];
const organized_paths: string[] = this.config.organize(value);
if (organized_paths.length === 0) {
return results;
}
if (this.config.to_many) {
const filename: string = organized_paths.pop() ?? ''; // remove filename
const parsed_filename = path.parse(filename);
organized_paths.push(parsed_filename.name); // add back filename without extension for a directory
organized_paths.push('*'); // wildcard to get all references
}
const limit = options?.limit ?? 100;
const offset = options?.offset ?? 0;
let counter = 0;
const glob_pattern = path.resolve(path.join(this.config.root, ...organized_paths));
for await (const item_file of fs.expandGlob(glob_pattern)) {
const file_info: Deno.FileInfo = await Deno.lstat(item_file.path);
if (file_info.isSymlink) {
if (counter < offset) {
++counter;
continue;
}
const resolved_item_path = await Deno.readLink(item_file.path);
results.push(resolved_item_path);
++counter;
}
if (counter >= (offset + limit)) {
break;
}
}
return results;
}
async index(item: T, authoritative_path: string): Promise<string[]> {
if (typeof this.config.root !== 'string') {
throw new Error('root should have been set by FSDB instance');
}
if (typeof this.config.organize === 'undefined') {
throw new Error('symlink indexer must have an organizer set!');
}
const results: string[] = [];
const values: string[] = this.get_values_to_index(item);
if (values.length === 0) {
return results;
}
for (const value of values) {
const organized_paths: string[] = this.config.organize(value);
if (organized_paths.length === 0) {
continue;
}
if (this.config.to_many) {
const filename: string = organized_paths.pop() ?? ''; // remove filename
const parsed_filename = path.parse(filename);
organized_paths.push(parsed_filename.name); // add back filename without extension for a directory
const item_id: string = item[this.config.id_field as keyof T] as string;
if (typeof item_id !== 'string') {
continue;
}
organized_paths.push(`${item_id}.json`);
}
const symlink_path = path.resolve(path.join(this.config.root, ...organized_paths));
const item_dir = path.dirname(authoritative_path);
const reverse_link_path = path.join(item_dir, `.index.symlink.${this.config.name}.${sanitize(value)}`);
// clean up old indexes
try {
const previous_symlink_index_link_path = await Deno.readLink(reverse_link_path);
await Deno.remove(previous_symlink_index_link_path);
if (this.config.to_many) {
await cleanup_empty_directories(previous_symlink_index_link_path);
}
await Deno.remove(reverse_link_path);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) {
throw error;
}
}
// create index symlink and reverse link
await Deno.mkdir(path.dirname(symlink_path), { recursive: true });
await fs.ensureSymlink(authoritative_path, symlink_path);
await fs.ensureSymlink(symlink_path, reverse_link_path);
results.push(symlink_path);
}
return results;
}
async remove(item: T, item_path: string): Promise<string[]> {
const results: string[] = [];
const values: string[] = this.get_values_to_index(item);
const item_dir: string = path.dirname(item_path);
for (const value of values) {
const item_dir_reverse_link: string = path.join(item_dir, `.index.symlink.${this.config.name}.${sanitize(value)}`);
if (!fs.existsSync(item_dir_reverse_link)) {
continue;
}
const index_symlink_path: string = await Deno.readLink(item_dir_reverse_link);
if (fs.existsSync(index_symlink_path)) {
await Deno.remove(index_symlink_path);
results.push(index_symlink_path);
}
await Deno.remove(item_dir_reverse_link);
results.push(...await cleanup_empty_directories(index_symlink_path));
}
return results;
}
}