The factory loudly rejects every REMOVED pre-8.0 path key, but an unknown
nested `config` object (e.g. `storage: { config: { baseDir } }` — a shape
that was never supported) fell through SILENTLY to the zero-config default
directory. Every instance constructed with such a shape wrote to ONE shared
on-disk root while its caller believed each had its own — found live when
two integration tests' brains shared a store across an entire
single-process CI run and a health probe refused on the foreign edges it
sampled. A nested `config` carrying any path-shaped key now throws the same
loud migration error, naming the canonical `path` rename. The two tests are
repaired to the supported shape (and now actually test isolated stores, for
the first time since 8.0).
256 lines
9.8 KiB
TypeScript
256 lines
9.8 KiB
TypeScript
/**
|
|
* @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<string, unknown>` 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, unknown> = {}
|
|
): 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')
|
|
}
|
|
// A nested `config` object carrying a path-shaped key is the same hazard in
|
|
// a shape nobody ever supported: it used to fall through SILENTLY to the
|
|
// shared default root — every instance writing one directory while its
|
|
// caller believed each had its own. (Found live: an integration test's
|
|
// brains shared one store across a whole single-process run and a health
|
|
// probe refused on the foreign edges it sampled.) Loud, with the rename.
|
|
const nested = (config as Record<string, unknown>).config
|
|
if (nested && typeof nested === 'object') {
|
|
const pathish = ['path', 'baseDir', 'rootDirectory', 'rootDir', 'dir', 'directory']
|
|
const hit = pathish.find(
|
|
(k) => typeof (nested as Record<string, unknown>)[k] === 'string' &&
|
|
((nested as Record<string, unknown>)[k] as string).length > 0
|
|
)
|
|
if (hit) throwRemovedStorageKey(`config.${hit}`)
|
|
}
|
|
|
|
// 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<string, unknown>
|
|
): 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<StorageAdapter> {
|
|
return pickAdapter(options)
|
|
}
|
|
|
|
async function pickAdapter(options: StorageOptions): Promise<StorageAdapter> {
|
|
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<string, unknown>)
|
|
) {
|
|
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<StorageAdapter> {
|
|
// Single source of truth for the on-disk root across every config shape.
|
|
const rootDir = resolveFilesystemRoot(options as StorageOptions & Record<string, unknown>)
|
|
|
|
// 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'
|