/** * @module storage/storageFactory * @description Storage adapter factory for Brainy 8.0. * * Brainy 8.0 ships **two storage adapters**: * - `'filesystem'` (default) — Node.js + Bun + Deno; persistent on disk. * - `'memory'` — in-memory; ephemeral; the right choice for tests + ephemeral * workloads. * * Cloud-storage adapters (GCS, S3, R2, Azure) and the browser-only OPFS * adapter were removed in 8.0. The path * forward for cloud backup is operator tooling: persist locally with * `db.persist()`, sync the resulting on-disk artefact with `gsutil` / * `aws s3 cp` / `rclone` / `azcopy`. This is the standard pattern every * production database uses; bundling cloud SDKs into the library buys * nothing and ships ~13 K LOC of code we don't maintain well. */ import type { StorageAdapter } from '../coreTypes.js' import { MemoryStorage } from './adapters/memoryStorage.js' import type { OperationConfig } from '../utils/operationUtils.js' /** * Options for creating a storage adapter (Brainy 8.0). */ export interface StorageOptions { /** * Storage backend to use. * - `'auto'` (default) — `'filesystem'` on Node-like runtimes, `'memory'` * in a browser. * - `'memory'` — in-memory; ephemeral. * - `'filesystem'` — persistent disk storage; Node-like runtimes only. * * A top-level `path` implies `'filesystem'`, so `{ path: '/data' }` works * without an explicit `type`. */ type?: 'auto' | 'memory' | 'filesystem' /** Force memory storage regardless of environment. */ forceMemoryStorage?: boolean /** Force filesystem storage. Throws in a browser environment. */ forceFileSystemStorage?: boolean /** * **Canonical** directory for filesystem storage. This is the one key the * rest of the API already speaks (`persist(path)`, `Brainy.load(path)`, * `asOf(path)`, `restore(path)`). Specifying it implies `type: 'filesystem'`. * * @example * new Brainy({ storage: { path: '/var/lib/app/data' } }) */ path?: string /** * @deprecated REMOVED in 8.0 — passing `rootDirectory` THROWS. Use the * canonical {@link StorageOptions.path} (`storage: { path: '/data' }`). */ rootDirectory?: string /** * @deprecated REMOVED in 8.0 — a nested `options.path` / `options.rootDirectory` * THROWS. Use the top-level {@link StorageOptions.path}. */ options?: { rootDirectory?: string path?: string [key: string]: any } /** * @deprecated REMOVED in 8.0 — `fileSystemStorage.path` / `.rootDirectory` * THROWS. Use the top-level {@link StorageOptions.path}. */ fileSystemStorage?: { rootDirectory?: string path?: string [key: string]: any } /** Operation tuning (timeouts, retry budgets) passed through to the adapter. */ operationConfig?: OperationConfig } /** Default on-disk root when a filesystem store is requested without a path. */ export const DEFAULT_FILESYSTEM_ROOT = './brainy-data' /** * @description Throw a clear migration error for a removed legacy storage-config * key. 8.0 is a clean break: the pre-8.0 aliases were REMOVED (not deprecated), * so a stale config fails loudly with the exact rename rather than silently * writing to the wrong directory. * @param key - The removed key path (e.g. `'rootDirectory'`, `'options.path'`). * @throws Always — naming the canonical `path` replacement. */ function throwRemovedStorageKey(key: string): never { throw new Error( `[brainy] storage config '${key}' was removed in 8.0 — use the top-level ` + `'path' instead (e.g. storage: { path: '/data' }).` ) } /** * @description Resolve any supported filesystem storage config shape to the ONE * canonical on-disk root, encoding the full precedence in a single place so the * factory, the 7.x→8.0 migration probe, and any plugin storage factory all agree * on the IDENTICAL directory (no `./brainy-data` split-brain). * * Behavior: * 1. `path` — the one supported key. Returned as-is. * 2. A removed pre-8.0 alias (`rootDirectory`, `options.*`, `fileSystemStorage.*`) → * THROW with the exact rename (never silently fall through to the default, * which would misplace a 7.x consumer's data on upgrade). * 3. No path at all → `./brainy-data` (the zero-config default). * * @param config - A storage config object (`StorageOptions`-shaped; tolerant of * extra keys for the plugin-factory `Record` contract). * @returns The resolved directory string. * @throws If a removed pre-8.0 alias is present (message names the `path` rename). * @example * resolveFilesystemRoot({ path: '/data' }) // → '/data' * resolveFilesystemRoot({ rootDirectory: '/data' }) // → throws (use `path`) * resolveFilesystemRoot({ type: 'filesystem' }) // → './brainy-data' */ export function resolveFilesystemRoot( config: StorageOptions & Record = {} ): string { // 1. Canonical top-level path — the one and only supported key. if (typeof config.path === 'string' && config.path.length > 0) { return config.path } // 2. Removed pre-8.0 aliases → throw with the exact rename. Detect them even // though they're no longer the path, so a stale config fails loudly // instead of silently landing on the default and misplacing data. if (typeof config.rootDirectory === 'string' && config.rootDirectory.length > 0) { throwRemovedStorageKey('rootDirectory') } const opts = config.options if ( opts && typeof opts === 'object' && ((typeof opts.path === 'string' && opts.path.length > 0) || (typeof opts.rootDirectory === 'string' && opts.rootDirectory.length > 0)) ) { throwRemovedStorageKey('options.path') } const fss = config.fileSystemStorage if ( fss && typeof fss === 'object' && ((typeof fss.path === 'string' && fss.path.length > 0) || (typeof fss.rootDirectory === 'string' && fss.rootDirectory.length > 0)) ) { throwRemovedStorageKey('fileSystemStorage.path') } // 3. Zero-config default. A `type: 'filesystem'` with no path lands here // intentionally ("persist, default location"). return DEFAULT_FILESYSTEM_ROOT } /** * @description Whether a storage config (no explicit/`'auto'` type) names a * filesystem directory through ANY supported shape. Used by `pickAdapter` so * `{ path: '/data' }` (or a deprecated alias) implies filesystem on a Node * runtime without the caller writing `type: 'filesystem'`. * @param config - A storage config object. * @returns `true` if a directory was specified through any recognized key. */ function hasExplicitFilesystemPath( config: StorageOptions & Record ): boolean { const nonEmpty = (v: unknown): boolean => typeof v === 'string' && v.length > 0 return ( nonEmpty(config.path) || nonEmpty(config.rootDirectory) || nonEmpty(config.options?.path) || nonEmpty(config.options?.rootDirectory) || nonEmpty(config.fileSystemStorage?.path) || nonEmpty(config.fileSystemStorage?.rootDirectory) ) } /** * Resolve `StorageOptions` to a concrete storage adapter. * * - `'auto'` (default) picks `FileSystemStorage`, falling back to * `MemoryStorage` only if filesystem init fails (e.g. no write permission). * - `forceMemoryStorage: true` returns `MemoryStorage` regardless. * - `forceFileSystemStorage: true` returns `FileSystemStorage`. */ export async function createStorage(options: StorageOptions = {}): Promise { return pickAdapter(options) } async function pickAdapter(options: StorageOptions): Promise { if (options.forceMemoryStorage) { return new MemoryStorage() } const requestedType = options.type ?? 'auto' if (requestedType === 'memory') { return new MemoryStorage() } if (requestedType === 'filesystem' || options.forceFileSystemStorage) { return await createFilesystemStorage(options) } // 'auto' (or no type) with an explicit filesystem path → filesystem. A naked // `{ path: '/data' }` should land on disk at that path, not silently fall to // memory on a runtime where the `auto` filesystem attempt happens to fail. if ( requestedType === 'auto' && hasExplicitFilesystemPath(options as StorageOptions & Record) ) { return await createFilesystemStorage(options) } // 'auto' with no path: prefer filesystem, fall back to memory if init fails. try { return await createFilesystemStorage(options) } catch { return new MemoryStorage() } } async function createFilesystemStorage(options: StorageOptions): Promise { // Single source of truth for the on-disk root across every config shape. const rootDir = resolveFilesystemRoot(options as StorageOptions & Record) // Dynamic import so browser bundles don't pull node:fs. const { FileSystemStorage } = await import('./adapters/fileSystemStorage.js') return new FileSystemStorage(rootDir) as unknown as StorageAdapter } // Re-export the surviving adapters for direct construction by callers // that want to skip the factory. export { MemoryStorage } from './adapters/memoryStorage.js'