first commit
This commit is contained in:
60
docs/persistence/README.md
Normal file
60
docs/persistence/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# `persistence`
|
||||
|
||||
Caching, database adapters, search engines, and storage abstractions.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Subcategories
|
||||
|
||||
4 subcategories, 23 packages.
|
||||
|
||||
### `cache`
|
||||
|
||||
Caching abstractions, memory caches, and Redis backends.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cache`](./cache/README.md) | Type-safe caching abstraction for Suaveplan. Provides `MemoryCache` with LRU/LFU/FIFO/TTL eviction, `NamespacedCache` for shared-storage isolation, a `CacheFac… |
|
||||
| [`@suaveplan/cache-redis`](./cache-redis/README.md) | `AbstractCache<T>` backend over a minimal `RedisLike` interface — native `SET ... EX`, `GET`, `DEL`, `EXISTS`, `TTL`, `SCAN`. Reuses `@suaveplan/cache-storage`… |
|
||||
| [`@suaveplan/cache-storage`](./cache-storage/README.md) | `AbstractCache<T>` backend that bridges any `@suaveplan/storage` `AbstractStorage` into a typed cache via a 32-byte fixed-width TTL header and pluggable `Codec… |
|
||||
|
||||
### `database`
|
||||
|
||||
Database adapters, migrations, seeding, and SQLite support.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/db`](./db/README.md) | Universal relational database adapter interface for Suaveplan. Provides the `DatabaseAdapter` contract (extending `DatabaseLike` from `@suaveplan/types`), inje… |
|
||||
| [`@suaveplan/db-sqlite`](./db-sqlite/README.md) | Universal SQLite adapter for Suaveplan. Runs on `bun:sqlite` on the Bun server and `@sqlite.org/sqlite-wasm` (Web Worker) in the browser, unified behind a sing… |
|
||||
| [`@suaveplan/migration`](./migration/README.md) | Database-agnostic schema migration runner with ledger tracking, checksum validation, dry-run reporting, distributed locking, event bus integration, and OTel in… |
|
||||
| [`@suaveplan/seed`](./seed/README.md) | Universal declarative database seeding and fixture framework. Define seed data once, let the runner handle dependency ordering, environment filtering, idempote… |
|
||||
| [`@suaveplan/surrealdb`](./surrealdb/README.md) | Universal SurrealDB client wrapper for Suaveplan. Implements `@suaveplan/db`'s `DatabaseAdapter` interface with connection lifecycle management, typed CRUD, sc… |
|
||||
|
||||
### `search`
|
||||
|
||||
Full-text search, BM25, fuzzy matching, and faceted search.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/search`](./search/README.md) | Full-text and fuzzy search primitives with in-memory BM25 engine, trigram fuzzy matching, faceted search, and pagination support. |
|
||||
|
||||
### `storage`
|
||||
|
||||
Storage abstractions, browser backends, S3, CDN, and URI routing.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/storage`](./storage/README.md) | Enterprise key-value storage abstractions for Suaveplan. Provides `AbstractStorage` (implementing `StorageLike`), `MemoryStorage` with TTL and zero-copy stream… |
|
||||
| [`@suaveplan/storage-browser-indexeddb`](./storage-browser-indexeddb/README.md) | Browser-tier `AbstractStorage` adapter backed by the W3C IndexedDB API (`globalThis.indexedDB`). Provides persistent, origin-scoped key-value storage for brows… |
|
||||
| [`@suaveplan/storage-browser-localstorage`](./storage-browser-localstorage/README.md) | Browser-tier `AbstractStorage` adapter backed by the synchronous `Storage` API (`globalThis.localStorage`). Provides namespaced, origin-scoped key-value storag… |
|
||||
| [`@suaveplan/storage-browser-opfs`](./storage-browser-opfs/README.md) | Browser-tier `AbstractStorage` adapter backed by the Origin Private File System (OPFS). Provides persistent, origin-scoped key-value storage for browser consum… |
|
||||
| [`@suaveplan/storage-cdn`](./storage-cdn/README.md) | Types-only foundation for CDN-flavoured `@suaveplan/storage` adapters. Declares `CdnAdapter` (an abstract base class extending `AbstractStorage` with `purge`,… |
|
||||
| [`@suaveplan/storage-cdn-bunnycdn`](./storage-cdn-bunnycdn/README.md) | BunnyCDN Storage Zone + Pull Zone provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`set`/`delete`/`exists`/`getMe… |
|
||||
| [`@suaveplan/storage-cdn-rackspace`](./storage-cdn-rackspace/README.md) | Rackspace Cloud Files (OpenStack Swift) origin + Rackspace CDN provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`… |
|
||||
| [`@suaveplan/storage-file`](./storage-file/README.md) | Node.js filesystem `AbstractStorage` adapter for `@suaveplan/storage`. Implements atomic writes via temp-file-then-rename, AbortSignal threaded through every a… |
|
||||
| [`@suaveplan/storage-http`](./storage-http/README.md) | Universal HTTP/CDN-backed `AbstractStorage` adapter for `@suaveplan/storage`. Reads via `GET`/`HEAD`/`Range` against any `<baseUrl><key>` origin, with optional… |
|
||||
| [`@suaveplan/storage-plugin-cache`](./storage-plugin-cache/README.md) | Read-through cache decorator for `AbstractStorage`. Wraps any `@suaveplan/storage` instance with an injectable `AbstractCache<Uint8Array>`; hit/miss/populate o… |
|
||||
| [`@suaveplan/storage-s3`](./storage-s3/README.md) | S3-compatible object storage adapter for `@suaveplan/storage`. Extends `AbstractStorage` backed by `@aws-sdk/client-s3` with presigned URL generation, multipar… |
|
||||
| [`@suaveplan/storage-uri`](./storage-uri/README.md) | Universal URI-routed dispatch over `@suaveplan` `StorageLike` adapters. |
|
||||
| [`@suaveplan/storage-uri-plugin-bundle`](./storage-uri-plugin-bundle/README.md) | Per-package `bundle://` URI scheme for `@suaveplan/storage-uri`. |
|
||||
| [`@suaveplan/storage-uri-plugin-fallback`](./storage-uri-plugin-fallback/README.md) | Universal `fallback://` URI plugin for `@suaveplan/storage-uri`. Ships ordered multi-source resolution with cycle detection, depth cap, mirror writes, list uni… |
|
||||
<!-- end:auto-docs -->
|
||||
3
docs/persistence/_intro.md
Normal file
3
docs/persistence/_intro.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `persistence`
|
||||
|
||||
Caching, database adapters, search engines, and storage abstractions.
|
||||
15
docs/persistence/cache/README.md
vendored
Normal file
15
docs/persistence/cache/README.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# `cache`
|
||||
|
||||
Caching abstractions, memory caches, and Redis backends.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `cache`
|
||||
|
||||
3 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cache`](./cache/README.md) | Type-safe caching abstraction for Suaveplan. Provides `MemoryCache` with LRU/LFU/FIFO/TTL eviction, `NamespacedCache` for shared-storage isolation, a `CacheFac… |
|
||||
| [`@suaveplan/cache-redis`](./cache-redis/README.md) | `AbstractCache<T>` backend over a minimal `RedisLike` interface — native `SET ... EX`, `GET`, `DEL`, `EXISTS`, `TTL`, `SCAN`. Reuses `@suaveplan/cache-storage`… |
|
||||
| [`@suaveplan/cache-storage`](./cache-storage/README.md) | `AbstractCache<T>` backend that bridges any `@suaveplan/storage` `AbstractStorage` into a typed cache via a 32-byte fixed-width TTL header and pluggable `Codec… |
|
||||
<!-- end:auto-docs -->
|
||||
1
docs/persistence/cache/_intro.md
vendored
Normal file
1
docs/persistence/cache/_intro.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Caching abstractions, memory caches, and Redis backends.
|
||||
80
docs/persistence/cache/cache-redis/README.md
vendored
Normal file
80
docs/persistence/cache/cache-redis/README.md
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
# @suaveplan/cache-redis - AbstractCache backend over a minimal RedisLike interface — native SET ... EX, GET, DEL, EXISTS, TTL, SCAN. Reuses @suaveplan/cache-storage codecs (JSON / identity / base64).
|
||||
|
||||
`AbstractCache<T>` backend over a minimal `RedisLike` interface — native `SET ... EX`, `GET`, `DEL`, `EXISTS`, `TTL`, `SCAN`. Reuses `@suaveplan/cache-storage` codecs (JSON / identity / base64).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [errors](./src/errors/errors.md) — Defines the structured error surface for `@suaveplan/cache-redis`. Every failure inside the package — connection drops…
|
||||
- [errors](./src/errors/errors.md) — Defines the structured error surface for `@suaveplan/cache-redis`. Every failure inside the package — connection drops…
|
||||
- [redis-cache](./src/redis-cache/redis-cache.md) — `RedisCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and adapts a Redis-compatible store (via the minimal…
|
||||
- [redis-cache](./src/redis-cache/redis-cache.md) — `RedisCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and adapts a Redis-compatible store (via the minimal…
|
||||
- [redis-like](./src/redis-like/redis-like.md) — Declares the minimal Redis client surface `RedisCache` consumes. Any client conforming to this interface — `ioredis`, `…
|
||||
- [redis-like](./src/redis-like/redis-like.md) — Declares the minimal Redis client surface `RedisCache` consumes. Any client conforming to this interface — `ioredis`, `…
|
||||
- [registry-augment](./src/registry-augment/registry-augment.md) — Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `redis` entry, and side-effect-register…
|
||||
- [registry-augment](./src/registry-augment/registry-augment.md) — Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `redis` entry, and side-effect-register…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Summary
|
||||
|
||||
`@suaveplan/cache-redis` is the server-tier cache backend for the cache family. It plugs any Redis-compatible client (`ioredis`, `node-redis`, custom adapters over RESP3, KeyDB, Dragonfly) into the unified `AbstractCache<T>` surface. TTL is owned by the Redis server via native `SET ... EX`, so there are no client-side header bytes, no per-read expiry comparisons, and no client-side eviction tables — Redis's own `maxmemory-policy` decides what to evict. Side-effect import auto-registers `RedisCache` as the `"redis"` provider on `CacheFactory`, so `createCache("redis", { redis })` works after a single import.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/cache-redis
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { RedisCache } from "@suaveplan/cache-redis";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
const cache = new RedisCache<{ id: string; email: string }>({
|
||||
redis: new Redis({ host: "redis.example.com" }),
|
||||
namespace: "users",
|
||||
defaultTtl: 5 * 60_000,
|
||||
});
|
||||
|
||||
await cache.set("alice", { id: "1", email: "alice@example.com" });
|
||||
const user = await cache.get("alice");
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [errors](./src/errors/errors.md) — `CACHE_REDIS_CODES`, `RedisCacheError`.
|
||||
- [redis-like](./src/redis-like/redis-like.md) — minimal `RedisLike` client surface.
|
||||
- [redis-cache](./src/redis-cache/redis-cache.md) — `RedisCache<T>` class.
|
||||
- [registry-augment](./src/registry-augment/registry-augment.md) — `CacheProviderRegistry` augmentation + auto-registration.
|
||||
|
||||
## Factory registration
|
||||
|
||||
```ts
|
||||
import "@suaveplan/cache-redis"; // side-effect: register provider
|
||||
import { createCache } from "@suaveplan/cache";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
const cache = createCache("redis", { redis: new Redis() });
|
||||
```
|
||||
|
||||
## Why a separate package, not `cache-storage` over a future `storage-redis`?
|
||||
|
||||
Redis owns TTL natively (`EX`, `EXPIRE`) and eviction natively (`maxmemory-policy`). Layering the cache over a hypothetical `storage-redis` would:
|
||||
|
||||
1. Encode TTL into the value bytes (wasted — Redis ignores it).
|
||||
2. Require client-side eviction tracking (wasted — Redis evicts per policy).
|
||||
3. Lose `TTL key` introspection (Redis can tell us; the header can't).
|
||||
|
||||
So `RedisCache` talks to Redis directly via `RedisLike`, and `cache-storage` is reserved for genuinely byte-oriented stores (filesystem, S3, browser storage).
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
| --- | --- |
|
||||
| `@suaveplan/cache` | `AbstractCache<T>`, `CacheFactory`, `CacheProviderRegistry`, `CacheEvents`. |
|
||||
| `@suaveplan/cache-storage` | Shared `Codec<T>` contract + `JsonCodec` / `IdentityCodec`. |
|
||||
| `@suaveplan/error` | `defineCodes`, `SuaveplanError`. |
|
||||
| `@suaveplan/types` | `EventBusLike`. |
|
||||
| `tslib` | TypeScript helpers. |
|
||||
|
||||
76
docs/persistence/cache/cache-redis/src/errors/errors.md
vendored
Normal file
76
docs/persistence/cache/cache-redis/src/errors/errors.md
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the structured error surface for `@suaveplan/cache-redis`. Every failure inside the package — connection drops to the Redis server, individual command rejections, codec round-trip failures, oversized keys, invalid TTL arguments — surfaces as a `RedisCacheError` carrying one of the codes registered in `CACHE_REDIS_CODES`. Consumers programmatically branch on `error.code` rather than parsing free-form messages, and the optional `key` and `command` discriminants pinpoint exactly which command on which key triggered the failure.
|
||||
|
||||
## Features
|
||||
|
||||
- 5-code registry built with `defineCodes("CACHE_REDIS", [...])` so codes serialise identically across OpenTelemetry span events, the wire-format error envelope, and the i18n resolver.
|
||||
- `RedisCacheError` extends `SuaveplanError<CacheRedisCode>` for cause chains, structured `traceId`, and arbitrary `context`.
|
||||
- `key` and `command` discriminants on every error — present whenever a failure originates at a known key or command boundary.
|
||||
- Every code is exercised at a real call site by `errors.test.ts`, satisfying the §13 step 0 reachability gate.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CACHE_REDIS_CODES,
|
||||
RedisCacheError,
|
||||
} from "@suaveplan/cache-redis/errors";
|
||||
|
||||
try {
|
||||
await cache.set("k", value);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof RedisCacheError &&
|
||||
err.code === CACHE_REDIS_CODES.CONNECTION_FAILED
|
||||
) {
|
||||
metrics.increment("cache.redis.connection_failure");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CACHE_REDIS_CODES,
|
||||
RedisCacheError,
|
||||
} from "@suaveplan/cache-redis/errors";
|
||||
|
||||
function classify(err: unknown): "retry" | "fatal" | "ignore" {
|
||||
if (!(err instanceof RedisCacheError)) return "fatal";
|
||||
switch (err.code) {
|
||||
case CACHE_REDIS_CODES.CONNECTION_FAILED:
|
||||
case CACHE_REDIS_CODES.COMMAND_FAILED:
|
||||
return "retry";
|
||||
case CACHE_REDIS_CODES.CODEC_ERROR:
|
||||
return "ignore"; // cache miss is safe
|
||||
case CACHE_REDIS_CODES.KEY_TOO_LONG:
|
||||
case CACHE_REDIS_CODES.INVALID_TTL:
|
||||
return "fatal";
|
||||
default:
|
||||
return "fatal";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `CACHE_REDIS_CODES` | const | Frozen object mapping reason → `"CACHE_REDIS.<REASON>"` value. |
|
||||
| `CacheRedisCode` | type | Union of the registered code values. |
|
||||
| `RedisCacheError` | class | Extends `SuaveplanError<CacheRedisCode>`. Constructor: `(code, messageKey, options?)` where options may carry `key`, `command`, `cause`, `context`, `traceId`. |
|
||||
| `RedisCacheErrorOptions` | type | `SuaveplanErrorOptions & { key?: string; command?: string }`. |
|
||||
|
||||
Registered codes: `CONNECTION_FAILED`, `COMMAND_FAILED`, `CODEC_ERROR`, `KEY_TOO_LONG`, `INVALID_TTL`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`CONNECTION_FAILED` is intentionally the catch-all for "the underlying `RedisLike` client threw something" — the cache cannot distinguish network errors from server-side `READONLY` rejects without parsing the cause, and most consumers treat both as "retry later." `COMMAND_FAILED` is reserved for failures that originate inside the cache itself — currently only `clear()` without a namespace; future commands that have explicit semantic preconditions will share this code.
|
||||
105
docs/persistence/cache/cache-redis/src/redis-cache/redis-cache.md
vendored
Normal file
105
docs/persistence/cache/cache-redis/src/redis-cache/redis-cache.md
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
# RedisCache
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`RedisCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and adapts a Redis-compatible store (via the minimal `RedisLike` interface) into the unified cache surface. The class uses native Redis primitives — `SET ... EX`, `GET`, `DEL`, `EXISTS`, `TTL`, `SCAN` — so TTL is owned by the Redis server (no client-side header bytes, no per-read expiry comparison) and the underlying `maxmemory-policy` handles eviction without duplicate client-side bookkeeping.
|
||||
|
||||
## Features
|
||||
|
||||
- Drop-in `AbstractCache<T>` subclass — inherits `wrap`, `getMultiple`, `setMultiple` from the base class with `AbortSignal` and stale-while-revalidate semantics unchanged.
|
||||
- Native TTL via `SET ... EX <seconds>` — milliseconds in the cache API are converted to seconds at the boundary, with sub-second TTLs rounded up to `EX: 1` so a positive TTL is never silently dropped.
|
||||
- Pluggable `Codec<T>` reused from `@suaveplan/cache-storage` — `JsonCodec<T>` default (UTF-8 string on the wire), `IdentityCodec` (base64-encoded binary).
|
||||
- Namespaced `clear()` via `SCAN`-then-`DEL` batches — bounded by namespace size, never `FLUSHDB`.
|
||||
- Refuses `clear()` without a namespace with `CACHE_REDIS.COMMAND_FAILED` — explicit foot-gun protection on shared Redis instances.
|
||||
- Client-side `getStats()` counters (`hits`, `misses`, `evictions`, `hitRate`). `size` returns `0` — Redis `DBSIZE` is whole-DB, not per-namespace, and `SCAN`-then-count is O(N) and inappropriate for a `getStats` call.
|
||||
- Structured `RedisCacheError` for every failure path with `key` and `command` discriminants.
|
||||
- Optional lifecycle events through any `EventBusLike<CacheEvents>`.
|
||||
- Auto-registered with the `CacheFactory` registry as the `"redis"` provider on side-effect import.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RedisCache } from "@suaveplan/cache-redis";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
const cache = new RedisCache<{ id: string; email: string }>({
|
||||
redis: new Redis(),
|
||||
namespace: "users",
|
||||
defaultTtl: 5 * 60_000,
|
||||
});
|
||||
|
||||
await cache.set("alice", { id: "1", email: "alice@example.com" });
|
||||
const user = await cache.get("alice");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import "@suaveplan/cache-redis"; // side-effect: registers the "redis" provider
|
||||
import { createCache } from "@suaveplan/cache";
|
||||
import { IdentityCodec } from "@suaveplan/cache-storage";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
const events = new EventBus();
|
||||
events.on("evict", ({ key }) => log.info("cache evicted", { key }));
|
||||
|
||||
const redis = new Redis({ host: "redis", maxRetriesPerRequest: 3 });
|
||||
|
||||
const binaryCache = createCache("redis" as never, {
|
||||
redis,
|
||||
namespace: "thumbnails",
|
||||
codec: new IdentityCodec(),
|
||||
events,
|
||||
} as never);
|
||||
|
||||
await binaryCache.set("avatar:42", await renderAvatar(42));
|
||||
|
||||
// Wrap, single-flight, SWR — inherited from AbstractCache.
|
||||
const summary = await binaryCache.wrap(
|
||||
`summary:${userId}`,
|
||||
() => buildSummary(userId),
|
||||
{ ttl: 60_000, staleWhileRevalidate: true, staleThreshold: 5_000 },
|
||||
);
|
||||
|
||||
// Clear only the "thumbnails" namespace — never touches the rest of the DB.
|
||||
await binaryCache.clear();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new RedisCache<T>(options: RedisCacheOptions<T>)`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `redis` | `RedisLike` | — (required) | The underlying Redis client. |
|
||||
| `codec` | `Codec<T>` | `new JsonCodec<T>()` | Value codec. |
|
||||
| `defaultTtl` | `number` | `0` | TTL in ms when `set` omits one. `0` ⇒ no expiry. |
|
||||
| `events` | `EventBusLike` | `undefined` | Lifecycle event sink. |
|
||||
| `namespace` | `string` | `""` | Key prefix. Required for `clear()`. |
|
||||
|
||||
### Methods
|
||||
|
||||
Every `AbstractCacheShape<T>` method: `get`, `set`, `delete`, `clear`, `has`, `getStats`, `getMultiple`, `setMultiple`, `wrap`. See the parent `AbstractCache` documentation for inherited helpers.
|
||||
|
||||
### Thrown errors
|
||||
|
||||
- `RedisCacheError("CACHE_REDIS.CONNECTION_FAILED")` — the underlying client threw on a command.
|
||||
- `RedisCacheError("CACHE_REDIS.COMMAND_FAILED")` — `clear()` invoked without a namespace.
|
||||
- `RedisCacheError("CACHE_REDIS.CODEC_ERROR")` — codec encode or decode failure.
|
||||
- `RedisCacheError("CACHE_REDIS.KEY_TOO_LONG")` — key longer than 1024 chars.
|
||||
- `RedisCacheError("CACHE_REDIS.INVALID_TTL")` — negative TTL passed to `set`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cache uses base64 only when the codec is `IdentityCodec`; the `JsonCodec` default emits valid UTF-8 strings directly to avoid the 33% base64 overhead. Detection is via `instanceof IdentityCodec` — adding a different binary codec means setting a per-codec "is-binary-safe" capability flag (planned for a follow-up; the current check covers ≥95% of use).
|
||||
|
||||
`clear()` issues `SCAN 0 MATCH <ns>:* COUNT 1000` and `DEL`s each batch until the cursor returns `"0"`. The total deleted count is reflected in `getStats().evictions`. For very large namespaces this is O(N) — callers who need to wipe millions of keys should run the operation off the request path.
|
||||
|
||||
`hitCount` and `lastAccessedAt` tracked by `MemoryCache` are not maintained — Redis cannot return them in a single round-trip, and burning a second command on every read for advisory metrics would halve throughput. Use `TTL key` if you need to introspect remaining expiry.
|
||||
98
docs/persistence/cache/cache-redis/src/redis-like/redis-like.md
vendored
Normal file
98
docs/persistence/cache/cache-redis/src/redis-like/redis-like.md
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
# RedisLike
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the minimal Redis client surface `RedisCache` consumes. Any client conforming to this interface — `ioredis`, `node-redis`, a custom adapter over the official RESP3 protocol, or even a non-Redis store like KeyDB or Dragonfly that exposes the same commands — is a valid argument to the cache. Constraining the surface to seven primitives (`set`, `get`, `del`, `exists`, `ttl`, `expire`, `scan`) plus optional `mget` keeps the contract small enough that an in-memory `FakeRedis` for tests is straightforward to write and audit.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven required methods plus optional `mget` — chosen to match the exact set of commands `RedisCache` needs at runtime.
|
||||
- `set` accepts `{ EX, PX }` options matching the canonical Redis protocol; the cache only emits `EX`.
|
||||
- `scan` returns the cursor + matched keys as a single normalised shape (`RedisScanResult`), letting adapters bridge over both the `ioredis` tuple shape `[cursor, keys]` and the `node-redis` object shape `{ cursor, keys }`.
|
||||
- No transactional, pub/sub, or scripting primitives — those concerns belong to the application, not the cache.
|
||||
- Pure type module — zero runtime code, no `instanceof` requirement.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { RedisLike } from "@suaveplan/cache-redis/redis-like";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
// `ioredis` v5 conforms to RedisLike out of the box.
|
||||
const client: RedisLike = new Redis({ host: "redis.example.com" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
RedisLike,
|
||||
RedisScanResult,
|
||||
} from "@suaveplan/cache-redis/redis-like";
|
||||
import { createClient } from "redis";
|
||||
|
||||
// `node-redis` returns scan results in `{ cursor, keys }` shape and uses
|
||||
// `pExpire` for millisecond TTLs — a thin adapter normalises both.
|
||||
class NodeRedisAdapter implements RedisLike {
|
||||
readonly #client = createClient();
|
||||
async set(key: string, value: string, opts?: { EX?: number }) {
|
||||
return opts?.EX !== undefined
|
||||
? this.#client.set(key, value, { EX: opts.EX })
|
||||
: this.#client.set(key, value);
|
||||
}
|
||||
async get(k: string) {
|
||||
return this.#client.get(k);
|
||||
}
|
||||
async del(...keys: string[]) {
|
||||
return this.#client.del(keys);
|
||||
}
|
||||
async exists(...keys: string[]) {
|
||||
return this.#client.exists(keys);
|
||||
}
|
||||
async ttl(k: string) {
|
||||
return this.#client.ttl(k);
|
||||
}
|
||||
async expire(k: string, s: number) {
|
||||
const ok = await this.#client.expire(k, s);
|
||||
return ok ? 1 : 0;
|
||||
}
|
||||
async scan(
|
||||
cursor: string,
|
||||
opts?: { MATCH?: string; COUNT?: number },
|
||||
): Promise<RedisScanResult> {
|
||||
const result = await this.#client.scan(Number(cursor), {
|
||||
MATCH: opts?.MATCH,
|
||||
COUNT: opts?.COUNT,
|
||||
});
|
||||
return { cursor: String(result.cursor), keys: result.keys };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
| --- | --- | --- |
|
||||
| `RedisLike` | interface | Required surface: `set`, `get`, `del`, `exists`, `ttl`, `expire`, `scan`, optional `mget`. |
|
||||
| `RedisSetOptions` | interface | `{ EX?: number; PX?: number }`. |
|
||||
| `RedisScanOptions` | interface | `{ MATCH?: string; COUNT?: number }`. |
|
||||
| `RedisScanResult` | interface | `{ cursor: string; keys: string[] }`. |
|
||||
|
||||
### Method semantics
|
||||
|
||||
- `set(key, value, opts?)` — `SET key value [EX seconds]`. Returns the server's response (typically `"OK"`); the cache ignores it.
|
||||
- `get(key)` — returns the value or `null` on miss.
|
||||
- `del(...keys)` — returns the number of keys actually deleted.
|
||||
- `exists(...keys)` — returns the number of matching keys.
|
||||
- `ttl(key)` — returns seconds, `-1` for "no expiry", `-2` for "missing key".
|
||||
- `expire(key, seconds)` — returns `1` on success, `0` on missing key.
|
||||
- `scan(cursor, opts?)` — returns `{ cursor, keys }` with `cursor === "0"` signalling end of iteration.
|
||||
- `mget(...keys)` — optional; if absent, `getMultiple` falls back to per-key `get`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface uses `string` for both keys and values because the canonical RESP protocol speaks strings; `RedisCache` runs UTF-8 / base64 conversion at the boundary. Adapters that expose `Buffer` (`ioredis` in `Buffer`-mode, `node-redis` with `returnBuffers`) work as long as the adapter normalises back to `string` before returning. A future binary-safe variant of the cache may add a parallel `RedisBinaryLike` contract; until then keeping `RedisLike` string-only keeps the implementation universal compatible.
|
||||
64
docs/persistence/cache/cache-redis/src/registry-augment/registry-augment.md
vendored
Normal file
64
docs/persistence/cache/cache-redis/src/registry-augment/registry-augment.md
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
# registry-augment
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `redis` entry, and side-effect-registers `RedisCache` as the implementation behind that key on the shared `CacheFactory`. Importing this module — directly or transitively via the package root — is enough; the registration is idempotent and survives repeated imports or duplicate package versions resolving to the same singleton registry.
|
||||
|
||||
## Features
|
||||
|
||||
- TypeScript-side module augmentation makes `createCache("redis", config)` type-safe — the registry key becomes part of `CacheProviderType`, and the config parameter is inferred from `RedisCacheOptions<T>` via the registry slot.
|
||||
- Idempotent runtime registration — guarded by both an internal `registered` flag and a `hasCacheProvider("redis")` query against the live registry, so a double-import or a second package version pointing at the same registry never throws or overwrites.
|
||||
- Side-effect import — the `registry-augment.ts` module is listed in `"sideEffects"` in `package.json` so bundlers preserve the registration call when the rest of the package is tree-shaken.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// Side-effect import: registers the "redis" provider.
|
||||
import "@suaveplan/cache-redis";
|
||||
|
||||
import { createCache } from "@suaveplan/cache";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
const cache = createCache("redis", { redis: new Redis() });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
// Drive registration manually — useful in tests that need to control
|
||||
// provider availability ordering or confirm idempotency.
|
||||
import {
|
||||
hasCacheProvider,
|
||||
unregisterCacheProvider,
|
||||
} from "@suaveplan/cache";
|
||||
import { registerRedisProvider } from "@suaveplan/cache-redis";
|
||||
|
||||
unregisterCacheProvider("redis");
|
||||
registerRedisProvider();
|
||||
console.log(hasCacheProvider("redis")); // true
|
||||
registerRedisProvider(); // no-op
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Description |
|
||||
| --- | --- | --- |
|
||||
| `registerRedisProvider()` | function | Idempotently calls `registerCacheProvider("redis", RedisCache)`. Safe to invoke repeatedly. |
|
||||
|
||||
The module performs module augmentation:
|
||||
|
||||
```ts
|
||||
declare module "@suaveplan/cache" {
|
||||
interface CacheProviderRegistry {
|
||||
redis: typeof RedisCache;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `registered` module-local flag is set even when the registry already contained the provider (e.g. because another package version registered it first). That ensures the second branch is exercised at most once per module instance and that subsequent calls hit the fast path. The function is exported so test suites can drive it explicitly after a `clearCacheProviders()` call without re-importing the package.
|
||||
88
docs/persistence/cache/cache-storage/README.md
vendored
Normal file
88
docs/persistence/cache/cache-storage/README.md
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# @suaveplan/cache-storage - AbstractCache backend that bridges any @suaveplan/storage AbstractStorage into a typed cache via a 32-byte fixed-width TTL header and pluggable Codec<T> (JSON default, identity for Uint8Array).
|
||||
|
||||
`AbstractCache<T>` backend that bridges any `@suaveplan/storage` `AbstractStorage` into a typed cache via a 32-byte fixed-width TTL header and pluggable `Codec<T>` (JSON default, identity for `Uint8Array`).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [identity](./src/codec/identity.md) — `IdentityCodec` is the `Codec<Uint8Array>` implementation that passes bytes through without encoding overhead. Use it w…
|
||||
- [json](./src/codec/json.md) — `JsonCodec<T>` is the default codec for `StorageCache`. It serialises arbitrary JSON-compatible values to UTF-8 bytes v…
|
||||
- [errors](./src/errors/errors.md) — Defines the structured error surface for `@suaveplan/cache-storage`. Every failure path inside the package — corrupt bl…
|
||||
- [errors](./src/errors/errors.md) — Defines the structured error surface for `@suaveplan/cache-storage`. Every failure path inside the package — corrupt bl…
|
||||
- [header](./src/header/header.md) — Defines the 32-byte fixed-width metadata header that `StorageCache` prepends to every cached value. The header carries…
|
||||
- [header](./src/header/header.md) — Defines the 32-byte fixed-width metadata header that `StorageCache` prepends to every cached value. The header carries…
|
||||
- [registry-augment](./src/registry-augment/registry-augment.md) — Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `storage` entry, and side-effect-regist…
|
||||
- [registry-augment](./src/registry-augment/registry-augment.md) — Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `storage` entry, and side-effect-regist…
|
||||
- [storage-cache](./src/storage-cache/storage-cache.md) — `StorageCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and turns any `AbstractStorage` (from `@suaveplan/…
|
||||
- [storage-cache](./src/storage-cache/storage-cache.md) — `StorageCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and turns any `AbstractStorage` (from `@suaveplan/…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Summary
|
||||
|
||||
`@suaveplan/cache-storage` is the universal cache backend for the cache family. It composes any byte-oriented storage adapter — `MemoryStorage`, `storage-browser-localstorage`, `storage-browser-indexeddb`, `storage-browser-opfs`, `storage-file`, `storage-s3`, `storage-cdn-*` — into a typed cache with TTL, lifecycle events, namespacing, and structured errors. The package subsumes the upstream `cache-browser` package by composition: pick the storage adapter that matches your environment, and the cache surface is identical to `MemoryCache`. Side-effect import auto-registers `StorageCache` as the `"storage"` provider on `CacheFactory`, so `createCache("storage", { storage })` works after a single import.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/cache-storage
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { StorageCache } from "@suaveplan/cache-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage/memory";
|
||||
|
||||
const cache = new StorageCache<{ id: string; email: string }>({
|
||||
storage: new MemoryStorage(),
|
||||
defaultTtl: 5 * 60_000,
|
||||
});
|
||||
|
||||
await cache.set("user:1", { id: "1", email: "alice@example.com" });
|
||||
const user = await cache.get("user:1");
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [errors](./src/errors/errors.md) — `CACHE_STORAGE_CODES`, `StorageCacheError`.
|
||||
- [header](./src/header/header.md) — 32-byte fixed-width metadata header codec.
|
||||
- [codec/identity](./src/codec/identity.md) — `IdentityCodec` for `Uint8Array` values.
|
||||
- [codec/json](./src/codec/json.md) — `JsonCodec<T>` default codec.
|
||||
- [storage-cache](./src/storage-cache/storage-cache.md) — `StorageCache<T>` class.
|
||||
- [registry-augment](./src/registry-augment/registry-augment.md) — `CacheProviderRegistry` augmentation + auto-registration.
|
||||
|
||||
## Browser-side caching (replaces `cache-browser`)
|
||||
|
||||
```ts
|
||||
import { StorageCache } from "@suaveplan/cache-storage";
|
||||
import { LocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
|
||||
|
||||
const cache = new StorageCache<string>({
|
||||
storage: new LocalStorageStorage(),
|
||||
namespace: "ui:preferences",
|
||||
defaultTtl: 7 * 24 * 60 * 60_000,
|
||||
});
|
||||
```
|
||||
|
||||
The same pattern works over `storage-browser-indexeddb` (larger payloads) and `storage-browser-opfs` (file-grade durability).
|
||||
|
||||
## Factory registration
|
||||
|
||||
```ts
|
||||
import "@suaveplan/cache-storage"; // side-effect: register provider
|
||||
import { createCache } from "@suaveplan/cache";
|
||||
import { MemoryStorage } from "@suaveplan/storage/memory";
|
||||
|
||||
const cache = createCache("storage", { storage: new MemoryStorage() });
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
| --- | --- |
|
||||
| `@suaveplan/cache` | `AbstractCache<T>`, `CacheFactory`, `CacheProviderRegistry`, `CacheEvents`, `CacheSetOptions`. |
|
||||
| `@suaveplan/error` | `defineCodes`, `SuaveplanError`. |
|
||||
| `@suaveplan/storage` | `AbstractStorage` contract. |
|
||||
| `@suaveplan/types` | `EventBusLike`. |
|
||||
| `tslib` | TypeScript helpers. |
|
||||
|
||||
66
docs/persistence/cache/cache-storage/src/codec/identity.md
vendored
Normal file
66
docs/persistence/cache/cache-storage/src/codec/identity.md
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
# IdentityCodec
|
||||
|
||||
## Purpose
|
||||
|
||||
`IdentityCodec` is the `Codec<Uint8Array>` implementation that passes bytes through without encoding overhead. Use it when the cached `T` is already binary — chunked file data, pre-serialised protobufs, encrypted blobs, audio samples — and reserialising through JSON would be wasteful or lossy. The codec is paired with `StorageCache<Uint8Array>` to provide a byte-cache surface over any `AbstractStorage`.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero-copy `encode` — returns the input `Uint8Array` by reference. Suitable for high-throughput paths where allocations matter.
|
||||
- Defensive-copy `decode` — returns a fresh `Uint8Array` so callers mutating the result cannot corrupt the underlying storage buffer.
|
||||
- No throws — every input is valid because the encode step is the identity function.
|
||||
- Stateless — a single instance is safe to share across cache instances and concurrent operations.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { IdentityCodec } from "@suaveplan/cache-storage/codec/identity";
|
||||
import { StorageCache } from "@suaveplan/cache-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage/memory";
|
||||
|
||||
const cache = new StorageCache<Uint8Array>({
|
||||
storage: new MemoryStorage(),
|
||||
codec: new IdentityCodec(),
|
||||
});
|
||||
|
||||
await cache.set("blob:1", new Uint8Array([1, 2, 3, 4]));
|
||||
const out = await cache.get("blob:1");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { IdentityCodec } from "@suaveplan/cache-storage/codec/identity";
|
||||
import { StorageCache } from "@suaveplan/cache-storage";
|
||||
|
||||
// Cache pre-compressed payloads: avoid re-encoding on every cache hit.
|
||||
const cache = new StorageCache<Uint8Array>({
|
||||
storage: filesystemStorage,
|
||||
codec: new IdentityCodec(),
|
||||
defaultTtl: 60_000,
|
||||
});
|
||||
|
||||
const compressed = await gzip(payload);
|
||||
await cache.set(`gz:${id}`, compressed);
|
||||
|
||||
const cached = await cache.get(`gz:${id}`);
|
||||
if (cached !== undefined) {
|
||||
// Stream straight to the response without a decode step.
|
||||
res.setHeader("content-encoding", "gzip");
|
||||
res.end(cached);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Signature | Description |
|
||||
| --- | --- | --- |
|
||||
| `new IdentityCodec()` | — | Constructs a stateless codec. No arguments. |
|
||||
| `encode(value)` | `(Uint8Array) → Uint8Array` | Returns `value` by reference. |
|
||||
| `decode(bytes)` | `(Uint8Array) → Uint8Array` | Returns a fresh copy of `bytes`. |
|
||||
|
||||
Conforms to `Codec<Uint8Array>` from `@suaveplan/cache-storage/codec`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `encode` step is intentionally non-copying. `StorageCache` immediately wraps the returned bytes in a fresh combined buffer (header + body), so referential sharing does not bleed into the on-disk representation. The `decode` step copies because the storage backend may hand out its internal buffer for performance — `MemoryStorage` and `storage-file` both do — and a caller that mutated the result would otherwise see arbitrary corruption on the next read.
|
||||
79
docs/persistence/cache/cache-storage/src/codec/json.md
vendored
Normal file
79
docs/persistence/cache/cache-storage/src/codec/json.md
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
# JsonCodec
|
||||
|
||||
## Purpose
|
||||
|
||||
`JsonCodec<T>` is the default codec for `StorageCache`. It serialises arbitrary JSON-compatible values to UTF-8 bytes via `JSON.stringify` + `TextEncoder.encode`, and parses bytes back through `TextDecoder.decode` (in fatal mode) + `JSON.parse`. Use it for the typical cache payload — profile objects, configuration documents, API responses, denormalised projections — anywhere the value is already JSON-safe.
|
||||
|
||||
## Features
|
||||
|
||||
- Round-trips every JSON-safe TypeScript value: strings, numbers, booleans, `null`, plain objects, arrays, and nested combinations of these.
|
||||
- Full multi-byte UTF-8 support — emoji, CJK, RTL scripts, mathematical operators all survive a round-trip.
|
||||
- Fatal-mode UTF-8 decode — invalid leading sequences fail loud instead of silently producing replacement characters, so corruption surfaces immediately.
|
||||
- Decode failures (malformed UTF-8 OR malformed JSON) are wrapped in `StorageCacheError("CACHE_STORAGE.CODEC_ERROR")` with the original throw on `cause`, so callers see a single structured failure shape.
|
||||
- Stateless — share one instance across many caches; both `TextEncoder` and `TextDecoder` are module-level singletons.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { JsonCodec } from "@suaveplan/cache-storage/codec/json";
|
||||
|
||||
const codec = new JsonCodec<{ user: string; roles: string[] }>();
|
||||
const bytes = codec.encode({ user: "alice", roles: ["admin"] });
|
||||
const value = codec.decode(bytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { JsonCodec } from "@suaveplan/cache-storage/codec/json";
|
||||
import { StorageCache } from "@suaveplan/cache-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage/memory";
|
||||
import {
|
||||
CACHE_STORAGE_CODES,
|
||||
StorageCacheError,
|
||||
} from "@suaveplan/cache-storage/errors";
|
||||
|
||||
interface UserSummary {
|
||||
id: string;
|
||||
email: string;
|
||||
preferredLocale: string;
|
||||
}
|
||||
|
||||
const cache = new StorageCache<UserSummary>({
|
||||
storage: new MemoryStorage(),
|
||||
codec: new JsonCodec<UserSummary>(),
|
||||
defaultTtl: 5 * 60_000,
|
||||
});
|
||||
|
||||
async function loadUser(id: string): Promise<UserSummary> {
|
||||
try {
|
||||
return await cache.wrap(`user:${id}`, () => fetchUser(id));
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof StorageCacheError &&
|
||||
err.code === CACHE_STORAGE_CODES.CODEC_ERROR
|
||||
) {
|
||||
log.warn("Corrupted cache entry — refetching", { id, cause: err.cause });
|
||||
await cache.delete(`user:${id}`);
|
||||
return fetchUser(id);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Signature | Description |
|
||||
| --- | --- | --- |
|
||||
| `new JsonCodec<T>()` | — | Constructs a stateless codec for `T`. |
|
||||
| `encode(value)` | `(T) → Uint8Array` | `TextEncoder.encode(JSON.stringify(value))`. |
|
||||
| `decode(bytes)` | `(Uint8Array) → T` | `JSON.parse(TextDecoder.decode(bytes))`. Throws `StorageCacheError("CACHE_STORAGE.CODEC_ERROR")` on malformed input. |
|
||||
|
||||
Conforms to `Codec<T>` from `@suaveplan/cache-storage/codec`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`TextDecoder` is constructed once at module load with `{ fatal: true }`. The fatal flag is critical — without it, invalid bytes would be replaced with `U+FFFD` and the cache would silently return decoded garbage. The split try/catch separates the two failure modes (`utf8` vs `json`) in the wrapped error's `messageKey` so observability pipelines can disambiguate them.
|
||||
|
||||
`encode` failures from `JSON.stringify` (circular references, `BigInt`, `Symbol`) propagate as-is — they originate in the application's data, not the cache, and `StorageCache.set` adapts them into `CACHE_STORAGE.CODEC_ERROR` at the cache boundary so the public surface stays uniform.
|
||||
76
docs/persistence/cache/cache-storage/src/errors/errors.md
vendored
Normal file
76
docs/persistence/cache/cache-storage/src/errors/errors.md
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the structured error surface for `@suaveplan/cache-storage`. Every failure path inside the package — corrupt blob layouts, codec decode failures, oversized keys or values, propagated backend faults — is reported by throwing a `StorageCacheError` carrying one of the codes registered in `CACHE_STORAGE_CODES`. Consumers programmatically branch on `error.code` (a discriminated union literal like `"CACHE_STORAGE.CODEC_ERROR"`) instead of pattern-matching free-form message strings. The optional `key` discriminant identifies the cache key that produced the failure when one is known.
|
||||
|
||||
## Features
|
||||
|
||||
- 5-code registry built with `defineCodes("CACHE_STORAGE", [...])` so codes serialise identically across the OpenTelemetry span pipeline, the wire-format error envelope, and the i18n resolver.
|
||||
- `StorageCacheError` extends `SuaveplanError<CacheStorageCode>` for cause-chain traversal and structured `traceId` / `context` propagation.
|
||||
- Optional `key` discriminant carried on every instance — present on every code that originates at a key boundary (read, write, delete, validate).
|
||||
- Every code is exercised by `errors.test.ts` against a real call site, satisfying the §13 step 0 reachability gate.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CACHE_STORAGE_CODES,
|
||||
StorageCacheError,
|
||||
} from "@suaveplan/cache-storage/errors";
|
||||
|
||||
try {
|
||||
await cache.get("k");
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof StorageCacheError &&
|
||||
err.code === CACHE_STORAGE_CODES.ENTRY_CORRUPT
|
||||
) {
|
||||
log.warn("Corrupted cache entry — deleting", { key: err.key });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
CACHE_STORAGE_CODES,
|
||||
StorageCacheError,
|
||||
} from "@suaveplan/cache-storage/errors";
|
||||
|
||||
function classify(err: unknown): "retry" | "fatal" | "ignore" {
|
||||
if (!(err instanceof StorageCacheError)) return "fatal";
|
||||
switch (err.code) {
|
||||
case CACHE_STORAGE_CODES.BACKEND_ERROR:
|
||||
return "retry";
|
||||
case CACHE_STORAGE_CODES.ENTRY_CORRUPT:
|
||||
case CACHE_STORAGE_CODES.CODEC_ERROR:
|
||||
return "ignore"; // cache miss is safe
|
||||
case CACHE_STORAGE_CODES.KEY_TOO_LONG:
|
||||
case CACHE_STORAGE_CODES.VALUE_TOO_LARGE:
|
||||
return "fatal";
|
||||
default:
|
||||
return "fatal";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `CACHE_STORAGE_CODES` | const | Frozen object mapping reason → `"CACHE_STORAGE.<REASON>"` value. |
|
||||
| `CacheStorageCode` | type | Union of the registered code values. |
|
||||
| `StorageCacheError` | class | `extends SuaveplanError<CacheStorageCode>`; constructor accepts `(code, messageKey, options?)` where options may include `key`, `cause`, `context`, `traceId`. |
|
||||
| `StorageCacheErrorOptions` | type | `SuaveplanErrorOptions & { key?: string }`. |
|
||||
|
||||
Registered codes: `ENTRY_CORRUPT`, `CODEC_ERROR`, `BACKEND_ERROR`, `KEY_TOO_LONG`, `VALUE_TOO_LARGE`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Codes are intentionally minimal — five is enough to cover every failure mode `StorageCache` can produce. `BACKEND_ERROR` is the only code that wraps a foreign exception (the underlying `AbstractStorage.get` / `set`); the others originate inside the package and have well-defined call sites. Decode failures from the codec always surface as `CODEC_ERROR` even when the codec implementation throws something else — `StorageCache.#decodeBody` adapts arbitrary throwables into the canonical form.
|
||||
81
docs/persistence/cache/cache-storage/src/header/header.md
vendored
Normal file
81
docs/persistence/cache/cache-storage/src/header/header.md
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
# header
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the 32-byte fixed-width metadata header that `StorageCache` prepends to every cached value. The header carries `createdAt`, `expiresAt`, `flags`, and `hitCount` so the cache can recover TTL semantics over any byte-oriented `AbstractStorage` without a side-channel metadata API. Fixed width keeps reads single-range and avoids a second round-trip when the underlying store supports byte-range fetches.
|
||||
|
||||
## Features
|
||||
|
||||
- 32 bytes, big-endian, deterministic layout — byte-identical between writers and readers across runtimes.
|
||||
- Two `u64` fields (`createdAt`, `expiresAt`) accommodate millisecond timestamps well past the year 10000.
|
||||
- One `u32` `flags` word carries codec selector bits (`FLAG_CODEC_JSON`) and a `FLAG_HAS_METADATA` presence bit reserved for downstream extensions.
|
||||
- One `u32` `hitCount` saturates at 2³² − 1 — informational only; not rewritten on read in this version.
|
||||
- 8 reserved bytes at the tail leave room for future extension without a version bump.
|
||||
- Bounds-checked `encodeHeader` — out-of-range fields surface as `StorageCacheError("CACHE_STORAGE.ENTRY_CORRUPT")` rather than producing a silently truncated blob.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeHeader, encodeHeader } from "@suaveplan/cache-storage/header";
|
||||
|
||||
const bytes = encodeHeader({
|
||||
createdAt: Date.now(),
|
||||
expiresAt: 0,
|
||||
flags: 0,
|
||||
hitCount: 0,
|
||||
});
|
||||
|
||||
const meta = decodeHeader(bytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeHeader,
|
||||
encodeHeader,
|
||||
FLAG_CODEC_JSON,
|
||||
FLAG_HAS_METADATA,
|
||||
HEADER_BYTES,
|
||||
} from "@suaveplan/cache-storage/header";
|
||||
|
||||
function packEntry(value: Uint8Array, ttlMs: number): Uint8Array {
|
||||
const now = Date.now();
|
||||
const head = encodeHeader({
|
||||
createdAt: now,
|
||||
expiresAt: ttlMs > 0 ? now + ttlMs : 0,
|
||||
flags: FLAG_HAS_METADATA | FLAG_CODEC_JSON,
|
||||
hitCount: 0,
|
||||
});
|
||||
const out = new Uint8Array(HEADER_BYTES + value.length);
|
||||
out.set(head, 0);
|
||||
out.set(value, HEADER_BYTES);
|
||||
return out;
|
||||
}
|
||||
|
||||
function unpackEntry(blob: Uint8Array): {
|
||||
meta: ReturnType<typeof decodeHeader>;
|
||||
body: Uint8Array;
|
||||
} {
|
||||
return { meta: decodeHeader(blob), body: blob.subarray(HEADER_BYTES) };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `HEADER_BYTES` | const number `32` | Fixed length of the metadata header. |
|
||||
| `HeaderMeta` | interface | `{ createdAt: number; expiresAt: number; flags: number; hitCount: number }`. |
|
||||
| `encodeHeader(meta)` | function | Returns a fresh 32-byte `Uint8Array`. Throws `StorageCacheError("CACHE_STORAGE.ENTRY_CORRUPT")` when any field is out of range. |
|
||||
| `decodeHeader(bytes)` | function | Parses the leading 32 bytes. Throws `StorageCacheError("CACHE_STORAGE.ENTRY_CORRUPT")` when the buffer is shorter. |
|
||||
| `FLAG_HAS_METADATA` | const number | Bit 0 — set by `StorageCache` on every encode. |
|
||||
| `FLAG_CODEC_JSON` | const number | Bit 1 — set when the body was produced by `JsonCodec` (for future routing). |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`u64` values are split into two `u32` big-endian writes (`high * 2³² + low`) so the encoder runs without `BigInt` overhead. `decodeHeader` is the inverse. The reserved 8 bytes at offset 24..31 are zeroed at encode time to keep on-disk parity bytewise stable; future versions may stuff version markers there without breaking forward decoders that mask the field.
|
||||
63
docs/persistence/cache/cache-storage/src/registry-augment/registry-augment.md
vendored
Normal file
63
docs/persistence/cache/cache-storage/src/registry-augment/registry-augment.md
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
# registry-augment
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `storage` entry, and side-effect-registers `StorageCache` as the implementation behind that key on the shared `CacheFactory`. Importing this module — directly or transitively via the package root — is enough; the registration is idempotent and survives repeated imports or duplicate package versions resolving to the same singleton registry.
|
||||
|
||||
## Features
|
||||
|
||||
- TypeScript-side module augmentation makes `createCache("storage", config)` type-safe — the registry key is part of `CacheProviderType`, and the config parameter is inferred from `StorageCacheOptions<T>` (via the registry slot).
|
||||
- Idempotent runtime registration — guarded by both an internal `registered` flag and a `hasCacheProvider("storage")` query against the live registry, so double-import never throws or overwrites.
|
||||
- Side-effect import — the `registry-augment.ts` module is listed in `"sideEffects"` in `package.json` so bundlers preserve the registration call when the rest of the package is tree-shaken.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// Side-effect import: registers the "storage" provider.
|
||||
import "@suaveplan/cache-storage";
|
||||
|
||||
import { createCache } from "@suaveplan/cache";
|
||||
import { MemoryStorage } from "@suaveplan/storage/memory";
|
||||
|
||||
const cache = createCache("storage", { storage: new MemoryStorage() });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
// Manually drive registration — useful in tests that need to control the
|
||||
// order of provider availability or want to confirm idempotency.
|
||||
import {
|
||||
hasCacheProvider,
|
||||
unregisterCacheProvider,
|
||||
} from "@suaveplan/cache";
|
||||
import { registerStorageProvider } from "@suaveplan/cache-storage";
|
||||
|
||||
unregisterCacheProvider("storage");
|
||||
registerStorageProvider();
|
||||
console.log(hasCacheProvider("storage")); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Description |
|
||||
| --- | --- | --- |
|
||||
| `registerStorageProvider()` | function | Idempotently calls `registerCacheProvider("storage", StorageCache)`. Safe to invoke repeatedly. |
|
||||
|
||||
The module also performs module augmentation:
|
||||
|
||||
```ts
|
||||
declare module "@suaveplan/cache" {
|
||||
interface CacheProviderRegistry {
|
||||
storage: typeof StorageCache;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `registered` module-local flag is set even when the registry already contained the provider (e.g. because another package version registered it first). That ensures the second branch is exercised at most once per module instance and that subsequent calls hit the fast path. The function is exported so test suites can drive it explicitly after a `clearCacheProviders()` call without re-importing the package.
|
||||
108
docs/persistence/cache/cache-storage/src/storage-cache/storage-cache.md
vendored
Normal file
108
docs/persistence/cache/cache-storage/src/storage-cache/storage-cache.md
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
# StorageCache
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`StorageCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and turns any `AbstractStorage` (from `@suaveplan/storage`) into a typed cache. Behind the type-level `T` API it stores each entry as a 32-byte fixed-width metadata header followed by codec-encoded value bytes — letting plain byte stores like `storage-browser-localstorage`, `storage-file`, or `storage-s3` participate in the cache provider registry without bespoke per-backend code. The class subsumes the upstream `cache-browser` package by composition: pick the storage adapter that matches your environment, hand it to `StorageCache`, and the cache surface is identical to `MemoryCache`.
|
||||
|
||||
## Features
|
||||
|
||||
- Drop-in `AbstractCache<T>` subclass — inherits `wrap`, `getMultiple`, `setMultiple` with `AbortSignal` and stale-while-revalidate semantics unchanged.
|
||||
- 32-byte TTL header (`createdAt`, `expiresAt`, `flags`, `hitCount`) — lazy expiration on read, never expires when `expiresAt === 0`.
|
||||
- Pluggable `Codec<T>` — `JsonCodec` by default; swap to `IdentityCodec` for binary payloads.
|
||||
- Optional namespacing — prefixes every storage key with `${namespace}:`. Namespaced `clear()` lists + deletes prefix-matched keys via the storage backend's `list({ prefix })` cursor.
|
||||
- Lifecycle events (`hit`, `miss`, `set`, `delete`, `clear`, `expire`) emitted through any `EventBusLike<CacheEvents>` — listeners optional, zero-cost when omitted.
|
||||
- Structured `StorageCacheError` for all failure modes — `ENTRY_CORRUPT`, `CODEC_ERROR`, `BACKEND_ERROR`, `KEY_TOO_LONG`, `VALUE_TOO_LARGE`.
|
||||
- Stats counters — `hits`, `misses`, `evictions`, `size`, `hitRate`. Size tracked client-side via insert/delete deltas (storage backends rarely expose a cheap count).
|
||||
- Auto-registered with the `CacheFactory` registry as the `"storage"` provider on side-effect import.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { StorageCache } from "@suaveplan/cache-storage";
|
||||
import { MemoryStorage } from "@suaveplan/storage/memory";
|
||||
|
||||
const cache = new StorageCache<{ id: string; name: string }>({
|
||||
storage: new MemoryStorage(),
|
||||
defaultTtl: 60_000,
|
||||
});
|
||||
|
||||
await cache.set("user:1", { id: "1", name: "Alice" });
|
||||
const user = await cache.get("user:1");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import "@suaveplan/cache-storage"; // side-effect: registers the "storage" provider
|
||||
import { createCache } from "@suaveplan/cache";
|
||||
import { StorageCache } from "@suaveplan/cache-storage";
|
||||
import { IdentityCodec } from "@suaveplan/cache-storage/codec/identity";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { FileStorage } from "@suaveplan/storage-file";
|
||||
|
||||
const events = new EventBus();
|
||||
events.on("evict", ({ key, reason }) => log.info("evicted", { key, reason }));
|
||||
|
||||
// Construct directly:
|
||||
const direct = new StorageCache<Uint8Array>({
|
||||
storage: new FileStorage({ root: "/var/cache/app" }),
|
||||
codec: new IdentityCodec(),
|
||||
namespace: "thumbnails",
|
||||
defaultTtl: 24 * 60 * 60_000,
|
||||
events,
|
||||
});
|
||||
|
||||
// Or via the factory (same instance shape, registry-driven):
|
||||
const indirect = createCache("storage" as never, {
|
||||
storage: new FileStorage({ root: "/var/cache/app" }),
|
||||
namespace: "responses",
|
||||
} as never);
|
||||
|
||||
await direct.set("avatar:42", await renderAvatar(42));
|
||||
|
||||
// `wrap` with single-flight + SWR is inherited:
|
||||
const summary = await direct.wrap(
|
||||
`summary:${userId}`,
|
||||
() => buildSummary(userId),
|
||||
{ ttl: 60_000, staleWhileRevalidate: true, staleThreshold: 5_000 },
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constructor
|
||||
|
||||
`new StorageCache<T>(options: StorageCacheOptions<T>)`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `storage` | `AbstractStorage` | — (required) | Underlying byte-oriented storage adapter. |
|
||||
| `codec` | `Codec<T>` | `new JsonCodec<T>()` | Value codec. |
|
||||
| `defaultTtl` | `number` | `0` | TTL in ms when `set` omits one. `0` means no expiry. |
|
||||
| `clock` | `Clock` | `() => Date.now()` | Injectable time source. |
|
||||
| `events` | `EventBusLike<CacheEvents>` | `undefined` | Lifecycle event sink. |
|
||||
| `namespace` | `string` | `""` | Key prefix; empty disables. |
|
||||
|
||||
### Methods
|
||||
|
||||
Every `AbstractCacheShape<T>` method: `get`, `set`, `delete`, `clear`, `has`, `getStats`, `getMultiple`, `setMultiple`, `wrap`. See the parent `AbstractCache` documentation for the inherited helpers.
|
||||
|
||||
### Thrown errors
|
||||
|
||||
- `StorageCacheError("CACHE_STORAGE.ENTRY_CORRUPT")` — stored blob shorter than `HEADER_BYTES`, or decoded header out of range.
|
||||
- `StorageCacheError("CACHE_STORAGE.CODEC_ERROR")` — codec encode or decode failure.
|
||||
- `StorageCacheError("CACHE_STORAGE.BACKEND_ERROR")` — underlying `AbstractStorage` threw.
|
||||
- `StorageCacheError("CACHE_STORAGE.KEY_TOO_LONG")` — key longer than 1024 chars.
|
||||
- `StorageCacheError("CACHE_STORAGE.VALUE_TOO_LARGE")` — encoded value exceeds 10 MiB.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`get` performs a single `storage.get(prefixedKey)` and parses the header in place. Expired entries are deleted lazily — there is no background sweeper, which keeps the package universal (no `setInterval` dependency). The size counter is tracked through `set` and `delete` deltas; it is not authoritative when the underlying storage is shared with another writer, but cache stats are advisory in that case anyway.
|
||||
|
||||
`clear()` with no namespace defers to the storage backend's own `clear()` — fast and atomic where the backend supports it. With a namespace, it iterates `storage.list({ prefix })` in pages of 1000 and deletes each batch, which is O(n) in matching keys but bounded by namespace size.
|
||||
|
||||
`hitCount` is set to `0` on write and **not** rewritten on read. Bumping it would turn every `get` into a `getThenSet`, halving throughput; the field is reserved for a future `getPartial`-aware variant that updates the header in place when the backend supports byte-range writes.
|
||||
59
docs/persistence/cache/cache/README.md
vendored
Normal file
59
docs/persistence/cache/cache/README.md
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
# @suaveplan/cache - Type-safe caching abstraction: LRU/LFU/FIFO/TTL MemoryCache, NamespacedCache, CacheFactory, stale-while-revalidate, multi-get/set, lifecycle events, injectable clock, OTel instrumentation, and structured CACHE.* error codes.
|
||||
|
||||
## Summary
|
||||
|
||||
Type-safe caching abstraction for Suaveplan. Provides `MemoryCache` with LRU/LFU/FIFO/TTL eviction, `NamespacedCache` for shared-storage isolation, a `CacheFactory` with module-augmentation-based provider registry, stale-while-revalidate, multi-get/set, injectable clock and event bus, OTel instrumentation, and structured `CACHE.*` error codes.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cache-error](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [common](./src/types/common.md) — Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circu…
|
||||
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration b…
|
||||
<!-- end:auto-docs --> - [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache-error](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circu…
|
||||
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration b…
|
||||
- [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache-error](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circu…
|
||||
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration b…
|
||||
- [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache-error](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circu…
|
||||
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration b…
|
||||
- [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ - [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cache-error](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ - [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circu…
|
||||
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration b…
|
||||
|
||||
## Packages
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [errors/cache-error](src/errors/cache-error.md) | `CacheError` class and `CACHE_CODES` registry |
|
||||
| [types/common](src/types/common.md) | Shared TypeScript types and `CacheProviderRegistry` extension point |
|
||||
| [types/schemas](src/types/schemas.md) | Zod validation schemas for all option types |
|
||||
| [abstract/abstract-cache](src/abstract/abstract-cache/abstract-cache.md) | `AbstractCache<T>` base class with `wrap`, `getMultiple`, `setMultiple` |
|
||||
| [abstract/namespaced-cache](src/abstract/namespaced-cache/namespaced-cache.md) | `NamespacedCache<T>` key-prefix wrapper |
|
||||
| [memory/memory-cache](src/memory/memory-cache/memory-cache.md) | `MemoryCache<T>` with LRU/LFU/FIFO/TTL eviction |
|
||||
| [factory/cache-factory](src/factory/cache-factory/cache-factory.md) | `CacheFactory` and free-function registry API |
|
||||
| [factory/registry](src/factory/registry/registry.md) | Built-in memory provider registration |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { MemoryCache } from "@suaveplan/cache";
|
||||
|
||||
const cache = new MemoryCache<string>({ maxSize: 500, defaultTTL: 60_000 });
|
||||
await cache.set("user:1", "Alice");
|
||||
const alice = await cache.get("user:1"); // "Alice"
|
||||
|
||||
// wrap — get-or-compute
|
||||
const data = await cache.wrap("expensive:key", async () => {
|
||||
return await fetchSomeData();
|
||||
}, { ttl: 30_000 });
|
||||
```
|
||||
88
docs/persistence/cache/cache/src/abstract/abstract-cache/abstract-cache.md
vendored
Normal file
88
docs/persistence/cache/cache/src/abstract/abstract-cache/abstract-cache.md
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# abstract-cache
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`AbstractCache<T>` is the base class for all cache implementations in `@suaveplan/cache`. It defines the six primitive abstract methods that every concrete cache must implement, and provides three composite helpers (`wrap`, `getMultiple`, `setMultiple`) that delegate to the primitives. This design separates the concern of cache mechanics (eviction, TTL, storage) from common access patterns, reducing code duplication across implementations.
|
||||
|
||||
## Features
|
||||
|
||||
- Six abstract primitive operations: `get`, `set`, `delete`, `clear`, `has`, `getStats`
|
||||
- `getMultiple(keys)` — fetches multiple keys concurrently, returns `{ found, missing }`
|
||||
- `setMultiple(entries)` — stores multiple entries concurrently with per-entry options
|
||||
- `wrap(key, fn, options?)` — get-or-compute with optional `forceRefresh` and `shouldCache` predicate
|
||||
- AbortSignal support on `wrap` via `options.signal`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { AbstractCache } from "@suaveplan/cache/abstract/abstract-cache";
|
||||
import type { CacheStats } from "@suaveplan/cache/types/common";
|
||||
|
||||
class MyCache extends AbstractCache<string> {
|
||||
readonly store = new Map<string, string>();
|
||||
async get(key: string) { return this.store.get(key); }
|
||||
async set(key: string, value: string) { this.store.set(key, value); }
|
||||
async delete(key: string) { return this.store.delete(key); }
|
||||
async clear() { this.store.clear(); }
|
||||
async has(key: string) { return this.store.has(key); }
|
||||
getStats(): CacheStats { return { hits: 0, misses: 0, size: this.store.size, hitRate: 0, evictions: 0 }; }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemoryCache } from "@suaveplan/cache/memory/memory-cache";
|
||||
|
||||
const cache = new MemoryCache<string>({ maxSize: 100 });
|
||||
|
||||
const value = await cache.wrap("api:user:1", async () => {
|
||||
const resp = await fetch("/api/user/1");
|
||||
return resp.text();
|
||||
}, { ttl: 30_000, shouldCache: (v) => v.length > 0 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `get(key: string): Promise<T | undefined>` (abstract)
|
||||
|
||||
Returns value or `undefined` on miss/expiry.
|
||||
|
||||
### `set(key, value, options?): Promise<void>` (abstract)
|
||||
|
||||
Stores a value. `options.ttl` overrides the default TTL.
|
||||
|
||||
### `delete(key): Promise<boolean>` (abstract)
|
||||
|
||||
Returns `true` if the key was present.
|
||||
|
||||
### `clear(): Promise<void>` (abstract)
|
||||
|
||||
Removes all entries.
|
||||
|
||||
### `has(key): Promise<boolean>` (abstract)
|
||||
|
||||
Returns `true` if present and not expired.
|
||||
|
||||
### `getStats(): CacheStats` (abstract)
|
||||
|
||||
Returns statistics snapshot.
|
||||
|
||||
### `getMultiple(keys): Promise<CacheMultiGetResult<T>>`
|
||||
|
||||
Concurrent multi-fetch. `found.size + missing.length === keys.length`.
|
||||
|
||||
### `setMultiple(entries): Promise<void>`
|
||||
|
||||
Concurrent multi-store.
|
||||
|
||||
### `wrap(key, fn, options?): Promise<T>`
|
||||
|
||||
Get-or-compute. `forceRefresh: true` bypasses cache check.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getMultiple` uses `Promise.all` for concurrent fetches. The `wrap` method's `shouldCache` predicate runs on both the cached value (to decide whether to serve a cached hit that no longer satisfies the predicate) and the freshly computed value (to decide whether to store it). AbortSignal is checked at the start of `wrap` before any async operations.
|
||||
68
docs/persistence/cache/cache/src/abstract/namespaced-cache/namespaced-cache.md
vendored
Normal file
68
docs/persistence/cache/cache/src/abstract/namespaced-cache/namespaced-cache.md
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
# namespaced-cache
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`NamespacedCache<T>` wraps any `AbstractCache` instance and transparently prefixes every key with a `namespace + separator` string. This enables multiple logical caches to share a single underlying storage (e.g. one `MemoryCache`) without key collisions, without requiring any changes to existing cache consumers.
|
||||
|
||||
The namespace prefix is stripped from keys returned by `getMultiple.missing` so callers always see their original key strings, not the internal prefixed form.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps any `AbstractCache<T>` via composition
|
||||
- Configurable separator (default `":"`)
|
||||
- Transparent prefix/unprefix for `getMultiple` results
|
||||
- Delegates `clear()` to the underlying cache (clears all namespaces)
|
||||
- `wrap()` delegates to the inner cache with the prefixed key
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryCache } from "@suaveplan/cache/memory/memory-cache";
|
||||
import { NamespacedCache } from "@suaveplan/cache/abstract/namespaced-cache";
|
||||
|
||||
const shared = new MemoryCache<string>({ maxSize: 1000 });
|
||||
const userCache = new NamespacedCache(shared, "user");
|
||||
const sessionCache = new NamespacedCache(shared, "session");
|
||||
|
||||
await userCache.set("alice", "data");
|
||||
await sessionCache.set("alice", "sess-data");
|
||||
// Keys in shared cache: "user:alice", "session:alice"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { NamespacedCache } from "@suaveplan/cache/abstract/namespaced-cache";
|
||||
import { MemoryCache } from "@suaveplan/cache/memory/memory-cache";
|
||||
|
||||
const base = new MemoryCache<Buffer>({ maxSize: 500 });
|
||||
const images = new NamespacedCache(base, "img", "::");
|
||||
|
||||
const result = await images.getMultiple(["logo.png", "hero.jpg"]);
|
||||
// result.missing => ["logo.png", "hero.jpg"] (original keys, not "img::logo.png")
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(inner, namespace, separator?)`
|
||||
|
||||
- `inner` — backing `AbstractCache<T>`
|
||||
- `namespace` — prefix string (e.g. `"user"`, `"session"`)
|
||||
- `separator` — default `":"`
|
||||
|
||||
### All `AbstractCache` methods
|
||||
|
||||
All operations are transparently delegated to `inner` after key prefixing.
|
||||
|
||||
`getMultiple` strips the prefix from returned keys in `found` and `missing`.
|
||||
|
||||
### `_namespaced(key): string` (protected)
|
||||
|
||||
Returns `${prefix}${key}`. Accessible to subclasses.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`clear()` clears the entire underlying cache, not just this namespace's keys. This is intentional — a `NamespacedCache` shares storage, so there is no efficient way to clear only one namespace without scanning all keys. Callers who need isolated clear semantics should use separate cache instances.
|
||||
62
docs/persistence/cache/cache/src/errors/cache-error.md
vendored
Normal file
62
docs/persistence/cache/cache/src/errors/cache-error.md
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
# cache-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines `CacheError` and the `CACHE_CODES` registry for all structured errors emitted by `@suaveplan/cache`. Every throw site in the package uses a stable code from this registry, allowing callers to distinguish failure modes (key validation errors, capacity overflows, missing providers, etc.) without string matching on error messages. `CacheError` extends `SuaveplanError` for compatibility with the `@suaveplan/error` cause-chain traversal, wire serialisation, and OTel span-event adapter.
|
||||
|
||||
## Features
|
||||
|
||||
- Six stable `CACHE.*` codes covering key validation, capacity, provider lookup, set/get failures, and serialisation errors
|
||||
- `CacheError` extends `SuaveplanError<CacheCode>` with a public constructor
|
||||
- Codes are registered globally at import time via `defineCodes`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CacheError, CACHE_CODES } from "@suaveplan/cache/errors/cache-error";
|
||||
|
||||
throw new CacheError(CACHE_CODES.KEY_INVALID, "cache key must not be empty");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CacheError, CACHE_CODES } from "@suaveplan/cache/errors/cache-error";
|
||||
|
||||
try {
|
||||
await cache.set("", value);
|
||||
} catch (err) {
|
||||
if (err instanceof CacheError && err.code === CACHE_CODES.KEY_INVALID) {
|
||||
console.warn("empty key rejected:", err.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CACHE_CODES`
|
||||
|
||||
`Record<string, CacheCode>` — stable code dictionary.
|
||||
|
||||
| Key | Value |
|
||||
|---|---|
|
||||
| `KEY_INVALID` | `"CACHE.KEY_INVALID"` |
|
||||
| `CAPACITY_EXCEEDED` | `"CACHE.CAPACITY_EXCEEDED"` |
|
||||
| `PROVIDER_NOT_FOUND` | `"CACHE.PROVIDER_NOT_FOUND"` |
|
||||
| `SET_FAILED` | `"CACHE.SET_FAILED"` |
|
||||
| `GET_FAILED` | `"CACHE.GET_FAILED"` |
|
||||
| `SERIALIZATION_FAILED` | `"CACHE.SERIALIZATION_FAILED"` |
|
||||
|
||||
### `CacheError`
|
||||
|
||||
```ts
|
||||
class CacheError extends SuaveplanError<CacheCode>
|
||||
constructor(code: CacheCode, message: string, options?: SuaveplanErrorOptions)
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `biome-ignore` comment suppresses the `noUselessConstructor` lint rule, which does not account for the protected base constructor pattern in `SuaveplanError`. The six codes map to the six error categories identified in the `enterprise-cache` OpenSpec proposal.
|
||||
81
docs/persistence/cache/cache/src/factory/cache/cache.md
vendored
Normal file
81
docs/persistence/cache/cache/src/factory/cache/cache.md
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
# cache-factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`CacheFactory` and its companion free functions provide a central registry for cache provider types. The factory decouples cache consumers from concrete implementations: callers request a cache by type string (`"memory"`, `"redis"`, etc.) rather than importing implementation classes directly. Third-party backends register themselves once via `registerCacheProvider` and become available throughout the application without modifying this package.
|
||||
|
||||
The `"memory"` provider is registered by default. Module augmentation of `CacheProviderRegistry` in `@suaveplan/cache/types/common` gives compile-time safety for registered types.
|
||||
|
||||
## Features
|
||||
|
||||
- `CacheFactory.create(type, config?)` — creates a cache instance by type string
|
||||
- `CacheFactory.register / unregister / has / providers` — static registry management
|
||||
- Free-function aliases: `createCache`, `createCacheWithConfig`, `registerCacheProvider`, etc.
|
||||
- `clearCacheProviders()` — removes all registrations (useful in tests)
|
||||
- `"memory"` is pre-registered and always available
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createCache } from "@suaveplan/cache/factory/cache";
|
||||
|
||||
const cache = createCache("memory", { maxSize: 500, defaultTTL: 60_000 });
|
||||
await cache.set("key", "value");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createCacheWithConfig,
|
||||
registerCacheProvider,
|
||||
} from "@suaveplan/cache/factory/cache";
|
||||
import type { CacheProviderImplementation } from "@suaveplan/cache/types/common";
|
||||
|
||||
// Register a custom provider
|
||||
class MyCache { /* ... */ }
|
||||
registerCacheProvider("custom", MyCache as unknown as CacheProviderImplementation);
|
||||
|
||||
const cache = createCacheWithConfig({ type: "custom", options: { maxSize: 100 } });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CacheFactory.create<T>(type, config?): AbstractCacheShape<T>`
|
||||
|
||||
Creates a cache of the named type. Throws `CacheError(PROVIDER_NOT_FOUND)` if unregistered.
|
||||
|
||||
### `CacheFactory.register(type, impl): void`
|
||||
|
||||
Registers a provider. Replaces existing.
|
||||
|
||||
### `CacheFactory.unregister(type): void`
|
||||
|
||||
Removes a provider.
|
||||
|
||||
### `CacheFactory.has(type): boolean`
|
||||
|
||||
Returns `true` if registered.
|
||||
|
||||
### `CacheFactory.providers(): string[]`
|
||||
|
||||
Returns all registered type names.
|
||||
|
||||
### `createCache<T>(type, config?)`
|
||||
|
||||
Free function alias for `CacheFactory.create`.
|
||||
|
||||
### `createCacheWithConfig<T>(config)`
|
||||
|
||||
Creates from `{ type, options? }` config object.
|
||||
|
||||
### `registerCacheProvider / unregisterCacheProvider / hasCacheProvider / getCacheProviders / clearCacheProviders`
|
||||
|
||||
Free-function aliases for the static methods.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses a module-level `Map` to persist across calls within the same module instance. `clearCacheProviders` removes ALL entries including the built-in `"memory"` registration; tests that call `clearCacheProviders` should re-register `MemoryCache` if they need it afterwards.
|
||||
42
docs/persistence/cache/cache/src/factory/registry/registry.md
vendored
Normal file
42
docs/persistence/cache/cache/src/factory/registry/registry.md
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Performs the side-effect of registering the built-in `"memory"` cache provider into `CacheFactory` at import time. This module is imported once by the package barrel so all consumers that import from `@suaveplan/cache` automatically have the memory provider available without explicit registration calls.
|
||||
|
||||
The registration is idempotent: calling `registerCacheProvider("memory", MemoryCache)` on an already-registered entry simply replaces it with the same value.
|
||||
|
||||
## Features
|
||||
|
||||
- Registers `MemoryCache` as `"memory"` in `CacheFactory`
|
||||
- Side-effect import — no exports needed
|
||||
- Idempotent registration
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// Importing the package barrel automatically executes this registration
|
||||
import { createCache } from "@suaveplan/cache";
|
||||
const cache = createCache("memory"); // works without explicit registration
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
// Third-party packages can follow this pattern to register their own providers
|
||||
import { registerCacheProvider } from "@suaveplan/cache/factory/cache";
|
||||
import { RedisCache } from "./redis-cache.js";
|
||||
|
||||
registerCacheProvider("redis", RedisCache);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
No exports. This module is a side-effect-only registration module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `registry.ts` module is kept separate from `cache-factory.ts` to avoid circular imports. The factory module must not import concrete implementations; only the registry wires them together. This separation follows the same pattern used in `@suaveplan/db` where singleton helpers are distinct from adapter definitions.
|
||||
97
docs/persistence/cache/cache/src/memory/memory-cache/memory-cache.md
vendored
Normal file
97
docs/persistence/cache/cache/src/memory/memory-cache/memory-cache.md
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
# memory-cache
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MemoryCache<T>` is the primary in-process cache implementation. It provides four interchangeable eviction policies — LRU (Least Recently Used), LFU (Least Frequently Used), FIFO (First-In-First-Out), and TTL (time-based expiry with no frequency ordering) — selected at construction time. All policies support TTL-based expiry via an injectable `Clock`, enabling deterministic testing without real timers.
|
||||
|
||||
The implementation prioritises O(1) complexity for the hot paths: LRU uses a doubly-linked list + Map for O(1) get and set; LFU uses a frequency Map and min-frequency pointer for O(1) amortised eviction; FIFO uses an insertion-order array + Map for O(1) get and amortised O(1) set.
|
||||
|
||||
## Features
|
||||
|
||||
- Four eviction policies: `lru`, `lfu`, `fifo`, `ttl`
|
||||
- Injectable `Clock` for deterministic TTL testing
|
||||
- Optional `EventBusLike` for lifecycle events (hit, miss, set, delete, evict, expire, clear)
|
||||
- Lazy TTL expiry on `get`/`has` calls with optional periodic cleanup
|
||||
- `destroy()` to cancel background timers
|
||||
- Key validation: rejects empty keys and keys exceeding `MAX_KEY_LENGTH` (1024)
|
||||
- `getStats()` returns `{ hits, misses, size, hitRate, evictions }`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryCache } from "@suaveplan/cache/memory/memory-cache";
|
||||
|
||||
const cache = new MemoryCache<string>({ maxSize: 500, defaultTTL: 60_000 });
|
||||
await cache.set("user:1", "Alice");
|
||||
const val = await cache.get("user:1"); // "Alice"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemoryCache } from "@suaveplan/cache/memory/memory-cache";
|
||||
|
||||
// Inject a fake clock for deterministic TTL tests
|
||||
let now = 1000;
|
||||
const clock = { now: () => now };
|
||||
const cache = new MemoryCache<string>({ defaultTTL: 100, evictionPolicy: "lru" }, clock);
|
||||
|
||||
await cache.set("key", "value");
|
||||
now += 200; // advance past TTL
|
||||
console.log(await cache.get("key")); // undefined — expired
|
||||
|
||||
// LFU example
|
||||
const lfu = new MemoryCache<number>({ maxSize: 3, evictionPolicy: "lfu" }, clock);
|
||||
await lfu.set("a", 1);
|
||||
await lfu.set("b", 2);
|
||||
await lfu.set("c", 3);
|
||||
await lfu.get("a"); // freq(a) = 1
|
||||
await lfu.get("a"); // freq(a) = 2
|
||||
await lfu.set("d", 4); // evicts "b" or "c" (lowest freq)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(options?, clock?, events?)`
|
||||
|
||||
- `options.maxSize` — default `1000`
|
||||
- `options.defaultTTL` — ms, default `0` (no expiry)
|
||||
- `options.evictionPolicy` — `"lru" | "lfu" | "fifo" | "ttl"`, default `"lru"`
|
||||
- `options.checkExpirationInterval` — ms between eager cleanup passes; `0` means lazy-only
|
||||
- `clock` — injectable `{ now: () => number }`
|
||||
- `events` — optional `EventBusLike` for lifecycle events
|
||||
|
||||
### `get(key): Promise<T | undefined>`
|
||||
|
||||
O(1) for LRU/LFU. Evicts expired entries lazily.
|
||||
|
||||
### `set(key, value, options?): Promise<void>`
|
||||
|
||||
O(1) for LRU/LFU. `options.ttl` overrides default.
|
||||
|
||||
### `delete(key): Promise<boolean>`
|
||||
|
||||
Returns `true` if key existed.
|
||||
|
||||
### `clear(): Promise<void>`
|
||||
|
||||
Removes all entries from all policy maps.
|
||||
|
||||
### `has(key): Promise<boolean>`
|
||||
|
||||
Returns `true` if present and not expired. Does NOT increment hit counter.
|
||||
|
||||
### `getStats(): CacheStats`
|
||||
|
||||
Synchronous snapshot. `hitRate = hits / (hits + misses)`.
|
||||
|
||||
### `destroy(): void`
|
||||
|
||||
Cancels background cleanup timer. Call on graceful shutdown.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The LRU doubly-linked list tracks `head` (most recently used) and `tail` (least recently used). On `get`, the accessed node is spliced out and prepended to head in O(1). On eviction, the tail is removed. The LFU implementation maintains a `_lfuFreqMap` (frequency → ordered Set of keys) and a `_lfuMinFreq` pointer. When a key is accessed its frequency is promoted and the min-freq pointer may increment; on eviction the min-freq bucket's first key is removed.
|
||||
76
docs/persistence/cache/cache/src/types/common.md
vendored
Normal file
76
docs/persistence/cache/cache/src/types/common.md
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# common
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circular imports and enables tree-shaking of runtime code when consumers only import type-level constructs. The `CacheProviderRegistry` interface is an intentional module-augmentation extension point: third-party packages can extend it to register their own provider types at compile time without modifying this package.
|
||||
|
||||
## Features
|
||||
|
||||
- `CacheEvictionPolicy` — `"lru" | "lfu" | "fifo" | "ttl"` discriminant
|
||||
- `CacheEntry<T>` — value with TTL, creation, access, and hit-count metadata
|
||||
- `CacheStats` — hits, misses, size, hit rate, optional memory usage, evictions
|
||||
- `CacheOptions` — capacity, TTL, eviction policy, clock injection
|
||||
- `CacheSetOptions` / `CacheWrapOptions` — per-operation overrides
|
||||
- `CacheEvents` — typed event map for `EventBusLike` lifecycle emission
|
||||
- `CacheMultiGetResult` / `CacheMultiSetEntry` — bulk operation types
|
||||
- `CacheProviderRegistry` — module-augmentation extension interface
|
||||
- `CacheTransformHook<T>` — value transform pipeline entry
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { CacheOptions } from "@suaveplan/cache/types/common";
|
||||
|
||||
const opts: CacheOptions = {
|
||||
maxSize: 500,
|
||||
defaultTTL: 60_000,
|
||||
evictionPolicy: "lru",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CacheMultiGetResult,
|
||||
CacheWrapOptions,
|
||||
} from "@suaveplan/cache/types/common";
|
||||
|
||||
const wrapOpts: CacheWrapOptions<string> = {
|
||||
ttl: 30_000,
|
||||
staleWhileRevalidate: true,
|
||||
staleThreshold: 5_000,
|
||||
shouldCache: (v) => v.length > 0,
|
||||
};
|
||||
|
||||
const result: CacheMultiGetResult<string> = {
|
||||
found: new Map([["key1", "value1"]]),
|
||||
missing: ["key2"],
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CacheEvictionPolicy`
|
||||
|
||||
`"lru" | "lfu" | "fifo" | "ttl"` — selects the eviction algorithm.
|
||||
|
||||
### `CacheEntry<T>`
|
||||
|
||||
`{ value, expiresAt, createdAt, lastAccessedAt, hitCount }` — internal entry shape.
|
||||
|
||||
### `CacheStats`
|
||||
|
||||
`{ hits, misses, size, hitRate, memoryUsage?, evictions }` — snapshot statistics.
|
||||
|
||||
### `CacheOptions`
|
||||
|
||||
See fields: `maxSize`, `defaultTTL`, `evictionPolicy`, `enableStats`, `checkExpirationInterval`, `clock`.
|
||||
|
||||
### `CacheProviderRegistry`
|
||||
|
||||
Empty interface — extend via module augmentation to register custom provider types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `CacheProviderRegistry` interface is intentionally empty to allow clean module augmentation. The `AbstractCacheShape<T>` interface provides the minimal shape that `CacheFactory` needs to instantiate providers without importing the concrete `AbstractCache` class.
|
||||
63
docs/persistence/cache/cache/src/types/schemas.md
vendored
Normal file
63
docs/persistence/cache/cache/src/types/schemas.md
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
# schemas
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration boundaries, catching invalid TTL values (negative numbers), unknown eviction policies, or extra keys in options objects before they propagate into the cache internals. Using Zod ensures type-safe runtime validation that stays in sync with the TypeScript interfaces in `common.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `CacheEvictionPolicySchema` — validates `"lru" | "lfu" | "fifo" | "ttl"` strings
|
||||
- `CacheOptionsSchema` — validates cache construction options (strict, no extra keys)
|
||||
- `CacheSetOptionsSchema` — validates per-set TTL and stale-while-revalidate options
|
||||
- `CacheWrapOptionsSchema` — validates `wrap()` options
|
||||
- `CacheFactoryConfigSchema` — validates factory config including type string
|
||||
- `CacheTransformHookSchema` — validates hook name and priority (function excluded)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CacheOptionsSchema } from "@suaveplan/cache/types/schemas";
|
||||
|
||||
const opts = CacheOptionsSchema.parse({ maxSize: 500, defaultTTL: 60_000 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CacheFactoryConfigSchema } from "@suaveplan/cache/types/schemas";
|
||||
|
||||
const cfg = CacheFactoryConfigSchema.safeParse({ type: "memory", options: { maxSize: 100 } });
|
||||
if (!cfg.success) {
|
||||
console.error(cfg.error.flatten());
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CacheEvictionPolicySchema`
|
||||
|
||||
`z.enum(["lru", "lfu", "fifo", "ttl"])`.
|
||||
|
||||
### `CacheOptionsSchema`
|
||||
|
||||
Strict. Optional fields: `maxSize` (positive int), `defaultTTL` (non-negative), `evictionPolicy`, `enableStats`, `checkExpirationInterval`.
|
||||
|
||||
### `CacheSetOptionsSchema`
|
||||
|
||||
Strict. Optional fields: `ttl` (non-negative), `staleWhileRevalidate`, `staleThreshold`.
|
||||
|
||||
### `CacheWrapOptionsSchema`
|
||||
|
||||
Strict. Optional fields: `ttl`, `staleWhileRevalidate`, `staleThreshold`, `forceRefresh`.
|
||||
|
||||
### `CacheFactoryConfigSchema`
|
||||
|
||||
Strict. Required `type` (non-empty string), optional `options`.
|
||||
|
||||
### `CacheTransformHookSchema`
|
||||
|
||||
Strict. Required `name`, optional `priority` (int). The `transform` function is excluded from the schema as Zod cannot validate function shapes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All option schemas use `.strict()` to reject extra keys. This surfaces typos in configuration objects early and prevents silent misconfigurations from passing validation. The `CacheTransformHookSchema` intentionally omits the `transform` field since Zod v4 can parse but not meaningfully validate function types.
|
||||
17
docs/persistence/database/README.md
Normal file
17
docs/persistence/database/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# `database`
|
||||
|
||||
Database adapters, migrations, seeding, and SQLite support.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `database`
|
||||
|
||||
5 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/db`](./db/README.md) | Universal relational database adapter interface for Suaveplan. Provides the `DatabaseAdapter` contract (extending `DatabaseLike` from `@suaveplan/types`), inje… |
|
||||
| [`@suaveplan/db-sqlite`](./db-sqlite/README.md) | Universal SQLite adapter for Suaveplan. Runs on `bun:sqlite` on the Bun server and `@sqlite.org/sqlite-wasm` (Web Worker) in the browser, unified behind a sing… |
|
||||
| [`@suaveplan/migration`](./migration/README.md) | Database-agnostic schema migration runner with ledger tracking, checksum validation, dry-run reporting, distributed locking, event bus integration, and OTel in… |
|
||||
| [`@suaveplan/seed`](./seed/README.md) | Universal declarative database seeding and fixture framework. Define seed data once, let the runner handle dependency ordering, environment filtering, idempote… |
|
||||
| [`@suaveplan/surrealdb`](./surrealdb/README.md) | Universal SurrealDB client wrapper for Suaveplan. Implements `@suaveplan/db`'s `DatabaseAdapter` interface with connection lifecycle management, typed CRUD, sc… |
|
||||
<!-- end:auto-docs -->
|
||||
1
docs/persistence/database/_intro.md
Normal file
1
docs/persistence/database/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Database adapters, migrations, seeding, and SQLite support.
|
||||
110
docs/persistence/database/db-sqlite/README.md
Normal file
110
docs/persistence/database/db-sqlite/README.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# @suaveplan/db-sqlite - Universal SQLite adapter for Suaveplan: bun:sqlite on the Bun server, @sqlite.org/sqlite-wasm in the browser, unified behind a Drizzle-ORM surface with LRU prepared-statement cache, forward-only migration runner, OTel instrumentation, and structured SQLITE.* error codes.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal SQLite adapter for Suaveplan. Runs on `bun:sqlite` on the Bun server and `@sqlite.org/sqlite-wasm` (Web Worker) in the browser, unified behind a single `SqliteAdapter` that implements `DatabaseAdapter` from `@suaveplan/db`. Includes an LRU prepared-statement cache (128 entries by default), a forward-only ledger-tracked `MigrationRunner` with dry-run support, OpenTelemetry span instrumentation, structured `SQLITE_*` error codes, and a `./unsafe` subpath for raw queries.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
|
||||
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
|
||||
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
|
||||
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
|
||||
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the……
|
||||
|
||||
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
|
||||
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
|
||||
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the…
|
||||
|
||||
- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- adapter.wasm — Browser-runtime entry point for the…
|
||||
- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
|
||||
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
|
||||
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapter.bun](./src/adapter/adapter.bun.md) — Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun run…
|
||||
- [adapter](./src/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapter.wasm](./src/adapter/adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [conformance](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_ - [conformance-suite](./src/conformance/conformance-suite.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ - [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-driver](./src/drivers/bun-driver.md) — Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlit…
|
||||
- [wasm-driver](./src/drivers/wasm-driver.md) — Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running…
|
||||
- [wasm-worker](./src/drivers/wasm-worker.md) — Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `cre…
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ - [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_ - [unsafe](./src/unsafe/unsafe.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/db-sqlite
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createSqliteAdapter } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const db = await createSqliteAdapter({ url: ":memory:" });
|
||||
|
||||
await db.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await db.run("INSERT INTO users (id, email) VALUES (?, ?)", [
|
||||
"user-123",
|
||||
"alice@example.com",
|
||||
]);
|
||||
|
||||
const rows = await db.all<{ id: string; email: string }>(
|
||||
"SELECT id, email FROM users WHERE id = ?",
|
||||
["user-123"],
|
||||
);
|
||||
|
||||
await db.close();
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [Adapter (universal)](./src/adapter/adapter.md) — `SqliteAdapter`, `createSqliteAdapter`, runtime selection
|
||||
- [Adapter (Bun)](./src/adapter/adapter.bun.md) — `bun:sqlite` driver binding
|
||||
- [Adapter (WASM)](./src/adapter/adapter.wasm.md) — browser/Worker `@sqlite.org/sqlite-wasm` binding
|
||||
- [Cache](./src/cache/cache.md) — `PreparedStatementCache` (LRU)
|
||||
- [Connection](./src/connection/connection.md) — connection helpers and singleton management
|
||||
- [Bun driver](./src/drivers/bun-driver.md) — low-level Bun SQLite driver
|
||||
- [WASM driver](./src/drivers/wasm-driver.md) — low-level WASM SQLite driver
|
||||
- [WASM worker](./src/drivers/wasm-worker.md) — Web Worker host for WASM driver
|
||||
- [Errors](./src/errors/errors.md) — `SqliteError`, `SQLITE_*` codes
|
||||
- [Migration](./src/migration/migration.md) — `MigrationRunner`, forward-only migration ledger
|
||||
- [OTel](./src/otel/otel.md) — span and metric instrumentation
|
||||
- [Schemas](./src/schemas/schemas.md) — Zod-validated config types
|
||||
- [Conformance](./src/conformance/conformance-suite.md) — shared `DatabaseAdapter` conformance suite
|
||||
- [Unsafe](./src/unsafe/unsafe.md) — `./unsafe` subpath for raw queries
|
||||
@@ -0,0 +1,61 @@
|
||||
# adapter.bun
|
||||
|
||||
## Purpose
|
||||
|
||||
Bun-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a bundler or the Bun runtime resolves `import { SqliteAdapter } from "@suaveplan/db-sqlite/adapter"`, this file is selected via the `"bun"` condition in the package's `exports` field. It re-exports `SqliteAdapter` and the `SqliteAdapterOptions` type so consumers see a single, stable surface regardless of which environment condition matched.
|
||||
|
||||
The separation between adapter entry points prevents browser bundles from pulling in `bun:sqlite` references, and prevents Bun server bundles from pulling in WASM worker code. Each entry is a thin barrel that resolves at bundle time.
|
||||
|
||||
## Features
|
||||
|
||||
- Conditional export target for the `"bun"` condition in `package.json`
|
||||
- Re-exports `SqliteAdapter` from `./adapter.js`
|
||||
- Re-exports `SqliteAdapterOptions` type from `../schemas/index.js`
|
||||
- Zero runtime overhead — pure re-export barrel
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// Resolved automatically by Bun via conditional exports
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const db = new SqliteAdapter();
|
||||
await db.connect();
|
||||
const rows = await db.query("SELECT 1 AS n");
|
||||
await db.disconnect();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite/adapter";
|
||||
import type { SqliteAdapterOptions } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const opts: SqliteAdapterOptions = {
|
||||
filename: "/var/data/app.db",
|
||||
readonly: false,
|
||||
};
|
||||
|
||||
const adapter = new SqliteAdapter(opts);
|
||||
await adapter.connect();
|
||||
|
||||
await adapter.migrate({ migrationsFolder: "./migrations" });
|
||||
|
||||
await adapter.transaction(async (tx) => {
|
||||
await tx.execute("INSERT INTO users (name) VALUES (?)", ["Alice"]);
|
||||
});
|
||||
|
||||
await adapter.disconnect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This file does not export any symbols directly — it is a conditional-export barrel. All types and values are documented in [`adapter.md`](./adapter.md) (`SqliteAdapter`) and [`../schemas/schemas.md`](../schemas/schemas.md) (`SqliteAdapterOptions`).
|
||||
|
||||
**Conditional export condition:** `"bun"` — selected when the consumer runs under the Bun runtime or when a bundler sets the `bun` condition (e.g. via `resolve.conditions: ["bun"]` in Vite).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The file intentionally contains no logic. Any change to the Bun-specific adapter behaviour lives in `adapter.ts`, not here. This design keeps the conditional export system orthogonal to implementation: adding a new runtime condition (e.g. `"deno"`) only requires adding a new entry-point barrel and a new `exports` condition, with no changes to the core adapter code.
|
||||
|
||||
Vite excludes this file from the default ESM browser bundle because the `"browser"` condition resolves to `adapter.wasm.ts` instead.
|
||||
113
docs/persistence/database/db-sqlite/src/adapter/adapter.md
Normal file
113
docs/persistence/database/db-sqlite/src/adapter/adapter.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - adapter.wasm — Browser-runtime entry point for the……
|
||||
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- adapter.wasm — Browser-runtime entry point for the……
|
||||
|
||||
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
|
||||
- adapter.wasm — Browser-runtime entry point for the……
|
||||
|
||||
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
|
||||
- adapter.wasm — Browser-runtime entry point for the…
|
||||
|
||||
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
- [adapter.wasm](./adapter.wasm.md) — Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler re…
|
||||
|
||||
## Purpose
|
||||
|
||||
`SqliteAdapter` is the main `DatabaseAdapter` implementation for `@suaveplan/db-sqlite`. It implements the full `DatabaseAdapter` contract from `@suaveplan/db`, backing it with a `bun:sqlite` database instance, an LRU `PreparedStatementCache`, a `MigrationRunner`, and OTel span instrumentation on every public operation.
|
||||
|
||||
The adapter is designed for the Bun server runtime only. Attempting to construct it outside Bun throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` immediately. Browser callers should use `createWasmDriver` directly via the `./adapter` conditional export, which resolves to `adapter.wasm.ts` in browser bundles.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements `DatabaseAdapter` from `@suaveplan/db` — fully substitutable wherever `DatabaseLike` is expected
|
||||
- Idempotent `connect()` — concurrent callers share one in-flight connection promise
|
||||
- `PreparedStatementCache` (LRU, default 128 entries) reused across `query()` calls
|
||||
- `transaction()` uses `BEGIN IMMEDIATE` to prevent deadlocks on concurrent writes; auto-rollback on failure
|
||||
- `migrate()` delegates to `MigrationRunner` and invalidates the cache on success
|
||||
- `AbortSignal` respected on every public method — throws `SqliteError(SQLITE.*)` when aborted
|
||||
- All operations wrapped in `withQuerySpan` for OTel tracing and latency metrics
|
||||
- `sideEffects: false` — safe for tree-shaking
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite";
|
||||
|
||||
const adapter = new SqliteAdapter({ filename: ":memory:" });
|
||||
await adapter.connect();
|
||||
|
||||
const rows = await adapter.query<{ id: number }>(
|
||||
"SELECT id FROM users WHERE active = ?",
|
||||
[1]
|
||||
);
|
||||
await adapter.disconnect();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite";
|
||||
|
||||
const adapter = new SqliteAdapter({ filename: "/data/app.db" });
|
||||
await adapter.connect();
|
||||
|
||||
// Transactional insert
|
||||
await adapter.transaction(async (tx) => {
|
||||
await tx.execute("INSERT INTO accounts VALUES (?, ?)", [1, "Alice"]);
|
||||
await tx.execute("INSERT INTO accounts VALUES (?, ?)", [2, "Bob"]);
|
||||
});
|
||||
|
||||
// Run migrations
|
||||
const result = await adapter.migrate({
|
||||
migrationsFolder: "./db/migrations",
|
||||
});
|
||||
console.log(`Applied ${result.applied} migration(s)`);
|
||||
|
||||
await adapter.disconnect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new SqliteAdapter(options?, schema?)`
|
||||
|
||||
Validates options, throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` outside Bun.
|
||||
|
||||
### `connect(config?, opts?): Promise<void>`
|
||||
|
||||
Opens the SQLite file or `:memory:` database. Idempotent under concurrent calls — only one connection is opened.
|
||||
|
||||
### `disconnect(opts?): Promise<void>`
|
||||
|
||||
Closes the database, invalidates the prepared-statement cache, and resets connection state.
|
||||
|
||||
### `query<T>(sql, params?, opts?): Promise<T[]>`
|
||||
|
||||
Executes a `SELECT` query. Uses the prepared-statement cache for repeated SQL strings.
|
||||
|
||||
### `execute(sql, params?, opts?): Promise<{ rowsAffected: number }>`
|
||||
|
||||
Executes a `INSERT`/`UPDATE`/`DELETE` statement. Returns affected row count.
|
||||
|
||||
### `transaction<T>(fn, opts?): Promise<T>`
|
||||
|
||||
Runs `fn` inside a `BEGIN IMMEDIATE` transaction. Commits on success, rolls back on failure.
|
||||
|
||||
### `migrate(config?, opts?): Promise<MigrationResult>`
|
||||
|
||||
Delegates to `MigrationRunner` and invalidates the prepared-statement cache on success.
|
||||
|
||||
### `isConnected: boolean`
|
||||
|
||||
`true` after `connect()` and before `disconnect()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `_connectingPromise` field serialises concurrent `connect()` calls — the second caller awaits the same in-flight promise rather than opening a second connection. This is safe because `bun:sqlite` uses synchronous I/O under the covers and does not support concurrent connections to the same file.
|
||||
|
||||
`transaction()` casts `this` to `DatabaseLike` when passing it to `fn`. This is sound because `SqliteAdapter` implements all `DatabaseLike` methods. The cast avoids exposing internal `_sqlite` state to transaction callbacks while keeping the type constraint tight.
|
||||
@@ -0,0 +1,64 @@
|
||||
# adapter.wasm
|
||||
|
||||
## Purpose
|
||||
|
||||
Browser-runtime entry point for the `./adapter` conditional export in `@suaveplan/db-sqlite`. When a browser bundler resolves `import { createWasmDriver } from "@suaveplan/db-sqlite/adapter"`, this file is selected via the `"browser"` condition in the package's `exports` field. It re-exports the WASM-backed driver factory and its related types, giving browser consumers a clean surface that never references `bun:sqlite` or any Node.js-only API.
|
||||
|
||||
The separation between Bun and WASM adapter entry points keeps tree-shaking effective: a Bun server bundle never pulls in `@sqlite.org/sqlite-wasm` or the Web Worker, and a browser bundle never pulls in `bun:sqlite`. Each entry point is resolved at build time by the bundler's condition matching.
|
||||
|
||||
## Features
|
||||
|
||||
- Conditional export target for the `"browser"` condition in `package.json`
|
||||
- Re-exports `createWasmDriver`, `WasmDriverResult`, and `WasmProxyDb` from the WASM driver
|
||||
- Re-exports `WasmDriverOptions` type from the shared schemas
|
||||
- Zero runtime overhead — pure re-export barrel
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// Resolved automatically by the browser bundler via conditional exports
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const { db, close } = await createWasmDriver();
|
||||
const rows = await db.query("SELECT 1 AS n");
|
||||
await close();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite/adapter";
|
||||
import type { WasmDriverOptions } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const opts: WasmDriverOptions = {
|
||||
filename: "app.db", // OPFS-backed persistent storage
|
||||
};
|
||||
|
||||
const { db, close } = await createWasmDriver(opts);
|
||||
|
||||
await db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)"
|
||||
);
|
||||
await db.execute("INSERT INTO notes (body) VALUES (?)", ["Hello from the browser"]);
|
||||
|
||||
const rows = await db.query<{ id: number; body: string }>(
|
||||
"SELECT * FROM notes ORDER BY id"
|
||||
);
|
||||
console.log(rows);
|
||||
|
||||
await close();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This file does not export any symbols directly — it is a conditional-export barrel. All types and values are documented in [`../drivers/wasm-driver.md`](../drivers/wasm-driver.md).
|
||||
|
||||
**Conditional export condition:** `"browser"` — selected when a bundler uses browser-targeting conditions (e.g. Vite's default, Webpack's `target: "web"`).
|
||||
|
||||
**Default fallback:** When neither `"bun"` nor `"browser"` conditions are active (e.g. vanilla Node.js `require()`), the `exports.default` condition falls back to the Bun adapter entry point. The WASM path is never the fallback to avoid loading 1.5 MB of WASM in server-side contexts.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`WasmDriverOptions` is imported from `../schemas/index.js` rather than from `wasm-driver.js` because the Zod schemas live in the shared schemas module — keeping the driver files free of Zod as a direct import. This pattern is consistent across all driver options types in this package.
|
||||
|
||||
The WASM worker bundle (`wasm-worker.js`) is inlined as an asset URL by Vite at build time using `new URL("./wasm-worker.js", import.meta.url)`. The worker receives messages for `init`, `query`, `execute`, and `close`, and responds synchronously from SQLite's in-memory or OPFS-backed database.
|
||||
88
docs/persistence/database/db-sqlite/src/cache/cache.md
vendored
Normal file
88
docs/persistence/database/db-sqlite/src/cache/cache.md
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# cache
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`PreparedStatementCache` is an LRU-bounded cache for `bun:sqlite` prepared statement objects, keyed by SQL text. Reusing prepared statements avoids the overhead of repeated SQL parsing and optimisation — a significant win for hot query paths. The cache is bounded to prevent unbounded memory growth in applications that generate many distinct SQL strings.
|
||||
|
||||
The cache integrates with OpenTelemetry to surface hit/miss/eviction counters at `sqlite.cache.hits`, `sqlite.cache.misses`, and `sqlite.cache.evictions`. These are no-ops when no OTel provider is registered, keeping the module safe for use in zero-dependency environments.
|
||||
|
||||
## Features
|
||||
|
||||
- LRU eviction when `size >= maxSize` (default `128`)
|
||||
- `get(sql)` — returns cached statement or `undefined`; updates LRU order
|
||||
- `set(sql, stmt)` — inserts or updates; evicts LRU entry when at capacity, calling `stmt.finalize()` on the evicted statement
|
||||
- `invalidate()` — clears all entries and calls `finalize()` on each held statement; called by `SqliteAdapter.migrate()` to prevent stale plan reuse after schema changes
|
||||
- `size` / `maxSize` read-only getters
|
||||
- OTel counters: `sqlite.cache.hits`, `sqlite.cache.misses`, `sqlite.cache.evictions`
|
||||
- `static resetInstruments()` — clears the OTel lazy singleton for test isolation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PreparedStatementCache } from "@suaveplan/db-sqlite/cache/cache";
|
||||
|
||||
const cache = new PreparedStatementCache();
|
||||
const stmt = sqlite.prepare("SELECT * FROM users WHERE id = ?");
|
||||
cache.set("SELECT * FROM users WHERE id = ?", stmt);
|
||||
|
||||
const hit = cache.get("SELECT * FROM users WHERE id = ?");
|
||||
// hit === stmt
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PreparedStatementCache } from "@suaveplan/db-sqlite/cache/cache";
|
||||
|
||||
// Small cache for testing eviction
|
||||
const cache = new PreparedStatementCache(3);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const sql = `SELECT ${i} FROM t`;
|
||||
cache.set(sql, { finalize: () => undefined }); // mock statement
|
||||
}
|
||||
|
||||
console.log(cache.size); // 3 — first entry evicted
|
||||
|
||||
cache.invalidate();
|
||||
console.log(cache.size); // 0 — all entries cleared
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new PreparedStatementCache(maxSize?: number)`
|
||||
|
||||
Constructs a new cache. `maxSize` defaults to `128`.
|
||||
|
||||
### `get(sql: string): Statement | undefined`
|
||||
|
||||
Returns the cached statement for `sql`, or `undefined` on miss. Increments `sqlite.cache.hits` or `sqlite.cache.misses`. Updates LRU order on hit.
|
||||
|
||||
### `set(sql: string, stmt: Statement): void`
|
||||
|
||||
Inserts or updates the cache entry for `sql`. On capacity overflow, evicts the least-recently-used entry, calling `stmt.finalize()` on it, and increments `sqlite.cache.evictions`.
|
||||
|
||||
### `invalidate(): void`
|
||||
|
||||
Clears all entries. Calls `stmt.finalize()` on each held statement before clearing. Called automatically by `SqliteAdapter.migrate()`.
|
||||
|
||||
### `size: number`
|
||||
|
||||
Current number of cached entries.
|
||||
|
||||
### `maxSize: number`
|
||||
|
||||
Maximum capacity as set at construction.
|
||||
|
||||
### `static resetInstruments(): void`
|
||||
|
||||
Clears the lazy OTel instruments singleton. Use in tests to ensure each test suite starts with a fresh counter baseline.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
LRU ordering is maintained with a parallel `string[]` array (`#lruOrder`) rather than a doubly-linked list. This is simpler and sufficient for cache sizes up to a few hundred entries — `Array.indexOf` and `splice` on an array of at most 128 strings add negligible overhead compared to a SQLite query round-trip. For much larger caches a linked-list implementation would be preferable.
|
||||
|
||||
`finalize()` is called defensively with a try/catch because `bun:sqlite` statements may already be finalised if the underlying database was closed before the cache was cleared.
|
||||
@@ -0,0 +1,64 @@
|
||||
# conformance-suite
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `runConformanceSuite`, a driver-agnostic test factory that accepts a `DriverFactory` callback and executes a standard suite of CRUD, transaction, and rollback assertions against any SQLite driver. Running the same suite against both the Bun driver and a WASM driver ensures that both runtimes satisfy the same behavioural contract, catching driver-specific regressions before they reach production.
|
||||
|
||||
The suite is intentionally minimal — it tests the lowest-level SQL operations that the adapter layer depends on. Higher-level tests (prepared-statement cache behaviour, migration runner idempotency, OTel counter increments) live in their respective unit test files.
|
||||
|
||||
## Features
|
||||
|
||||
- `runConformanceSuite(name, factory)` — registers a Vitest `describe` block named `${name} conformance`
|
||||
- Tests: table creation, SELECT, INSERT with parameters, transaction commit, transaction rollback, UPDATE, DELETE
|
||||
- `DriverFactory` interface — a single `create()` method returning `{ sqlite, close }`
|
||||
- Runs under the Bun driver via `bun-conformance.test.ts`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { runConformanceSuite } from "@suaveplan/db-sqlite/src/conformance/conformance-suite";
|
||||
import { createBunDriver } from "@suaveplan/db-sqlite/drivers/bun-driver";
|
||||
|
||||
runConformanceSuite("Bun driver", {
|
||||
async create() {
|
||||
return createBunDriver({ filename: ":memory:" });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { runConformanceSuite } from "@suaveplan/db-sqlite/src/conformance/conformance-suite";
|
||||
|
||||
// Custom driver shim for testing
|
||||
runConformanceSuite("Custom driver", {
|
||||
async create() {
|
||||
const driver = await MyCustomDriver.open(":memory:");
|
||||
return { sqlite: driver.raw, close: async () => driver.close() };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DriverFactory`
|
||||
|
||||
```ts
|
||||
interface DriverFactory {
|
||||
create(): Promise<{ sqlite: any; close: () => Promise<void> }>;
|
||||
}
|
||||
```
|
||||
|
||||
### `runConformanceSuite(name: string, factory: DriverFactory): void`
|
||||
|
||||
Registers a Vitest `describe` block with 5 test cases covering the core SQLite contract.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The conformance suite uses raw `sqlite.run()` / `sqlite.query().all()` calls rather than going through `SqliteAdapter` or Drizzle. This keeps the suite focused on driver-level semantics rather than adapter-layer concerns, making it useful for validating new driver implementations independently of the adapter code.
|
||||
|
||||
Each test creates fresh tables with unique names to avoid interference between tests within the same suite run.
|
||||
@@ -0,0 +1,83 @@
|
||||
# connection
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides thin singleton-management wrappers for `SqliteAdapter`, built on top of the `@suaveplan/db` singleton helpers. These functions let callers share a single connected `SqliteAdapter` instance across an application without manually tracking the singleton lifecycle or passing the adapter through every layer of the call stack.
|
||||
|
||||
The helpers default the singleton name to `"default"` so simple applications need only call `getDefaultSqlite()` with no arguments. More complex applications with multiple databases use explicit names.
|
||||
|
||||
## Features
|
||||
|
||||
- `getDefaultSqlite(options?, config?, name?)` — creates-or-returns a connected `SqliteAdapter` singleton
|
||||
- `closeSqlite(name?)` — disconnects and removes the named singleton
|
||||
- `closeAllSqlite()` — disconnects and removes all `SqliteAdapter` singletons
|
||||
- `hasSqlite(name?)` — returns `true` if the named singleton exists and is connected
|
||||
- Default name: `"default"` — single-database apps never need to supply a name
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getDefaultSqlite } from "@suaveplan/db-sqlite/connection/connection";
|
||||
|
||||
const adapter = await getDefaultSqlite();
|
||||
const rows = await adapter.query("SELECT * FROM users");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
closeAllSqlite,
|
||||
closeSqlite,
|
||||
getDefaultSqlite,
|
||||
hasSqlite,
|
||||
} from "@suaveplan/db-sqlite/connection/connection";
|
||||
|
||||
// Primary database
|
||||
const primary = await getDefaultSqlite(
|
||||
{ filename: "/data/app.db" },
|
||||
undefined,
|
||||
"primary"
|
||||
);
|
||||
|
||||
// Analytics read-only shard
|
||||
const analytics = await getDefaultSqlite(
|
||||
{ filename: "/data/analytics.db", readonly: true },
|
||||
undefined,
|
||||
"analytics"
|
||||
);
|
||||
|
||||
console.log(hasSqlite("primary")); // true
|
||||
console.log(hasSqlite("analytics")); // true
|
||||
|
||||
await closeSqlite("analytics");
|
||||
console.log(hasSqlite("analytics")); // false
|
||||
|
||||
// Shutdown — closes all remaining singletons
|
||||
await closeAllSqlite();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getDefaultSqlite(options?, config?, name?): Promise<SqliteAdapter>`
|
||||
|
||||
Creates a new `SqliteAdapter` using the given `options`, calls `connect(config)` on it, caches it under `name`, and returns it. Subsequent calls with the same `name` return the cached instance without reconnecting.
|
||||
|
||||
### `closeSqlite(name?): Promise<void>`
|
||||
|
||||
Disconnects and removes the singleton named `name`. No-op if no singleton by that name exists.
|
||||
|
||||
### `closeAllSqlite(): Promise<void>`
|
||||
|
||||
Disconnects and removes all singletons. Throws `AggregateError` if any disconnect fails.
|
||||
|
||||
### `hasSqlite(name?): boolean`
|
||||
|
||||
Returns `true` if a singleton exists under `name`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
These functions delegate entirely to `@suaveplan/db`'s `getSingleton`, `closeSingleton`, `closeAllSingletons`, and `hasSingleton`. The `SqliteAdapter`-specific names (`getDefaultSqlite`, `closeSqlite`, etc.) exist for discoverability — callers working with SQLite need not know the generic singleton API. The return type is narrowed from `DatabaseAdapter` to `SqliteAdapter` via a cast so callers have access to SQLite-specific properties.
|
||||
@@ -0,0 +1,81 @@
|
||||
# bun-driver
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides `createBunDriver`, the server-side SQLite driver for `@suaveplan/db-sqlite`. It dynamically imports `bun:sqlite` (a built-in Bun module) and returns a thin wrapper holding the raw `Database` instance plus an idempotent `close()` function. The `isBunEnvironment()` guard ensures the driver is never accidentally called in a browser context — doing so raises `SQLITE.ENVIRONMENT_ERROR` immediately rather than surfacing a confusing module-not-found error at import time.
|
||||
|
||||
The dynamic `import("bun:sqlite")` pattern keeps the module evaluatable in non-Bun environments (so bundlers can tree-shake it out and browser builds do not fail), while still providing full type information from `@types/bun`.
|
||||
|
||||
## Features
|
||||
|
||||
- `createBunDriver(options?)` — opens an in-memory (default) or file-backed SQLite database via `bun:sqlite`
|
||||
- `isBunEnvironment()` — detects Bun runtime via `process.versions.bun`
|
||||
- `BunDriverResult` — `{ sqlite, close }` shape; `close()` is idempotent (safe to call multiple times)
|
||||
- Throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` when called outside Bun
|
||||
- Throws `SqliteError(SQLITE.DRIVER_INIT_FAILED)` if `Database` constructor throws (e.g. bad path, locked file)
|
||||
- `readonly` option forwarded to `bun:sqlite` `Database` constructor
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createBunDriver } from "@suaveplan/db-sqlite/drivers/bun-driver";
|
||||
|
||||
const { sqlite, close } = await createBunDriver();
|
||||
// sqlite is a bun:sqlite Database instance — run raw SQL or pass to Drizzle
|
||||
await close();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createBunDriver, isBunEnvironment } from "@suaveplan/db-sqlite/drivers/bun-driver";
|
||||
|
||||
if (!isBunEnvironment()) {
|
||||
throw new Error("Server-side only path");
|
||||
}
|
||||
|
||||
const { sqlite, close } = await createBunDriver({
|
||||
filename: "/data/app.db",
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
try {
|
||||
sqlite.run("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT)");
|
||||
sqlite.run("INSERT OR REPLACE INTO kv VALUES (?, ?)", ["hello", "world"]);
|
||||
} finally {
|
||||
await close(); // second call is a no-op
|
||||
await close(); // safe
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `isBunEnvironment(): boolean`
|
||||
|
||||
Returns `true` when `process.versions.bun` is defined. Safe to call in any environment.
|
||||
|
||||
### `createBunDriver(options?: BunDriverOptions): Promise<BunDriverResult>`
|
||||
|
||||
Opens a SQLite database. Throws `SqliteError(SQLITE.ENVIRONMENT_ERROR)` outside Bun, `SqliteError(SQLITE.DRIVER_INIT_FAILED)` on open failure.
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `options.filename` | `string` | `":memory:"` | File path or `":memory:"` |
|
||||
| `options.readonly` | `boolean` | `false` | Open in read-only mode |
|
||||
|
||||
### `BunDriverResult`
|
||||
|
||||
```ts
|
||||
interface BunDriverResult {
|
||||
sqlite: Database; // bun:sqlite Database instance
|
||||
close: () => Promise<void>; // idempotent — safe to call multiple times
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`close()` sets a `closed` flag so repeated calls are no-ops rather than throwing "database already closed". This is intentional: callers cleaning up in `finally` blocks should not have to track whether they already closed.
|
||||
|
||||
The `bun:sqlite` import is deferred via `await import("bun:sqlite")` inside the function body so that bundlers processing non-Bun targets (e.g. a Vite browser build) can externalize or ignore the module without causing a build error at parse time. The `isBunEnvironment()` guard before the import provides an early exit in the rare case this driver is invoked in a test environment that stubs the process object.
|
||||
@@ -0,0 +1,89 @@
|
||||
# wasm-driver
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides `createWasmDriver`, the browser-side SQLite driver for `@suaveplan/db-sqlite`. It spawns a Web Worker running `wasm-worker.ts`, which loads `@sqlite.org/sqlite-wasm` and manages a SQLite database instance. Communication between the caller and the worker uses a message-passing protocol with sequential IDs mapped to `Promise` resolve/reject pairs, giving a fully async interface without blocking the main thread.
|
||||
|
||||
OPFS (Origin Private File System) persistence is attempted automatically when a non-`:memory:` filename is supplied and the worker context supports `sqlite3.oo1.OpfsDb`. When OPFS headers (`Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`) are absent, the worker falls back to in-memory storage with a console warning.
|
||||
|
||||
## Features
|
||||
|
||||
- `createWasmDriver(options?, workerFactory?)` — spawns worker, awaits ready handshake, returns `{ db, close }`
|
||||
- Injectable `workerFactory` parameter for test isolation — avoids real Worker spawning in Vitest
|
||||
- `WasmProxyDb` — `{ query(sql, params?), execute(sql, params?) }` interface returned by the driver
|
||||
- `close()` is idempotent and drains all pending requests with rejection before terminating the worker
|
||||
- Worker init failure terminates the worker and throws `SqliteError(SQLITE.DRIVER_INIT_FAILED)`
|
||||
- Worker runtime errors reject all pending promises with `SqliteError(SQLITE.DRIVER_INIT_FAILED)`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite/drivers/wasm-driver";
|
||||
|
||||
const { db, close } = await createWasmDriver();
|
||||
await db.execute("CREATE TABLE kv (key TEXT PRIMARY KEY, value TEXT)", []);
|
||||
await db.execute("INSERT INTO kv VALUES (?, ?)", ["hello", "world"]);
|
||||
const rows = await db.query("SELECT * FROM kv WHERE key = ?", ["hello"]);
|
||||
await close();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite/drivers/wasm-driver";
|
||||
|
||||
// With OPFS persistence (requires COOP/COEP headers on the page)
|
||||
const { db, close } = await createWasmDriver({ filename: "app.db" });
|
||||
|
||||
try {
|
||||
await db.execute("BEGIN IMMEDIATE", []);
|
||||
await db.execute("INSERT INTO users VALUES (?, ?)", [1, "Alice"]);
|
||||
await db.execute("COMMIT", []);
|
||||
} catch (err) {
|
||||
await db.execute("ROLLBACK", []);
|
||||
throw err;
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createWasmDriver<TSchema>(options?, workerFactory?): Promise<WasmDriverResult<TSchema>>`
|
||||
|
||||
Spawns the WASM worker and awaits the `ready` handshake before resolving.
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `options.filename` | `string` | `undefined` (→ `:memory:`) | OPFS file path or omit for in-memory |
|
||||
| `workerFactory` | `() => Worker` | `defaultWorkerFactory` | Injectable for testing |
|
||||
|
||||
**Throws** `SqliteError(SQLITE.DRIVER_INIT_FAILED)` if the worker fails to initialise.
|
||||
|
||||
### `WasmProxyDb<TSchema>`
|
||||
|
||||
```ts
|
||||
interface WasmProxyDb<TSchema> {
|
||||
query(sql: string, params?: unknown[]): Promise<unknown[][]>;
|
||||
execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>;
|
||||
}
|
||||
```
|
||||
|
||||
### `WasmDriverResult<TSchema>`
|
||||
|
||||
```ts
|
||||
interface WasmDriverResult<TSchema> {
|
||||
db: WasmProxyDb<TSchema>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The worker URL is constructed with `new URL("./wasm-worker.js", import.meta.url)` so bundlers (Vite, webpack) can resolve it as a worker entry point and emit the correct asset path. The `.js` extension (not `.ts`) is used because the compiled output is what the browser loads at runtime.
|
||||
|
||||
The pending-promise map (`Map<id, {resolve, reject}>`) ensures that each in-flight request receives exactly one response, and that a stale response for a cancelled request is silently ignored. On `close()`, all pending promises are rejected before worker termination to prevent callers from hanging indefinitely.
|
||||
|
||||
OPFS support requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` response headers. The worker automatically falls back to in-memory storage without these headers rather than crashing, since detecting header presence from inside a Worker is unreliable.
|
||||
@@ -0,0 +1,81 @@
|
||||
# wasm-worker
|
||||
|
||||
## Purpose
|
||||
|
||||
Web Worker implementation for the WASM SQLite driver. This module runs inside a dedicated worker thread created by `createWasmDriver` and handles all direct SQLite operations using `@sqlite.org/sqlite-wasm`. It communicates with the main thread via a simple message-passing protocol, keeping the WASM heap and SQLite connection completely isolated from the main thread's event loop.
|
||||
|
||||
Running SQLite operations in a worker prevents blocking the UI thread during expensive queries and migrations, and satisfies browser restrictions around synchronous WASM I/O on the main thread (required for OPFS-backed databases via `sqlite3_vfs`).
|
||||
|
||||
## Features
|
||||
|
||||
- Loads `@sqlite.org/sqlite-wasm` lazily on `init` message
|
||||
- Supports `init`, `query`, `execute`, `begin_tx`, `commit_tx`, `rollback_tx`, and `close` message types
|
||||
- Sequential request IDs for reliable request-response correlation
|
||||
- Returns structured `{ id, ok, result }` or `{ id, ok: false, error }` responses
|
||||
- Opens either an in-memory database (default) or a named database when `filename` is provided
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
This module is not imported directly. It is instantiated by `createWasmDriver` as a Web Worker:
|
||||
|
||||
```ts
|
||||
// Worker is created internally by the driver
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite/adapter";
|
||||
|
||||
const { db, close } = await createWasmDriver();
|
||||
await db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)");
|
||||
const rows = await db.query("SELECT * FROM t");
|
||||
await close();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
For testing purposes, the worker's message protocol can be exercised directly by creating a mock worker that implements the same interface. The `createWasmDriver` function accepts an injectable `workerFactory` parameter for this:
|
||||
|
||||
```ts
|
||||
import { createWasmDriver } from "@suaveplan/db-sqlite";
|
||||
|
||||
function mockWorkerFactory(): Worker {
|
||||
let onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
const post = (data: unknown) => { if (onmessage) onmessage({ data } as MessageEvent); };
|
||||
|
||||
return {
|
||||
set onmessage(fn) { onmessage = fn; },
|
||||
postMessage(msg: unknown) {
|
||||
const m = msg as { type: string; id: number };
|
||||
if (m.type === "init") post({ id: m.id, ok: true, result: { type: "ready" } });
|
||||
if (m.type === "query") post({ id: m.id, ok: true, result: [] });
|
||||
},
|
||||
terminate() {},
|
||||
} as unknown as Worker;
|
||||
}
|
||||
|
||||
const { db, close } = await createWasmDriver(undefined, mockWorkerFactory);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Message Protocol (inbound → worker)
|
||||
|
||||
| `type` | Extra fields | Description |
|
||||
|---------------|----------------------------|-----------------------------------------|
|
||||
| `init` | `filename?: string` | Load WASM, open database, reply ready |
|
||||
| `query` | `sql: string`, `params?` | Run `all()`, reply with rows array |
|
||||
| `execute` | `sql: string`, `params?` | Run `run()`, reply with `rowsAffected` |
|
||||
| `begin_tx` | — | `BEGIN IMMEDIATE` |
|
||||
| `commit_tx` | — | `COMMIT` |
|
||||
| `rollback_tx` | — | `ROLLBACK` |
|
||||
| `close` | — | Close database, terminate worker |
|
||||
|
||||
### Message Protocol (outbound ← worker)
|
||||
|
||||
All responses carry `{ id: number, ok: boolean }`.
|
||||
|
||||
- Success: `{ id, ok: true, result: <payload> }`
|
||||
- Failure: `{ id, ok: false, error: "<message>" }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `/// <reference lib="webworker" />` triple-slash directive at the top of the file provides TypeScript access to `self`, `MessageEvent`, `postMessage`, and other Web Worker globals without requiring the file to be compiled with `"lib": ["WebWorker"]` globally — that would pollute the non-worker files.
|
||||
|
||||
The module uses the `@sqlite.org/sqlite-wasm` default export which is a zero-argument async factory: `const sqlite3 = await initSqlite()`. Previous versions of this package accepted a configuration argument; the current API does not.
|
||||
93
docs/persistence/database/db-sqlite/src/errors/errors.md
Normal file
93
docs/persistence/database/db-sqlite/src/errors/errors.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the structured error hierarchy for `@suaveplan/db-sqlite`. All runtime failures — connection problems, query errors, migration failures, environment mismatches — are reported as `SqliteError` instances carrying a stable `SQLITE.*` code from the `SQLITE_CODES` registry. This makes error handling programmatic and refactor-safe: callers switch on `err.code` rather than matching message strings.
|
||||
|
||||
The companion `mapSqliteCode` helper normalises raw error values thrown by `bun:sqlite` (which surfaces result-code strings like `SQLITE_BUSY` or `SQLITE_CONSTRAINT`) into the same stable code namespace, so adapters never leak driver-specific strings into calling code.
|
||||
|
||||
## Features
|
||||
|
||||
- `SQLITE_CODES` registry — 10 stable `SQLITE.*` code strings defined via `defineCodes`
|
||||
- `SqliteError extends SuaveplanError<SqliteCode>` — carries `code`, `message`, `cause`, and full `SuaveplanError` wire-format serialisation
|
||||
- `mapSqliteCode(err)` — maps raw `bun:sqlite` result codes and message substrings to stable `SqliteCode` values; falls back to `SQLITE.QUERY_FAILED`
|
||||
- Type alias `SqliteCode` — union of all valid code strings
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SqliteError, SQLITE_CODES } from "@suaveplan/db-sqlite/errors/errors";
|
||||
|
||||
throw new SqliteError(
|
||||
SQLITE_CODES.NOT_CONNECTED,
|
||||
"Cannot query: adapter is not connected"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { mapSqliteCode, SqliteError, SQLITE_CODES } from "@suaveplan/db-sqlite/errors/errors";
|
||||
|
||||
async function safeQuery(db: unknown, sql: string): Promise<unknown[]> {
|
||||
try {
|
||||
// ... execute query
|
||||
return [];
|
||||
} catch (raw) {
|
||||
const code = mapSqliteCode(raw);
|
||||
if (code === SQLITE_CODES.BUSY) {
|
||||
// caller can retry on BUSY
|
||||
throw new SqliteError(code, "Database is busy — retry after a moment", { cause: raw instanceof Error ? raw : undefined });
|
||||
}
|
||||
throw new SqliteError(code, `Query failed: ${raw instanceof Error ? raw.message : String(raw)}`, {
|
||||
cause: raw instanceof Error ? raw : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SQLITE_CODES`
|
||||
|
||||
Registry object produced by `defineCodes("SQLITE", [...])`. Keys and values:
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| `NOT_CONNECTED` | `"SQLITE.NOT_CONNECTED"` |
|
||||
| `QUERY_FAILED` | `"SQLITE.QUERY_FAILED"` |
|
||||
| `EXECUTE_FAILED` | `"SQLITE.EXECUTE_FAILED"` |
|
||||
| `TRANSACTION_FAILED` | `"SQLITE.TRANSACTION_FAILED"` |
|
||||
| `MIGRATION_FAILED` | `"SQLITE.MIGRATION_FAILED"` |
|
||||
| `ENVIRONMENT_ERROR` | `"SQLITE.ENVIRONMENT_ERROR"` |
|
||||
| `BUSY` | `"SQLITE.BUSY"` |
|
||||
| `CONSTRAINT` | `"SQLITE.CONSTRAINT"` |
|
||||
| `CORRUPT` | `"SQLITE.CORRUPT"` |
|
||||
| `DRIVER_INIT_FAILED` | `"SQLITE.DRIVER_INIT_FAILED"` |
|
||||
|
||||
### `SqliteError`
|
||||
|
||||
```ts
|
||||
class SqliteError extends SuaveplanError<SqliteCode> {
|
||||
constructor(code: SqliteCode, message: string, options?: SuaveplanErrorOptions): SqliteError
|
||||
}
|
||||
```
|
||||
|
||||
Inherits `.code`, `.message`, `.cause`, `.name` from `SuaveplanError`. Fully serialisable to the `@suaveplan/error` wire format.
|
||||
|
||||
### `mapSqliteCode(err: unknown): SqliteCode`
|
||||
|
||||
Maps a raw thrown value from `bun:sqlite` to a stable `SqliteCode`. Checks `err.message` for known result-code strings (`SQLITE_BUSY`, `SQLITE_LOCKED`, `SQLITE_CONSTRAINT`, `SQLITE_CORRUPT`, `SQLITE_NOTADB`). Returns `SQLITE.QUERY_FAILED` as the safe default when no pattern matches.
|
||||
|
||||
### `SqliteCode`
|
||||
|
||||
Type alias: `(typeof SQLITE_CODES)[keyof typeof SQLITE_CODES]` — the union of all ten code strings.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `noUselessConstructor` biome rule is suppressed on `SqliteError` because `SuaveplanError` uses a `protected` constructor; the subclass constructor exists solely to expose a `public` entry point — without it TypeScript raises TS2674.
|
||||
|
||||
`mapSqliteCode` checks `err instanceof SqliteError` first so re-thrown errors are not re-wrapped. The result-code detection loop converts both the error message and the raw value to upper-case before matching, handling both `"SQLITE_BUSY: database is locked"` messages and bare result-code strings.
|
||||
@@ -0,0 +1,84 @@
|
||||
# migration
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MigrationRunner` applies forward-only SQL migration files to a SQLite database. It maintains a ledger table (`__sqlite_migrations` by default) that records which migration files have been applied, making runs idempotent — re-running the same set of migration files produces the same database state regardless of how many times it is called.
|
||||
|
||||
Each migration is executed inside a `BEGIN IMMEDIATE` transaction. A failure mid-migration triggers an automatic `ROLLBACK`, ensuring the database is never left in a partially-applied state. A `dryRun()` mode reads and validates files but makes no database changes, letting callers preview what would be applied.
|
||||
|
||||
## Features
|
||||
|
||||
- Forward-only, filename-ordered migration application (`.sql` files sorted lexicographically)
|
||||
- Idempotent: skips already-applied migrations via ledger table lookup
|
||||
- Transactional: each migration runs in `BEGIN IMMEDIATE` / `COMMIT` / `ROLLBACK`
|
||||
- Dry-run mode: previews pending migrations without modifying the database
|
||||
- Injectable `Clock` for deterministic `applied_at` timestamps in tests
|
||||
- Configurable `migrationsFolder`, `migrationsTable`, and `dryRun` via `MigrationRunnerOptionsSchema`
|
||||
- Throws `SqliteError(SQLITE.MIGRATION_FAILED)` with original cause on any failure
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createBunDriver } from "@suaveplan/db-sqlite/drivers/bun-driver";
|
||||
import { MigrationRunner } from "@suaveplan/db-sqlite/migration/migration";
|
||||
|
||||
const { sqlite, close } = await createBunDriver({ filename: "app.db" });
|
||||
try {
|
||||
const runner = new MigrationRunner(sqlite, { migrationsFolder: "./db/migrations" });
|
||||
const result = await runner.run();
|
||||
console.log(`Applied ${result.applied} migration(s):`, result.migrations);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MigrationRunner } from "@suaveplan/db-sqlite/migration/migration";
|
||||
|
||||
// Dry-run to preview without applying
|
||||
const runner = new MigrationRunner(sqlite, {
|
||||
migrationsFolder: "./db/migrations",
|
||||
migrationsTable: "custom_ledger",
|
||||
});
|
||||
|
||||
const preview = await runner.dryRun();
|
||||
console.log("Would apply:", preview.migrations);
|
||||
|
||||
if (preview.applied > 0) {
|
||||
const result = await runner.run();
|
||||
console.log("Applied:", result.migrations);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new MigrationRunner(sqlite, options?, clock?)`
|
||||
|
||||
Constructs a `MigrationRunner`. Validates `options` via `MigrationRunnerOptionsSchema`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `sqlite` | `bun:sqlite Database` | Database instance to migrate |
|
||||
| `options` | `Partial<MigrationRunnerOptions>` | Folder, table, dry-run settings |
|
||||
| `clock` | `Clock` | Injectable clock for `applied_at` (default: `Date.now`) |
|
||||
|
||||
### `run(options?: Partial<MigrationRunnerOptions>): Promise<MigrationResult>`
|
||||
|
||||
Reads `.sql` files from `migrationsFolder`, skips already-applied entries, applies each in order, records to ledger. Returns `{ applied: number, migrations: string[] }`.
|
||||
|
||||
### `dryRun(options?: Partial<MigrationRunnerOptions>): Promise<MigrationResult>`
|
||||
|
||||
Same as `run()` but makes no database changes. Useful for change-preview UIs and CI checks.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Migrations are sorted lexicographically by filename. Conventional naming (`001_init.sql`, `002_add_users.sql`) produces chronological order. Non-`.sql` files in the folder are silently ignored.
|
||||
|
||||
The `#isMigrationApplied` check uses a raw SQL query rather than a prepared statement to avoid circular dependency with `PreparedStatementCache` — the migration runner runs before the adapter is fully connected and the cache is available.
|
||||
|
||||
The `applied_at` column stores a Unix millisecond timestamp as `INTEGER` rather than ISO 8601 text. This keeps sorting and comparison simple and avoids timezone ambiguity. Consumers that need a `Date` object divide by 1000 or use `new Date(applied_at)`.
|
||||
65
docs/persistence/database/db-sqlite/src/otel/otel.md
Normal file
65
docs/persistence/database/db-sqlite/src/otel/otel.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrumentation for `@suaveplan/db-sqlite`. The `withQuerySpan` function wraps any async database operation in a named OTel span, setting `db.system=sqlite` and `db.statement` (sanitised) as span attributes, recording exceptions on failure, and emitting latency and count metrics. It is a no-op when no OTel SDK is registered — the `@opentelemetry/api` no-op provider handles all calls silently.
|
||||
|
||||
`sanitizeSql` strips string literals and numeric values from SQL strings before attaching them to span attributes, preventing PII leakage into telemetry backends. Parameterised queries still record their structure (`SELECT * FROM users WHERE id = ?`) without revealing actual parameter values.
|
||||
|
||||
## Features
|
||||
|
||||
- `withQuerySpan(operationName, rawSql, fn)` — wraps fn in a named span with `db.system=sqlite` and sanitised `db.statement`
|
||||
- `sanitizeSql(sql)` — replaces string literals, quoted identifiers, and bare numbers with `?` placeholders
|
||||
- Histograms: `sqlite.query.duration_ms` (labels: `operation`)
|
||||
- Counters: `sqlite.query.count`, `sqlite.error.count` (labels: `operation`)
|
||||
- `resetOtelInstruments()` — clears the lazy instruments singleton for test isolation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { withQuerySpan } from "@suaveplan/db-sqlite/otel";
|
||||
|
||||
const rows = await withQuerySpan(
|
||||
"db.query",
|
||||
"SELECT * FROM users WHERE id = 42",
|
||||
async () => sqlite.query("SELECT * FROM users WHERE id = ?").all([42])
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { sanitizeSql, withQuerySpan } from "@suaveplan/db-sqlite/otel";
|
||||
|
||||
// Preview sanitisation before instrumentation
|
||||
const safe = sanitizeSql("SELECT * FROM t WHERE name = 'Alice' AND age = 30");
|
||||
// => "SELECT * FROM t WHERE name = ? AND age = ?"
|
||||
|
||||
const result = await withQuerySpan("db.execute", safe, async () => {
|
||||
sqlite.run("INSERT INTO events VALUES (?, ?)", ["login", 1234567890]);
|
||||
return { rowsAffected: 1 };
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `withQuerySpan<T>(operationName, rawSql, fn): Promise<T>`
|
||||
|
||||
Starts a span named `operationName`, sets `db.system=sqlite` and `db.statement=sanitizeSql(rawSql)`, then calls `fn()`. On success records latency and increments `sqlite.query.count`. On failure records exception, sets `ERROR` status, and increments `sqlite.error.count`. Always ends the span.
|
||||
|
||||
### `sanitizeSql(sql: string): string`
|
||||
|
||||
Replaces single-quoted strings, double-quoted strings, and numeric literals (integer and decimal) with `?`. Safe to call on arbitrary SQL strings — does not parse SQL, only applies a regex substitution.
|
||||
|
||||
### `resetOtelInstruments(): void`
|
||||
|
||||
Clears the lazy OTel instruments singleton. Use in tests so each suite starts with fresh counter baselines. Also called internally by `SqliteAdapter.disconnect()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments lazy singleton is initialised on the first `withQuerySpan` call rather than at module load to ensure the OTel provider is registered before the meter is obtained. Tests that call `resetOtelInstruments()` between suites prevent counter state from leaking across test files.
|
||||
|
||||
`sanitizeSql` uses a single-pass regex and is intentionally simple — it does not understand SQL syntax, so complex nested strings or edge-case identifiers may not be fully sanitised. This is acceptable for telemetry purposes where approximate sanitisation is sufficient.
|
||||
71
docs/persistence/database/db-sqlite/src/schemas/schemas.md
Normal file
71
docs/persistence/database/db-sqlite/src/schemas/schemas.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Zod schemas and inferred TypeScript types for all `@suaveplan/db-sqlite` configuration objects. Centralising validation in one module ensures that every entry point — `createBunDriver`, `createWasmDriver`, `SqliteAdapter`, `MigrationRunner` — applies the same rules with the same error messages, rather than duplicating ad-hoc checks across modules.
|
||||
|
||||
All schemas use `.strict()` where applicable so extra unknown properties are rejected at runtime, preventing silent misconfiguration from typos in option objects.
|
||||
|
||||
## Features
|
||||
|
||||
- `BunDriverOptionsSchema` — validates `filename` (optional string) and `readonly` (optional boolean, default `false`)
|
||||
- `WasmDriverOptionsSchema` — validates `filename` (optional string) for OPFS persistence
|
||||
- `SqliteAdapterOptionsSchema` — alias for `BunDriverOptionsSchema`; adapter and driver share the same shape
|
||||
- `MigrationRunnerOptionsSchema` — validates `migrationsFolder` (default `"./migrations"`), `migrationsTable` (default `"__sqlite_migrations"`), `dryRun` (default `false`)
|
||||
- Inferred TypeScript types exported for all four schemas
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BunDriverOptionsSchema } from "@suaveplan/db-sqlite/schemas";
|
||||
|
||||
const opts = BunDriverOptionsSchema.parse({ filename: ":memory:" });
|
||||
// opts.readonly === false (default applied)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MigrationRunnerOptionsSchema } from "@suaveplan/db-sqlite/schemas";
|
||||
|
||||
const opts = MigrationRunnerOptionsSchema.parse({
|
||||
migrationsFolder: "./db/migrations",
|
||||
dryRun: true,
|
||||
});
|
||||
// opts.migrationsTable === "__sqlite_migrations" (default applied)
|
||||
// opts.dryRun === true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `BunDriverOptionsSchema`
|
||||
|
||||
`z.object({ filename?: string, readonly?: boolean }).strict()` — `readonly` defaults to `false`.
|
||||
|
||||
### `WasmDriverOptionsSchema`
|
||||
|
||||
`z.object({ filename?: string }).strict()` — no defaults; absence means in-memory.
|
||||
|
||||
### `SqliteAdapterOptionsSchema`
|
||||
|
||||
Alias for `BunDriverOptionsSchema`. Both driver and adapter accept the same option shape.
|
||||
|
||||
### `MigrationRunnerOptionsSchema`
|
||||
|
||||
`z.object({ migrationsFolder?: string, migrationsTable?: string, dryRun?: boolean }).strict()`.
|
||||
|
||||
Defaults: `migrationsFolder = "./migrations"`, `migrationsTable = "__sqlite_migrations"`, `dryRun = false`.
|
||||
|
||||
### Type aliases
|
||||
|
||||
- `BunDriverOptions = z.infer<typeof BunDriverOptionsSchema>`
|
||||
- `WasmDriverOptions = z.infer<typeof WasmDriverOptionsSchema>`
|
||||
- `SqliteAdapterOptions = BunDriverOptions`
|
||||
- `MigrationRunnerOptions = z.infer<typeof MigrationRunnerOptionsSchema>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SqliteAdapterOptions` is deliberately an alias for `BunDriverOptions` rather than a separate schema so that passing an `SqliteAdapterOptions` object to `createBunDriver` is type-safe without casting. If the WASM adapter ever gains `readonly` support the alias can be split without a breaking change to the option names callers already use.
|
||||
68
docs/persistence/database/db-sqlite/src/unsafe/unsafe.md
Normal file
68
docs/persistence/database/db-sqlite/src/unsafe/unsafe.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# unsafe
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `unsafe` subpath exports `rawQuery` — a direct SQL execution function that bypasses the Drizzle ORM layer entirely. It is intentionally segregated behind the `@suaveplan/db-sqlite/unsafe` subpath import to make its use explicit and auditable: any import of this path stands out in code review as a deliberate bypass of the normal query builder.
|
||||
|
||||
**Security warning:** `rawQuery` provides no SQL injection protection. Never pass user-supplied or externally-sourced strings as the `sql` argument. Use only for migration scripts, low-level tooling, or read-only administrative queries where the SQL is fully under developer control.
|
||||
|
||||
## Features
|
||||
|
||||
- `rawQuery<T>(db, sql, params?)` — executes a prepared statement on any `bun:sqlite`-compatible Database instance
|
||||
- Returns rows typed as `T[]` (caller asserts the row shape)
|
||||
- Maps execution errors to `SqliteError(SQLITE.QUERY_FAILED)`
|
||||
- Supports parameterised queries via the `params` array — always use `?` placeholders and pass values in `params` rather than string-interpolating them
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rawQuery } from "@suaveplan/db-sqlite/unsafe";
|
||||
|
||||
const rows = await rawQuery<{ count: number }>(
|
||||
sqlite,
|
||||
"SELECT count(*) AS count FROM sqlite_master WHERE type = ?",
|
||||
["table"]
|
||||
);
|
||||
console.log(rows[0]?.count);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { rawQuery } from "@suaveplan/db-sqlite/unsafe";
|
||||
|
||||
// Used in a migration helper to read schema state before Drizzle is set up
|
||||
async function tableExists(sqlite: unknown, name: string): Promise<boolean> {
|
||||
const rows = await rawQuery<{ found: number }>(
|
||||
sqlite,
|
||||
"SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
[name]
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rawQuery<T>(db, sql, params?): Promise<T[]>`
|
||||
|
||||
Executes `sql` as a prepared statement on `db`. Throws `SqliteError(SQLITE.QUERY_FAILED)` on error.
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `db` | `any` | A `bun:sqlite` Database instance |
|
||||
| `sql` | `string` | The SQL to execute — must not contain user input |
|
||||
| `params` | `unknown[]` | Bind parameters for `?` placeholders |
|
||||
|
||||
**Returns** `Promise<T[]>` — rows shaped as `T`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`rawQuery` uses `db.prepare(sql).all(params)` rather than `db.query(sql).all(params)` to be consistent with the caching strategy used by `SqliteAdapter`. The `params` spread form (`stmt.all(...params)`) passes each parameter as a positional argument, matching `bun:sqlite`'s expected calling convention.
|
||||
|
||||
The `db` parameter is typed as `any` to accept both `bun:sqlite Database` instances and test doubles without requiring a concrete import of `bun:sqlite` types from this module.
|
||||
93
docs/persistence/database/db/README.md
Normal file
93
docs/persistence/database/db/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# @suaveplan/db - Universal relational database adapter interface: DatabaseAdapter (extends DatabaseLike), structured DB.* error codes, Zod-validated config types, injectable connection singletons, MigrationRunner interface, and full OTel instrumentation.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal relational database adapter interface for Suaveplan. Provides the `DatabaseAdapter` contract (extending `DatabaseLike` from `@suaveplan/types`), injectable connection singletons, structured `DB.*` error codes, Zod-validated config types, a `MigrationRunner` interface, and full OpenTelemetry instrumentation.
|
||||
|
||||
This package is the stable foundation that `@suaveplan/db-sqlite` and future adapters build upon.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [database-adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [connection](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [singleton](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [database-error](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [migration](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [migration-runner](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [instruments](./src/telemetry/instruments.md) — `instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating…
|
||||
- [trace](./src/telemetry/trace.md) — `withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boil…
|
||||
- [types](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [database-config](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [database-adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-error](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration-runner](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/telemetry/instruments.md) — `instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating…
|
||||
- [trace](./src/telemetry/trace.md) — `withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boil…
|
||||
- [types](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-config](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-error](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration-runner](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/telemetry/instruments.md) — `instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating…
|
||||
- [trace](./src/telemetry/trace.md) — `withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boil…
|
||||
- [types](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-config](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-error](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration-runner](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/telemetry/instruments.md) — `instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating…
|
||||
- [trace](./src/telemetry/trace.md) — `withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boil…
|
||||
- [types](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-config](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [database-adapter](./src/adapter/database-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_ - [singleton](./src/connection/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [database-error](./src/errors/database-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ - [migration-runner](./src/migration/migration-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/telemetry/instruments.md) — `instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating…
|
||||
- [trace](./src/telemetry/trace.md) — `withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boil…
|
||||
- [types](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_ - [database-config](./src/types/database-config.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/db
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { getSingleton, closeSingleton } from "@suaveplan/db";
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite";
|
||||
|
||||
// Get-or-create a named singleton connection
|
||||
const db = await getSingleton("app", () => new SqliteAdapter({ filename: "./app.db" }));
|
||||
|
||||
// Query
|
||||
const users = await db.query<{ id: number; name: string }>(
|
||||
"SELECT id, name FROM users WHERE active = ?",
|
||||
[1]
|
||||
);
|
||||
|
||||
// Transaction
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await tx.execute("INSERT INTO audit (msg) VALUES (?)", ["login"]);
|
||||
return tx.query<{ id: number }>("SELECT last_insert_rowid() AS id");
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
await closeSingleton("app");
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [`src/adapter/database-adapter.md`](src/adapter/database-adapter.md) | `DatabaseAdapter` interface — full connection contract |
|
||||
| [`src/connection/singleton.md`](src/connection/singleton.md) | `getSingleton` / `closeSingleton` / `hasSingleton` / `closeAllSingletons` |
|
||||
| [`src/errors/database-error.md`](src/errors/database-error.md) | `DatabaseError`, `MigrationError`, `DB_CODES` |
|
||||
| [`src/migration/migration-runner.md`](src/migration/migration-runner.md) | `MigrationRunner` interface |
|
||||
| [`src/telemetry/instruments.md`](src/telemetry/instruments.md) | OTel metric instruments (histogram, counters) |
|
||||
| [`src/telemetry/trace.md`](src/telemetry/trace.md) | `withDbSpan` tracing helper |
|
||||
| [`src/types/database-config.md`](src/types/database-config.md) | `DatabaseConfig`, `MigrationConfig`, Zod schemas |
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | When thrown |
|
||||
|---|---|
|
||||
| `DB.CONNECTION_FAILED` | `connect()` fails |
|
||||
| `DB.NOT_CONNECTED` | Operation attempted before `connect()` |
|
||||
| `DB.QUERY_FAILED` | `query()` or `execute()` throws |
|
||||
| `DB.TRANSACTION_FAILED` | `transaction()` throws or rolls back |
|
||||
| `DB.MIGRATION_FAILED` | `migrate()` fails on a specific migration |
|
||||
|
||||
## Benchmarks
|
||||
|
||||
| Operation | Median |
|
||||
|---|---|
|
||||
| `getSingleton` cache-hit | ~2 µs |
|
||||
| `hasSingleton` lookup | < 1 µs |
|
||||
| `DatabaseConfigSchema.parse` | ~5 µs |
|
||||
| `withDbSpan` overhead (no-op OTel) | ~3 µs |
|
||||
106
docs/persistence/database/db/src/adapter/database-adapter.md
Normal file
106
docs/persistence/database/db/src/adapter/database-adapter.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# DatabaseAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DatabaseAdapter` is the full database connection contract for `@suaveplan/db`. It extends `DatabaseLike` from `@suaveplan/types` (the minimal DI interface used by packages that inject a database without depending on a concrete driver) and adds lifecycle management, migration support, and `AbortSignal` propagation on every async entrypoint. Concrete adapters — `@suaveplan/db-sqlite`, future Postgres and Turso adapters — implement this interface.
|
||||
|
||||
Keeping `DatabaseLike` as the base means any `DatabaseAdapter` can be passed wherever a `DatabaseLike` is expected, enabling clean dependency injection across the project without introducing circular dependencies between domain packages and their database layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `DatabaseLike` — structurally compatible with the minimal DI interface
|
||||
- `connect` / `disconnect` lifecycle with optional `DatabaseConfig`
|
||||
- `query<T>` — typed SELECT, returns `T[]`
|
||||
- `execute` — INSERT / UPDATE / DELETE / DDL, returns `{ rowsAffected: number }`
|
||||
- `transaction<T>` — callback receives active `DatabaseLike` tx handle; auto-commit / auto-rollback
|
||||
- `migrate` — delegates to a `MigrationRunner`; accepts optional `MigrationConfig`
|
||||
- `isConnected` — boolean state predicate; checked by singleton helpers before reconnecting
|
||||
- `AbortSignal` on every async method via `DbOperationOptions`
|
||||
- `close()` alias from `DatabaseLike` — concrete adapters may implement as `this.disconnect()`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { DatabaseAdapter } from "@suaveplan/db";
|
||||
|
||||
class SqliteAdapter implements DatabaseAdapter {
|
||||
#connected = false;
|
||||
get isConnected() { return this.#connected; }
|
||||
|
||||
async connect(config?) { /* open bun:sqlite */ this.#connected = true; }
|
||||
async disconnect() { /* close */ this.#connected = false; }
|
||||
async close() { await this.disconnect(); }
|
||||
|
||||
async query<T>(sql, params?) { /* ... */ return [] as T[]; }
|
||||
async execute(sql, params?) { return { rowsAffected: 0 }; }
|
||||
async transaction(fn) { return fn(this); }
|
||||
async migrate(config?) { return { applied: 0, migrations: [] }; }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { getSingleton, DB_CODES, DatabaseError } from "@suaveplan/db";
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite";
|
||||
|
||||
// Register a named singleton with an isolated registry (safe in tests)
|
||||
const reg = new Map();
|
||||
const db = await getSingleton(
|
||||
"app",
|
||||
() => new SqliteAdapter({ filename: "./app.db" }),
|
||||
undefined,
|
||||
undefined,
|
||||
reg
|
||||
);
|
||||
|
||||
// Abort a long-running query
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 5_000);
|
||||
|
||||
try {
|
||||
const rows = await db.query<{ id: number }>(
|
||||
"SELECT id FROM events WHERE ts > ?",
|
||||
[Date.now() - 86_400_000],
|
||||
{ signal: ac.signal }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof DatabaseError && err.code === DB_CODES.QUERY_FAILED) {
|
||||
console.error("query failed", err.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DatabaseAdapter`
|
||||
|
||||
Extends `DatabaseLike`.
|
||||
|
||||
| Method | Signature | Description |
|
||||
|---|---|---|
|
||||
| `connect` | `(config?, opts?) => Promise<void>` | Open the connection |
|
||||
| `disconnect` | `(opts?) => Promise<void>` | Close and release resources |
|
||||
| `close` | `() => Promise<void>` | `DatabaseLike` alias for `disconnect` |
|
||||
| `query<T>` | `(sql, params?, opts?) => Promise<T[]>` | Typed SELECT |
|
||||
| `execute` | `(sql, params?, opts?) => Promise<{ rowsAffected }>` | DML/DDL |
|
||||
| `transaction<T>` | `(fn, opts?) => Promise<T>` | Auto-commit/rollback |
|
||||
| `migrate` | `(config?, opts?) => Promise<MigrationResult>` | Apply pending migrations |
|
||||
| `isConnected` | `boolean` (readonly) | Connection state |
|
||||
|
||||
### `DbOperationOptions`
|
||||
|
||||
```ts
|
||||
interface DbOperationOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `transaction` callback signature uses `DatabaseLike` (not `DatabaseAdapter`) so implementations can pass a lightweight transaction handle that exposes only `query`/`execute`/`transaction`/`close` without the lifecycle methods. This matches how ORMs typically expose transaction objects and avoids callers calling `connect` or `migrate` on a transaction handle.
|
||||
|
||||
`disconnect` and `close` serve the same purpose — `disconnect` is the richer signature (accepts `DbOperationOptions`); `close` is the `DatabaseLike`-compatible alias with no arguments. Concrete adapters typically implement `disconnect` and delegate `close` to it.
|
||||
120
docs/persistence/database/db/src/connection/singleton.md
Normal file
120
docs/persistence/database/db/src/connection/singleton.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Connection Singletons
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`getSingleton`, `closeSingleton`, `hasSingleton`, and `closeAllSingletons` provide a lightweight singleton registry for `DatabaseAdapter` instances. The primary purpose is to avoid opening multiple connections to the same database within a single process — `getSingleton` is idempotent: the first call creates and connects the adapter, subsequent calls with the same name return the cached reference without reconnecting.
|
||||
|
||||
By accepting an optional `registry` parameter (a plain `Map<string, DatabaseAdapter>`), the helpers are safe for test isolation: each test suite can pass its own private map and have zero cross-test bleed. In production, omitting `registry` uses a module-level default map, replicating the familiar global-singleton pattern without requiring any global state management from callers.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent `getSingleton` — second call with same name returns cached adapter
|
||||
- `DatabaseConfigSchema` validation before `connect` is called
|
||||
- Wraps `connect` failures as `DatabaseError` with `DB.CONNECTION_FAILED`
|
||||
- `closeSingleton` — graceful disconnect + registry removal (no-op for unknown names)
|
||||
- `closeAllSingletons` — drains the entire registry; collects disconnect errors into `AggregateError`
|
||||
- Injectable `registry` parameter — full test isolation without global state
|
||||
- `AbortSignal` threading via `DbOperationOptions`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getSingleton, closeSingleton } from "@suaveplan/db";
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite";
|
||||
|
||||
// Get or create the default "app" connection
|
||||
const db = await getSingleton("app", () => new SqliteAdapter({ filename: "./app.db" }));
|
||||
|
||||
const rows = await db.query<{ id: number }>("SELECT id FROM users");
|
||||
|
||||
// Clean up at process exit
|
||||
await closeSingleton("app");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getSingleton,
|
||||
closeAllSingletons,
|
||||
hasSingleton,
|
||||
} from "@suaveplan/db";
|
||||
import type { DatabaseAdapter } from "@suaveplan/db";
|
||||
|
||||
// Test-isolated registry — no cross-test bleed
|
||||
const testRegistry = new Map<string, DatabaseAdapter>();
|
||||
|
||||
beforeEach(async () => {
|
||||
await getSingleton(
|
||||
"test-db",
|
||||
() => new SqliteAdapter({ filename: ":memory:" }),
|
||||
undefined,
|
||||
undefined,
|
||||
testRegistry
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await closeAllSingletons(undefined, testRegistry);
|
||||
});
|
||||
|
||||
it("has singleton after setup", () => {
|
||||
expect(hasSingleton("test-db", testRegistry)).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getSingleton`
|
||||
|
||||
```ts
|
||||
getSingleton(
|
||||
name: string,
|
||||
factory: () => DatabaseAdapter,
|
||||
config?: Partial<DatabaseConfig>,
|
||||
opts?: DbOperationOptions,
|
||||
registry?: Map<string, DatabaseAdapter>
|
||||
): Promise<DatabaseAdapter>
|
||||
```
|
||||
|
||||
Returns the cached adapter for `name`, or creates one via `factory`, validates `config`, connects it, and caches it. Throws `DatabaseError(DB.CONNECTION_FAILED)` if `connect` fails.
|
||||
|
||||
### `closeSingleton`
|
||||
|
||||
```ts
|
||||
closeSingleton(
|
||||
name: string,
|
||||
opts?: DbOperationOptions,
|
||||
registry?: Map<string, DatabaseAdapter>
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
Calls `disconnect` on the named adapter and removes it from the registry. No-op if `name` is not registered.
|
||||
|
||||
### `hasSingleton`
|
||||
|
||||
```ts
|
||||
hasSingleton(name: string, registry?: Map<string, DatabaseAdapter>): boolean
|
||||
```
|
||||
|
||||
Returns `true` if an adapter is registered under `name`.
|
||||
|
||||
### `closeAllSingletons`
|
||||
|
||||
```ts
|
||||
closeAllSingletons(
|
||||
opts?: DbOperationOptions,
|
||||
registry?: Map<string, DatabaseAdapter>
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
Clears the registry immediately, then disconnects all adapters concurrently. If one or more disconnects fail, an `AggregateError` is thrown after all attempts complete.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is cleared before `disconnect` is called in `closeAllSingletons`. This ensures that even if a `disconnect` rejects, the registry is empty and a subsequent `getSingleton` call will create a fresh adapter rather than returning a half-closed one. The AggregateError is thrown after all disconnect attempts so callers get complete failure information rather than a short-circuit on the first error.
|
||||
|
||||
Config validation uses `DatabaseConfigSchema.parse` (not `.safeParse`) so invalid config throws a `ZodError` immediately — fail-fast at the boundary is preferable to a cryptic driver-level error.
|
||||
107
docs/persistence/database/db/src/errors/database-error.md
Normal file
107
docs/persistence/database/db/src/errors/database-error.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# DatabaseError / MigrationError
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DatabaseError` is the structured error class for all database failures in `@suaveplan/db`. It extends `SuaveplanError` from `@suaveplan/error`, ensuring every database error carries a stable, machine-readable `DB.*` code alongside a human-readable message and optional cause chain. `MigrationError` is a specialisation that records which named migration failed.
|
||||
|
||||
Using `SuaveplanError` as the base means consumers can perform type-safe `instanceof` checks, extract OTel-compatible codes from `error.code`, and inspect cause chains consistently across the entire Suaveplan project.
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `SuaveplanError<DbCode>` — participates in the project-wide error hierarchy
|
||||
- Stable `DB.*` codes registered via `defineCodes` — never change between versions
|
||||
- `cause` threading — wraps lower-level driver errors without losing the original stack
|
||||
- `MigrationError.migration` field — names the exact migration file that failed
|
||||
- `name` property set to the concrete class name for easy identification in logs
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DatabaseError, MigrationError, DB_CODES } from "@suaveplan/db";
|
||||
|
||||
// Throw a connection failure
|
||||
throw new DatabaseError(
|
||||
DB_CODES.CONNECTION_FAILED,
|
||||
"Could not reach postgres://localhost:5432/app"
|
||||
);
|
||||
|
||||
// Throw a migration failure
|
||||
throw new MigrationError("003_add_index", "duplicate column name: email");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DatabaseError, DB_CODES } from "@suaveplan/db";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
async function runQuery(sql: string): Promise<unknown[]> {
|
||||
try {
|
||||
return await driver.query(sql);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError(
|
||||
DB_CODES.QUERY_FAILED,
|
||||
`Query failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ cause: cause instanceof Error ? cause : undefined }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Narrowing in catch blocks
|
||||
try {
|
||||
await runQuery("SELECT * FROM users");
|
||||
} catch (err) {
|
||||
if (err instanceof DatabaseError) {
|
||||
console.error(err.code, err.message); // "DB.QUERY_FAILED Query failed: ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DatabaseError`
|
||||
|
||||
```ts
|
||||
class DatabaseError extends SuaveplanError<DbCode> {
|
||||
constructor(code: DbCode, message: string, options?: SuaveplanErrorOptions): void
|
||||
}
|
||||
```
|
||||
|
||||
- **`code`** (`DbCode`) — one of `DB.CONNECTION_FAILED`, `DB.NOT_CONNECTED`, `DB.QUERY_FAILED`, `DB.TRANSACTION_FAILED`, `DB.MIGRATION_FAILED`
|
||||
- **`message`** (`string`) — human-readable description
|
||||
- **`options.cause`** (`Error | undefined`) — lower-level error that triggered this one
|
||||
|
||||
### `MigrationError`
|
||||
|
||||
```ts
|
||||
class MigrationError extends DatabaseError {
|
||||
readonly migration: string;
|
||||
constructor(migration: string, detail: string, options?: SuaveplanErrorOptions): void
|
||||
}
|
||||
```
|
||||
|
||||
- **`migration`** (`string`) — the migration name (e.g. `"001_init"`)
|
||||
- **`detail`** (`string`) — description of what went wrong
|
||||
- Message format: `"Migration '<migration>' failed: <detail>"`
|
||||
- Code is always `DB.MIGRATION_FAILED`
|
||||
|
||||
### `DB_CODES`
|
||||
|
||||
```ts
|
||||
const DB_CODES: {
|
||||
readonly CONNECTION_FAILED: "DB.CONNECTION_FAILED";
|
||||
readonly NOT_CONNECTED: "DB.NOT_CONNECTED";
|
||||
readonly QUERY_FAILED: "DB.QUERY_FAILED";
|
||||
readonly TRANSACTION_FAILED:"DB.TRANSACTION_FAILED";
|
||||
readonly MIGRATION_FAILED: "DB.MIGRATION_FAILED";
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both classes pass the `message` string directly to `SuaveplanError` as a literal message key rather than an i18n key, so no message-resolver configuration is required. This keeps `@suaveplan/db` dependency-free of any i18n infrastructure. If localisation is needed in a future version, the message key can be replaced with an i18n key and the `context` option used for interpolation.
|
||||
|
||||
`MigrationError` explicitly sets `this.name = "MigrationError"` in the constructor because `SuaveplanError` sets `this.name = new.target.name` — the explicit assignment in the subclass constructor ensures the name is accurate when the class is transpiled or minified.
|
||||
101
docs/persistence/database/db/src/migration/migration-runner.md
Normal file
101
docs/persistence/database/db/src/migration/migration-runner.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# MigrationRunner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MigrationRunner` is the interface that `DatabaseAdapter.migrate` delegates to in concrete adapter implementations. Separating the migration contract from the adapter contract lets adapters swap migration strategies without changing their top-level API — for example, a SQLite adapter might use Drizzle's migration runner while a SurrealDB adapter uses a custom `.surql` file runner, yet both expose the same `DatabaseAdapter.migrate` signature to callers.
|
||||
|
||||
The three-method design mirrors the principle of least surprise: `pendingMigrations` is a read-only query (safe to call for status checks without making changes), `applyMigration` is the atomic unit of work, and `run` is the high-level orchestrator that wires them together.
|
||||
|
||||
## Features
|
||||
|
||||
- `pendingMigrations` — read-only: lists unapplied migrations without side effects
|
||||
- `applyMigration` — atomic unit: applies a single named migration by SQL string
|
||||
- `run` — orchestrator: discovers, orders, and applies all pending migrations
|
||||
- `MigrationError` — thrown with the exact migration name on failure
|
||||
- `AbortSignal` on both `applyMigration` and `run` via `DbOperationOptions`
|
||||
- Accepts optional `MigrationConfig` to override folder/table defaults
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { MigrationRunner } from "@suaveplan/db";
|
||||
import { MigrationError } from "@suaveplan/db";
|
||||
|
||||
class FileMigrationRunner implements MigrationRunner {
|
||||
async pendingMigrations(config?) {
|
||||
const applied = await this.loadApplied(config);
|
||||
const all = await this.discoverFiles(config);
|
||||
return all
|
||||
.filter(m => !applied.has(m.name))
|
||||
.map(m => ({ name: m.name, applied: false }));
|
||||
}
|
||||
|
||||
async applyMigration(name, sql, opts?) {
|
||||
try {
|
||||
await this.db.execute(sql, [], opts);
|
||||
await this.recordApplied(name);
|
||||
} catch (err) {
|
||||
throw new MigrationError(name, err instanceof Error ? err.message : String(err), { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
async run(config?, opts?) {
|
||||
const pending = await this.pendingMigrations(config);
|
||||
const names: string[] = [];
|
||||
for (const { name } of pending) {
|
||||
const sql = await this.loadSql(name, config);
|
||||
await this.applyMigration(name, sql, opts);
|
||||
names.push(name);
|
||||
}
|
||||
return { applied: names.length, migrations: names };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { MigrationRunner } from "@suaveplan/db";
|
||||
import { MigrationError, withDbSpan } from "@suaveplan/db";
|
||||
|
||||
// Wrapping run with OTel tracing
|
||||
async function runMigrations(runner: MigrationRunner): Promise<void> {
|
||||
const result = await withDbSpan("db.migrate", () =>
|
||||
runner.run({ migrationsFolder: "./db/migrations" })
|
||||
);
|
||||
console.log(`Applied ${result.applied} migration(s):`, result.migrations);
|
||||
}
|
||||
|
||||
// Status check without applying
|
||||
async function checkMigrationStatus(runner: MigrationRunner): Promise<void> {
|
||||
const pending = await runner.pendingMigrations();
|
||||
if (pending.length > 0) {
|
||||
console.warn(`${pending.length} unapplied migrations:`, pending.map(m => m.name));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `MigrationRunner`
|
||||
|
||||
```ts
|
||||
interface MigrationRunner {
|
||||
pendingMigrations(config?: MigrationConfig): Promise<MigrationStatus[]>;
|
||||
applyMigration(name: string, sql: string, opts?: DbOperationOptions): Promise<void>;
|
||||
run(config?: MigrationConfig, opts?: DbOperationOptions): Promise<MigrationResult>;
|
||||
}
|
||||
```
|
||||
|
||||
- **`pendingMigrations`** — returns only unapplied migrations; never mutates state
|
||||
- **`applyMigration`** — applies one migration; throws `MigrationError` on failure with the migration's name in `error.migration`
|
||||
- **`run`** — discovers pending migrations via `pendingMigrations` and applies them in order via `applyMigration`; returns aggregate `MigrationResult`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`MigrationRunner` is intentionally an interface rather than an abstract class. This keeps the dependency graph clean — the runner itself has no implementation, so it imposes no runtime cost. Concrete adapters in `@suaveplan/db-sqlite` and future packages supply the implementation.
|
||||
|
||||
The `AbortSignal` is only threaded through `applyMigration` and `run` — `pendingMigrations` is a read-only operation and is not expected to take long enough to warrant cancellation in typical use cases. Implementors may add signal support to `pendingMigrations` as a non-breaking extension.
|
||||
97
docs/persistence/database/db/src/telemetry/instruments.md
Normal file
97
docs/persistence/database/db/src/telemetry/instruments.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# OTel Instruments
|
||||
|
||||
## Purpose
|
||||
|
||||
`instruments.ts` provides lazily-initialised OpenTelemetry metric instruments for `@suaveplan/db`. Rather than creating meters at module load time (which would execute OTel SDK code before any SDK is registered and could silently fail), instruments are created on first use via a module-level singleton guard. This ensures the package works correctly both when an OTel SDK exporter is configured and when it is not — in the latter case, the OTel no-op provider handles all calls without errors.
|
||||
|
||||
Four instruments cover the core observability surface: query/execute latency, connection lifecycle, transaction outcomes, and migration counts.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy initialisation — OTel meter created on first call, not at module load
|
||||
- `resetInstruments()` — clears the singleton for test isolation
|
||||
- `recordQueryDuration(ms, operation)` — histogram labelled by `db.operation`
|
||||
- `recordConnectionDelta(±1, dbName)` — UpDownCounter labelled by `db.name`
|
||||
- `recordTransaction(outcome)` — counter labelled by `db.outcome`
|
||||
- `recordMigration(name)` — counter labelled by `db.migration.name`
|
||||
- Works with no-op OTel provider — safe when no SDK is configured
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordQueryDuration,
|
||||
recordConnectionDelta,
|
||||
} from "@suaveplan/db/telemetry/instruments";
|
||||
|
||||
// In a concrete adapter's connect():
|
||||
recordConnectionDelta(1, "main");
|
||||
|
||||
// In a concrete adapter's query():
|
||||
const start = performance.now();
|
||||
const rows = await driver.query(sql);
|
||||
recordQueryDuration(performance.now() - start, "query");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordTransaction,
|
||||
recordMigration,
|
||||
resetInstruments,
|
||||
} from "@suaveplan/db/telemetry/instruments";
|
||||
|
||||
// In a concrete adapter's transaction():
|
||||
let outcome: "committed" | "rolled_back" = "rolled_back";
|
||||
try {
|
||||
await fn(tx);
|
||||
outcome = "committed";
|
||||
} finally {
|
||||
recordTransaction(outcome);
|
||||
}
|
||||
|
||||
// In a migration runner after each applied migration:
|
||||
recordMigration("003_add_index");
|
||||
|
||||
// In tests — reset between test runs to avoid state leak:
|
||||
beforeEach(() => resetInstruments());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `recordQueryDuration(durationMs, operation)`
|
||||
|
||||
Records a query or execute duration.
|
||||
|
||||
- `durationMs` — elapsed time in milliseconds
|
||||
- `operation` — `"query"` or `"execute"`
|
||||
|
||||
### `recordConnectionDelta(delta, dbName)`
|
||||
|
||||
Adjusts the active connection count.
|
||||
|
||||
- `delta` — `1` on connect, `-1` on disconnect
|
||||
- `dbName` — logical name of the database (used as the `db.name` attribute)
|
||||
|
||||
### `recordTransaction(outcome)`
|
||||
|
||||
Increments the transaction counter.
|
||||
|
||||
- `outcome` — `"committed"` or `"rolled_back"`
|
||||
|
||||
### `recordMigration(migrationName)`
|
||||
|
||||
Increments the migration counter.
|
||||
|
||||
- `migrationName` — the name of the applied migration file
|
||||
|
||||
### `resetInstruments()`
|
||||
|
||||
Clears the cached instruments singleton. Useful in unit tests to avoid state from a prior test bleeding into subsequent ones.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments singleton guard uses an `undefined` check rather than a lazy initialiser class so there is no closure overhead — the check is a single `!== undefined` comparison per call. The `metrics.getMeter("@suaveplan/db")` call is safe under the OTel no-op provider because it returns a no-op meter whose `createHistogram`, `createUpDownCounter`, and `createCounter` methods all return no-op instruments that silently discard all data.
|
||||
|
||||
`recordQueryDuration` uses a histogram (not a counter) so downstream dashboards can compute percentiles — P50/P95/P99 query latency is far more useful than a total call count for diagnosing slow queries.
|
||||
76
docs/persistence/database/db/src/telemetry/trace.md
Normal file
76
docs/persistence/database/db/src/telemetry/trace.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# withDbSpan
|
||||
|
||||
## Purpose
|
||||
|
||||
`withDbSpan` is a thin OTel tracing helper that wraps any async database operation in a named span. It handles the boilerplate of starting a span, setting its status on success or error, recording exceptions, and always ending the span — so concrete adapters and migration runners can add tracing with a single wrapper call rather than repeating the span lifecycle pattern in every method.
|
||||
|
||||
Like the instruments module, `withDbSpan` relies on the OTel no-op tracer when no SDK is configured, so it is safe to call in any environment including tests with no OTel setup.
|
||||
|
||||
## Features
|
||||
|
||||
- Creates a named span via `trace.getTracer("@suaveplan/db")`
|
||||
- Sets `SpanStatusCode.OK` on successful completion
|
||||
- Sets `SpanStatusCode.ERROR` and records the exception on throw
|
||||
- Always ends the span (via `finally`) — no span leaks
|
||||
- Accepts optional `Attributes` for span metadata (e.g. `db.name`, `db.operation`)
|
||||
- Works with the OTel no-op provider — zero overhead when no exporter is registered
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { withDbSpan } from "@suaveplan/db/telemetry/trace";
|
||||
|
||||
const rows = await withDbSpan("db.query", async () => {
|
||||
return driver.query("SELECT * FROM users");
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { withDbSpan } from "@suaveplan/db/telemetry/trace";
|
||||
|
||||
// With span attributes for richer trace data
|
||||
const result = await withDbSpan(
|
||||
"db.execute",
|
||||
async () => driver.execute("DELETE FROM sessions WHERE expires_at < ?", [Date.now()]),
|
||||
{
|
||||
"db.name": "app",
|
||||
"db.operation": "execute",
|
||||
"db.statement": "DELETE FROM sessions",
|
||||
}
|
||||
);
|
||||
|
||||
// Wrapping a full transaction
|
||||
const transferResult = await withDbSpan("db.transaction.transfer", async () => {
|
||||
return adapter.transaction(async (tx) => {
|
||||
await tx.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, "sender"]);
|
||||
await tx.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, "receiver"]);
|
||||
return { ok: true };
|
||||
});
|
||||
}, { "transfer.amount": 100 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `withDbSpan<T>`
|
||||
|
||||
```ts
|
||||
withDbSpan<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>,
|
||||
attrs?: Attributes
|
||||
): Promise<T>
|
||||
```
|
||||
|
||||
- **`name`** — OTel span name (e.g. `"db.query"`, `"db.migrate"`)
|
||||
- **`fn`** — async function to execute inside the span
|
||||
- **`attrs`** — optional `Attributes` map attached to the span at creation time
|
||||
- **Returns** — the resolved value of `fn()`
|
||||
- **Throws** — re-throws whatever `fn()` throws after recording the exception on the span
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`withDbSpan` uses the module-level `tracer` constant (created once at import time via `trace.getTracer("@suaveplan/db")`) rather than calling `getTracer` on every invocation. The OTel spec guarantees that `getTracer` with the same name returns the same tracer instance, so this is a safe optimisation.
|
||||
|
||||
Non-Error thrown values (e.g. strings, numbers) are wrapped in `new Error(String(err))` before being passed to `span.recordException`. This satisfies the OTel SDK's type requirement for `recordException` while still re-throwing the original value, so callers that catch strings continue to work as before.
|
||||
117
docs/persistence/database/db/src/types/database-config.md
Normal file
117
docs/persistence/database/db/src/types/database-config.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# DatabaseConfig / MigrationConfig
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DatabaseConfig` and `MigrationConfig` are the two primary configuration shapes for `@suaveplan/db`. They define the options accepted by `DatabaseAdapter.connect` and `DatabaseAdapter.migrate` respectively, and are validated at runtime via their companion Zod schemas. Centralising config shapes here ensures every concrete adapter (SQLite, Postgres, SurrealDB) receives the same validated input.
|
||||
|
||||
By deriving the TypeScript types from Zod schemas (`z.infer<typeof ...>`), the types and runtime validation are always in sync — adding a new field in one place adds it to both automatically.
|
||||
|
||||
## Features
|
||||
|
||||
- `DatabaseConfig` — connection settings: optional file path, network URL, and read-only mode flag
|
||||
- `MigrationConfig` — migration settings: migrations folder path and tracking table name, both with sensible defaults
|
||||
- `MigrationStatus` — per-migration state returned by `pendingMigrations`
|
||||
- `MigrationResult` — aggregate result returned by `migrate` / `MigrationRunner.run`
|
||||
- Strict Zod schemas (`.strict()`) — unknown fields throw validation errors rather than being silently stripped
|
||||
- Default values baked into the Zod schemas: `readonlyMode` defaults to `false`, `migrationsFolder` to `"./drizzle"`, `migrationsTable` to `"__drizzle_migrations"`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
DatabaseConfigSchema,
|
||||
MigrationConfigSchema,
|
||||
} from "@suaveplan/db";
|
||||
|
||||
// Parse and validate adapter config
|
||||
const dbConfig = DatabaseConfigSchema.parse({
|
||||
filename: ":memory:",
|
||||
readonlyMode: false,
|
||||
});
|
||||
|
||||
// Parse and validate migration config
|
||||
const migConfig = MigrationConfigSchema.parse({
|
||||
migrationsFolder: "./db/migrations",
|
||||
});
|
||||
// { migrationsFolder: "./db/migrations", migrationsTable: "__drizzle_migrations" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { DatabaseConfig, MigrationConfig } from "@suaveplan/db";
|
||||
import { DatabaseConfigSchema } from "@suaveplan/db";
|
||||
|
||||
function buildAdapter(raw: Partial<DatabaseConfig>): MyAdapter {
|
||||
// Validate at the boundary — unknown fields throw ZodError
|
||||
const config = DatabaseConfigSchema.parse(raw);
|
||||
return new MyAdapter(config);
|
||||
}
|
||||
|
||||
// TypeScript consumers that don't need runtime validation can use the type directly:
|
||||
function connectPg(config: DatabaseConfig): Promise<void> {
|
||||
if (!config.url) throw new Error("url required for PostgreSQL");
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DatabaseConfig`
|
||||
|
||||
```ts
|
||||
interface DatabaseConfig {
|
||||
filename?: string; // File path for embedded databases (e.g. ':memory:')
|
||||
url?: string; // Connection URL for network databases
|
||||
readonlyMode?: boolean; // Default: false
|
||||
}
|
||||
```
|
||||
|
||||
### `DatabaseConfigSchema`
|
||||
|
||||
Zod schema matching `DatabaseConfig`. Strict — rejects unknown fields.
|
||||
|
||||
### `MigrationConfig`
|
||||
|
||||
```ts
|
||||
interface MigrationConfig {
|
||||
migrationsFolder?: string; // Default: './drizzle'
|
||||
migrationsTable?: string; // Default: '__drizzle_migrations'
|
||||
}
|
||||
```
|
||||
|
||||
### `MigrationConfigSchema`
|
||||
|
||||
Zod schema matching `MigrationConfig`. Strict — rejects unknown fields.
|
||||
|
||||
### `MigrationStatus`
|
||||
|
||||
```ts
|
||||
interface MigrationStatus {
|
||||
name: string;
|
||||
applied: boolean;
|
||||
appliedAt?: Date;
|
||||
}
|
||||
```
|
||||
|
||||
Returned by `MigrationRunner.pendingMigrations`.
|
||||
|
||||
### `MigrationResult`
|
||||
|
||||
```ts
|
||||
interface MigrationResult {
|
||||
applied: number; // Count of migrations applied in this run
|
||||
migrations: string[]; // Names of applied migrations in order
|
||||
}
|
||||
```
|
||||
|
||||
Returned by `DatabaseAdapter.migrate` and `MigrationRunner.run`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both schemas use `.strict()` to catch typos in field names early. If a future version needs to support passthrough (e.g. for driver-specific extensions), that should be handled by extending the base schema rather than loosening it — keeping the core strict preserves the type-safety guarantee at the adapter boundary.
|
||||
|
||||
The `MigrationStatus` and `MigrationResult` types are plain interfaces (not Zod-derived) because they are output shapes returned to callers, not input shapes validated at the boundary.
|
||||
58
docs/persistence/database/migration/README.md
Normal file
58
docs/persistence/database/migration/README.md
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
|
||||
# surrealdb-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`SurrealDbAdapter` adapts a SurrealDB instance to the minimal `execute`/`query` interface required by `DatabaseMigrationStore` and migration context objects. Because SurrealDB uses named query variables rather than positional parameters, this adapter translates positional `params` arrays to `{ p0, p1, … }` named variable maps automatically. This allows the same migration SQL templates to work across SQLite (positional `?`) and SurrealDB (named `$p0`) by using a consistent calling convention.
|
||||
|
||||
## Features
|
||||
|
||||
- `query<T>(sql, params?)` — executes a SurrealDB query and returns typed results.
|
||||
- `execute(sql, params?)` — executes a non-query statement; always returns `{ rowsAffected: 0 }` (SurrealDB does not expose row counts).
|
||||
- Translates positional params array to named vars (`p0`, `p1`, …).
|
||||
- Wraps errors in `MigrationError(MIGRATION.STORE_ERROR)` or `MIGRATION.FAILED`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SurrealDbAdapter } from "@suaveplan/migration";
|
||||
import { DatabaseMigrationStore } from "@suaveplan/migration";
|
||||
import Surreal from "surrealdb";
|
||||
|
||||
const db = new Surreal();
|
||||
await db.connect("http://localhost:8000");
|
||||
|
||||
const adapter = new SurrealDbAdapter({ db });
|
||||
const store = new DatabaseMigrationStore({ adapter });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SurrealDbAdapter } from "@suaveplan/migration";
|
||||
import { MigrationRunner, MemoryMigrationStore } from "@suaveplan/migration";
|
||||
|
||||
const adapter = new SurrealDbAdapter({ db: surrealInstance });
|
||||
const store = new MemoryMigrationStore(); // or DatabaseMigrationStore
|
||||
const runner = new MigrationRunner({ migrations, store });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SurrealDbAdapter`
|
||||
|
||||
**Constructor**: `(config: SurrealDbAdapterConfig)`
|
||||
|
||||
- `config.db` — object implementing `query<T>(sql, vars?): Promise<T[]>`.
|
||||
|
||||
### `query<T>(sql: string, params?: readonly unknown[]): Promise<T[]>`
|
||||
|
||||
Translates params to named vars and delegates to `db.query`. Returns an empty array if the result is not an array.
|
||||
|
||||
### `execute(sql: string, params?: readonly unknown[]): Promise<{ rowsAffected: number }>`
|
||||
|
||||
Executes SQL via `db.query`. Always returns `{ rowsAffected: 0 }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
SurrealDB's `query` method returns an array of result sets per SQL statement. This adapter flattens the outer array and returns the first result set. For multi-statement SQL, callers should split statements and call `query` for each.
|
||||
@@ -0,0 +1,64 @@
|
||||
# batch-migrator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`BatchMigrator` orchestrates migrations across multiple database targets simultaneously. It is useful in multi-tenant architectures where each tenant has a separate database, or in microservice deployments where several service databases must be migrated as part of a single release. The migrator processes targets in configurable concurrency batches using `Promise.allSettled`, so a single target failure does not block others.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `concurrency` (default 5) — processes this many targets in parallel per batch.
|
||||
- `Promise.allSettled` semantics — partial failures are collected rather than aborting the run.
|
||||
- Returns `{ results, failed, succeeded }` with a map from target name to `MigrationResult`.
|
||||
- AbortSignal forwarding to each runner.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BatchMigrator } from "@suaveplan/migration";
|
||||
|
||||
const migrator = new BatchMigrator({ targets: [
|
||||
{ name: "tenant-a", runner: runnerA },
|
||||
{ name: "tenant-b", runner: runnerB },
|
||||
] });
|
||||
|
||||
const result = await migrator.run();
|
||||
// result.succeeded = ["tenant-a", "tenant-b"]
|
||||
// result.failed = []
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BatchMigrator } from "@suaveplan/migration";
|
||||
|
||||
const migrator = new BatchMigrator({ targets: tenantRunners, concurrency: 10 });
|
||||
const result = await migrator.run({ signal: controller.signal });
|
||||
|
||||
if (result.failed.length > 0) {
|
||||
console.error(`Failed targets: ${result.failed.join(", ")}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class BatchMigrator`
|
||||
|
||||
**Constructor**: `(config: BatchMigratorConfig)`
|
||||
|
||||
- `config.targets` — array of `{ name: string; runner: MigrationRunnerLike }`.
|
||||
- `config.concurrency?` — parallel target count per batch (default 5).
|
||||
|
||||
### `run(options?: RunOptions): Promise<BatchMigrationResult>`
|
||||
|
||||
Returns:
|
||||
|
||||
- `results: Map<string, MigrationResult>` — per-target outcome.
|
||||
- `failed: string[]` — names of targets that threw.
|
||||
- `succeeded: string[]` — names of targets that resolved.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The concurrency loop slices `targets` into chunks of `concurrency` and awaits each chunk before starting the next. This provides bounded parallelism without spinning up all targets at once. Failed targets receive a stub `MigrationResult` with `failed: [name]` to ensure the results map is always complete.
|
||||
@@ -0,0 +1,64 @@
|
||||
# migration-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the structured error class and stable error-code registry for the `@suaveplan/migration` package. All thrown errors in this package extend `MigrationError`, which itself extends `SuaveplanError` from `@suaveplan/error`, ensuring that every error carries a machine-readable code, an optional cause chain, and OTel-compatible span events.
|
||||
|
||||
Using a dedicated error hierarchy makes it straightforward for callers to distinguish migration failures from other application errors, and to implement targeted recovery logic (e.g. retry on lock timeouts, alert on checksum mismatches).
|
||||
|
||||
## Features
|
||||
|
||||
- `MIGRATION_CODES` — frozen record mapping reason strings to `MIGRATION.<REASON>` codes, built with `defineCodes`.
|
||||
- `MigrationError` — concrete error class that accepts one of the defined codes and a human-readable message, forwarding optional `cause` through the SuaveplanError chain.
|
||||
- Full TypeScript literal typing: `MigrationErrorCode` is inferred from the keys of `MIGRATION_CODES`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MigrationError, MIGRATION_CODES } from "@suaveplan/migration/errors/migration-error";
|
||||
|
||||
throw new MigrationError(
|
||||
MIGRATION_CODES.FAILED,
|
||||
"Migration 001_create_users failed during execution",
|
||||
{ cause: originalError },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MigrationError, MIGRATION_CODES } from "@suaveplan/migration/errors/migration-error";
|
||||
|
||||
function assertChecksum(stored: string, computed: string, id: string): void {
|
||||
if (stored !== computed) {
|
||||
throw new MigrationError(
|
||||
MIGRATION_CODES.CHECKSUM_MISMATCH,
|
||||
`Checksum mismatch for migration ${id}: stored=${stored} computed=${computed}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `MIGRATION_CODES`
|
||||
|
||||
Frozen record: `{ FAILED, LOCK_TIMEOUT, CHECKSUM_MISMATCH, NOT_FOUND, STORE_ERROR, PLAN_INVALID, ABORTED, DRY_RUN }`.
|
||||
Each value is the string `"MIGRATION.<REASON>"`.
|
||||
|
||||
### `MigrationErrorCode`
|
||||
|
||||
Union type of all values in `MIGRATION_CODES`.
|
||||
|
||||
### `class MigrationError`
|
||||
|
||||
Extends `SuaveplanError<MigrationErrorCode>`.
|
||||
|
||||
**Constructor**: `(code: MigrationErrorCode, message: string, options?: { cause?: unknown })`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` from `@suaveplan/error` builds the code map at module load time and freezes it, preventing accidental mutation. The `cause` option is only forwarded when it is an `Error` instance, matching `SuaveplanError`'s convention.
|
||||
@@ -0,0 +1,61 @@
|
||||
# distributed-lock
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a `DistributedMigrationLock` that coordinates migration locking across multiple application instances using a pluggable `DistributedLockAdapter`. Also exports `InMemoryDistributedLockAdapter` for testing and single-node use. The distributed lock uses the same exponential back-off polling strategy as the memory lock, delegating actual lock storage to the adapter.
|
||||
|
||||
## Features
|
||||
|
||||
- `DistributedMigrationLock` — delegates to any `DistributedLockAdapter` with configurable `key` and `ttl`.
|
||||
- `InMemoryDistributedLockAdapter` — TTL-based in-memory adapter for testing without external infrastructure.
|
||||
- Exponential back-off polling from 50 ms up to 1 s ceiling.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DistributedMigrationLock, InMemoryDistributedLockAdapter } from "@suaveplan/migration";
|
||||
|
||||
const lock = new DistributedMigrationLock({
|
||||
adapter: new InMemoryDistributedLockAdapter(),
|
||||
key: "app_migration_lock",
|
||||
ttl: 60_000,
|
||||
});
|
||||
|
||||
const acquired = await lock.acquire(30_000);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DistributedMigrationLock } from "@suaveplan/migration";
|
||||
import type { DistributedLockAdapter } from "@suaveplan/migration";
|
||||
|
||||
class PgAdvisoryLockAdapter implements DistributedLockAdapter {
|
||||
async acquire(key: string, ttl: number) { /* pg_try_advisory_lock */ return true; }
|
||||
async release(key: string) { /* pg_advisory_unlock */ }
|
||||
}
|
||||
|
||||
const lock = new DistributedMigrationLock({ adapter: new PgAdvisoryLockAdapter() });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class DistributedMigrationLock implements MigrationLockAdapter`
|
||||
|
||||
**Constructor**: `(config: DistributedLockConfig)`
|
||||
|
||||
- `config.adapter` — `DistributedLockAdapter` implementation.
|
||||
- `config.key?` — lock key (default `"migration_lock"`).
|
||||
- `config.ttl?` — lock TTL in ms (default `60_000`).
|
||||
|
||||
### `class InMemoryDistributedLockAdapter implements DistributedLockAdapter`
|
||||
|
||||
- `acquire(key, ttl): Promise<boolean>` — acquires if no unexpired entry for `key` exists.
|
||||
- `release(key): Promise<void>` — removes the entry.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`isLocked` uses a probe key (`<key>__probe`) with zero TTL to test whether anything is competing for the lock — this avoids reading lock state directly, which the interface does not expose.
|
||||
@@ -0,0 +1,57 @@
|
||||
# memory-lock
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MemoryMigrationLock` implements `MigrationLockAdapter` using an in-process boolean flag. It prevents concurrent migration runs within a single Node.js process — useful during development and in single-instance deployments. Multi-instance deployments should use `DistributedMigrationLock` or `RedisDistributedLock` instead.
|
||||
|
||||
## Features
|
||||
|
||||
- `acquire(timeoutMs?)` — polls with exponential back-off (10 ms → 500 ms ceiling) until the lock is available or the timeout expires.
|
||||
- `release()` — unsets the lock flag immediately.
|
||||
- `isLocked()` — synchronous check returning the current lock state.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryMigrationLock } from "@suaveplan/migration";
|
||||
|
||||
const lock = new MemoryMigrationLock();
|
||||
const acquired = await lock.acquire(5000);
|
||||
if (acquired) {
|
||||
// run migrations
|
||||
await lock.release();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemoryMigrationLock } from "@suaveplan/migration";
|
||||
|
||||
const lock = new MemoryMigrationLock();
|
||||
// Attempt to acquire with a short timeout
|
||||
const acquired = await lock.acquire(100);
|
||||
if (!acquired) {
|
||||
throw new Error("Could not acquire migration lock within 100 ms");
|
||||
}
|
||||
try {
|
||||
// critical section
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class MemoryMigrationLock implements MigrationLockAdapter`
|
||||
|
||||
- `acquire(timeoutMs?: number): Promise<boolean>` — returns `true` if acquired within the timeout (default 30 000 ms), `false` otherwise.
|
||||
- `release(): Promise<void>` — always resolves, sets locked flag to `false`.
|
||||
- `isLocked(): Promise<boolean>` — returns current locked state.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The polling loop uses exponential back-off starting at 10 ms and capped at 500 ms to balance responsiveness with CPU efficiency. `Math.max(0, ms)` guards against negative delays at the deadline boundary.
|
||||
@@ -0,0 +1,68 @@
|
||||
# redis-lock
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`RedisDistributedLock` implements `MigrationLockAdapter` using Redis `SET NX PX` semantics. This provides a distributed advisory lock suitable for multi-instance deployments where a shared Redis server is available. The implementation requires only a minimal Redis client interface (`RedisLockClient`) with `set` and `del` methods, avoiding any hard dependency on a specific Redis client library.
|
||||
|
||||
## Features
|
||||
|
||||
- `SET NX PX` lock acquisition — atomic test-and-set with TTL expiry.
|
||||
- Exponential back-off polling from 50 ms up to 1 s ceiling.
|
||||
- Configurable `key`, `ttl`, and acquisition `timeoutMs`.
|
||||
- `isLocked()` probe using a disposable key to avoid reading lock state directly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RedisDistributedLock } from "@suaveplan/migration";
|
||||
import { createClient } from "redis";
|
||||
|
||||
const redisClient = createClient();
|
||||
await redisClient.connect();
|
||||
|
||||
const lock = new RedisDistributedLock({
|
||||
client: redisClient,
|
||||
key: "app_migration_lock",
|
||||
ttl: 30_000,
|
||||
});
|
||||
|
||||
const acquired = await lock.acquire(10_000);
|
||||
if (acquired) {
|
||||
try { /* run migrations */ } finally { await lock.release(); }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { RedisDistributedLock } from "@suaveplan/migration";
|
||||
|
||||
// Inject a stub Redis client in tests
|
||||
const stubClient = {
|
||||
async set() { return "OK"; },
|
||||
async del() { return 1; },
|
||||
};
|
||||
|
||||
const lock = new RedisDistributedLock({ client: stubClient });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class RedisDistributedLock implements MigrationLockAdapter`
|
||||
|
||||
**Constructor**: `(config: RedisLockConfig)`
|
||||
|
||||
- `config.client` — Redis-compatible client with `set(key, value, opts)` and `del(key)` methods.
|
||||
- `config.key?` — lock key (default `"migration_lock"`).
|
||||
- `config.ttl?` — lock TTL in ms (default `60_000`).
|
||||
|
||||
- `acquire(timeoutMs?): Promise<boolean>` — polls until acquired or timeout.
|
||||
- `release(): Promise<void>` — deletes the lock key.
|
||||
- `isLocked(): Promise<boolean>` — uses a probe key to check lock state.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `isLocked` probe key (`<key>__probe`) uses a 1 ms TTL and is immediately deleted if acquired, minimising leakage. This approach avoids a Redis `GET` which the minimal `RedisLockClient` interface does not expose.
|
||||
@@ -0,0 +1,56 @@
|
||||
# migration-planner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The migration planner produces `MigrationPlan` objects that describe which migrations should be applied or rolled back, without executing them. Separating planning from execution makes dry-run reporting straightforward, enables validation before any database writes, and simplifies unit testing by removing I/O dependencies from the planning step.
|
||||
|
||||
## Features
|
||||
|
||||
- `sortMigrations(migrations)` — lexicographic sort by `id`, putting numeric prefixes in ascending order.
|
||||
- `planUp(all, applied)` — returns all pending (not-yet-applied) migrations sorted ascending.
|
||||
- `planDown(all, applied, steps?)` — returns the last N applied migrations in reverse (rollback) order.
|
||||
- `checksumForMigration(migration)` — returns the stored checksum or computes one from the up function body.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { planUp, sortMigrations } from "@suaveplan/migration";
|
||||
|
||||
const plan = planUp(allMigrations, []);
|
||||
// plan.toApply contains all migrations in ascending id order
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { planDown } from "@suaveplan/migration";
|
||||
|
||||
// Rollback only the last 2 applied migrations
|
||||
const plan = planDown(allMigrations, appliedRecords, 2);
|
||||
// plan.toRollback[0] is the most recently applied migration
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `sortMigrations(migrations: Migration[]): Migration[]`
|
||||
|
||||
Returns a new array sorted by `id` ascending. Does not mutate the input.
|
||||
|
||||
### `planUp(all: Migration[], applied: MigrationRecord[]): MigrationPlan`
|
||||
|
||||
Returns `{ toApply: Migration[], toRollback: [] }`. `toApply` contains migrations whose `id` is not in `applied`.
|
||||
|
||||
### `planDown(all: Migration[], applied: MigrationRecord[], steps?: number): MigrationPlan`
|
||||
|
||||
Returns `{ toApply: [], toRollback: Migration[] }`. `toRollback` is at most `steps` migrations (all if omitted), most-recent first.
|
||||
|
||||
### `checksumForMigration(migration: Migration): string`
|
||||
|
||||
Returns `migration.checksum` if set, otherwise computes SHA-256 of `migration.up.toString()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`planDown` reverses the sorted applied list before slicing, ensuring rollbacks proceed in most-recent-first order (LIFO). `sortMigrations` uses `localeCompare` for reliable multi-character prefix handling.
|
||||
@@ -0,0 +1,56 @@
|
||||
# dry-run-reporter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DryRunReporter` collects per-migration entries during a dry-run pass and produces a structured `DryRunReport` with an aggregate risk level. It allows callers to understand which migrations would be applied, their individual risk levels, and the worst-case overall risk without executing any SQL. This is particularly valuable in CI pipelines and change-review workflows.
|
||||
|
||||
## Features
|
||||
|
||||
- `record(entry)` — accumulates a `DryRunEntry` (migrationId, direction, riskLevel, optional reason).
|
||||
- `report()` — returns a snapshot of all entries, the computed total risk, and a human-readable summary.
|
||||
- Risk level aggregation: `totalRisk` is the maximum across all entries (`low` < `medium` < `high`).
|
||||
- Summary string differentiates between `up` and `down` counts.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DryRunReporter } from "@suaveplan/migration";
|
||||
|
||||
const reporter = new DryRunReporter();
|
||||
reporter.record({ migrationId: "001", direction: "up", riskLevel: "low" });
|
||||
const report = reporter.report();
|
||||
// report.totalRisk === "low"
|
||||
// report.summary === "Dry run: 1 up migration(s) would be applied. Overall risk: low."
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DryRunReporter } from "@suaveplan/migration";
|
||||
|
||||
const reporter = new DryRunReporter();
|
||||
reporter.record({ migrationId: "001", direction: "up", riskLevel: "low", reason: "add table" });
|
||||
reporter.record({ migrationId: "002", direction: "up", riskLevel: "high", reason: "drop column" });
|
||||
const report = reporter.report();
|
||||
// report.totalRisk === "high"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class DryRunReporter`
|
||||
|
||||
- `record(entry: DryRunEntry): void` — appends an entry. `entry` fields: `migrationId`, `direction`, `riskLevel` (`"low"|"medium"|"high"`), optional `reason`.
|
||||
- `report(): DryRunReport` — returns `{ entries, totalRisk, summary }`.
|
||||
|
||||
### `DryRunReport`
|
||||
|
||||
- `entries: DryRunEntry[]` — snapshot of all recorded entries.
|
||||
- `totalRisk: RiskLevel` — maximum risk level across all entries.
|
||||
- `summary: string` — human-readable description.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`RISK_ORDER` maps risk levels to integers for comparison. `report()` returns a spread copy of `entries` so callers cannot mutate the internal list. When no entries are recorded, `totalRisk` is `"low"` and the summary says "No migrations to apply."
|
||||
@@ -0,0 +1,61 @@
|
||||
# migration-executor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MigrationExecutor` takes a `MigrationPlan` and actually runs each migration in the specified direction, recording results in the store, emitting lifecycle events, and surfacing structured errors. It is the only component in the migration package that performs database writes. Separating execution from planning makes it easy to test each in isolation and to slot in different execution strategies (e.g. transactional, concurrent) without changing the planner logic.
|
||||
|
||||
## Features
|
||||
|
||||
- Runs `toApply` (direction `up`) or `toRollback` (direction `down`) from the plan.
|
||||
- AbortSignal cancellation between each migration step.
|
||||
- Checksum validation before applying already-recorded migrations.
|
||||
- Dry-run mode: records which migrations would run without executing SQL.
|
||||
- Event emission: `migration:plan`, `migration:before`, `migration:after`, `migration:failed`.
|
||||
- Wraps all thrown errors in `MigrationError(MIGRATION.FAILED)` unless already a `MigrationError`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MigrationExecutor } from "@suaveplan/migration";
|
||||
|
||||
const executor = new MigrationExecutor({ store, context, dryRun: false });
|
||||
const result = await executor.execute(plan, "up");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MigrationExecutor } from "@suaveplan/migration";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
const events = new EventBus<MigrationEventMap>();
|
||||
events.on("migration:after", ({ migration, durationMs }) => {
|
||||
console.log(`${migration.id} applied in ${durationMs}ms`);
|
||||
});
|
||||
|
||||
const executor = new MigrationExecutor({ store, context, events });
|
||||
const result = await executor.execute(plan, "up", { signal });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class MigrationExecutor`
|
||||
|
||||
**Constructor**: `(options: ExecutorOptions, clock?: Clock)`
|
||||
|
||||
- `options.store` — migration store adapter for recording results.
|
||||
- `options.context` — migration context injected into each migration function.
|
||||
- `options.events?` — optional event bus for lifecycle events.
|
||||
- `options.dryRun?` — if `true`, collects applied IDs without running SQL (default `false`).
|
||||
- `options.checksumValidation?` — if `true`, validates checksums on up-direction runs (default `true`).
|
||||
|
||||
### `execute(plan, direction, runOptions?): Promise<MigrationResult>`
|
||||
|
||||
Returns `{ applied, skipped, failed, direction, dryRun }`. Throws `MigrationError` on abort, checksum mismatch, or execution failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Checksum validation runs only for `up` migrations that already have a ledger record with a checksum — it does not block the first application. The `down` direction requires `migration.down` to be defined; if absent, a `MIGRATION.NOT_FOUND` error is thrown.
|
||||
@@ -0,0 +1,84 @@
|
||||
# migration-runner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MigrationRunner` is the top-level orchestrator for the migration package. It wires together the planner, executor, store, lock, and event bus into a single high-level API. Consumers create a runner with their migration list and store adapter, then call `up()` to apply pending migrations, `down()` to roll back, `status()` to inspect the current state, and `validate()` to verify migration metadata consistency before running.
|
||||
|
||||
## Features
|
||||
|
||||
- `up(options?)` — plans and applies all pending migrations; respects advisory locks, AbortSignal, and dry-run mode.
|
||||
- `down(options?)` — plans and rolls back the last N applied migrations.
|
||||
- `status()` — returns combined status of all registered migrations.
|
||||
- `validate()` — checks for duplicate IDs and returns structured validation errors.
|
||||
- Injectable `Clock`, `LoggerLike`, `EventBus`, and `MigrationLockAdapter`.
|
||||
- `createMigrationRunner` factory function for ergonomic construction.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MigrationRunner, MemoryMigrationStore } from "@suaveplan/migration";
|
||||
|
||||
const store = new MemoryMigrationStore();
|
||||
const runner = new MigrationRunner({ migrations, store });
|
||||
const result = await runner.up();
|
||||
// result.applied lists migration IDs that were applied
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MigrationRunner, MemoryMigrationStore, MemoryMigrationLock } from "@suaveplan/migration";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
const store = new MemoryMigrationStore();
|
||||
const lock = new MemoryMigrationLock();
|
||||
const events = new EventBus<MigrationEventMap>();
|
||||
|
||||
events.on("migration:after", ({ migration, durationMs }) => {
|
||||
console.log(`Applied ${migration.id} in ${durationMs}ms`);
|
||||
});
|
||||
|
||||
const runner = new MigrationRunner({ migrations, store, lock, events, checksumValidation: true });
|
||||
await runner.up({ signal: abortController.signal });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class MigrationRunner`
|
||||
|
||||
**Constructor**: `(config: MigrationRunnerConfig, clock?: Clock)`
|
||||
|
||||
- `config.migrations` — array of `Migration` objects.
|
||||
- `config.store` — `MigrationStoreAdapter` for ledger persistence.
|
||||
- `config.lock?` — optional `MigrationLockAdapter`.
|
||||
- `config.logger?` — optional `LoggerLike` for structured log output.
|
||||
- `config.events?` — optional `EventBus<MigrationEventMap>`.
|
||||
- `config.dryRun?` — default `false`.
|
||||
- `config.checksumValidation?` — default `true`.
|
||||
|
||||
### `up(options?: RunOptions): Promise<MigrationResult>`
|
||||
|
||||
Returns `{ applied, skipped, failed, direction: "up", dryRun }`.
|
||||
|
||||
### `down(options?: RunOptions & { steps?: number }): Promise<MigrationResult>`
|
||||
|
||||
Returns `{ applied, skipped, failed, direction: "down", dryRun }`.
|
||||
|
||||
### `status(): Promise<MigrationStatusEntry[]>`
|
||||
|
||||
Returns status for every registered migration sorted by id.
|
||||
|
||||
### `validate(): Promise<ValidationResult>`
|
||||
|
||||
Returns `{ valid, errors }`.
|
||||
|
||||
### `createMigrationRunner(config, clock?): MigrationRunner`
|
||||
|
||||
Factory function — equivalent to `new MigrationRunner(config, clock)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `buildContext()` method provides a no-op `execute`/`query` implementation suitable for runners that do not need direct database access in migration functions (e.g. when the migration functions close over an external adapter). Consumers requiring SQL execution in migration functions should provide a custom context via a subclass.
|
||||
@@ -0,0 +1,46 @@
|
||||
# migration.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Zod schemas for all public option types in `@suaveplan/migration`. Providing schemas alongside the TypeScript types enables runtime validation of configuration objects, safe deserialization of stored migration state, and structured error messages when callers pass invalid options. Every schema is strict (no unrecognised keys allowed at runtime), ensuring forward compatibility does not accidentally silently accept typo'd option names.
|
||||
|
||||
## Features
|
||||
|
||||
- `RunOptionsSchema` — validates `signal` (AbortSignal) and `target` (string) run options.
|
||||
- `MigrationRunnerOptionsSchema` — validates `dryRun` and `checksumValidation` booleans with defaults.
|
||||
- `DistributedLockConfigSchema` — validates distributed lock `key` and `ttl` options.
|
||||
- `RedisLockConfigSchema` — validates Redis lock `key` and `ttl` options.
|
||||
- `BatchMigratorConfigSchema` — validates `concurrency` with a positive-integer constraint and default 5.
|
||||
- `MemoryStorageOptionsSchema` — validates optional `maxSize` for the in-memory store LRU limit.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MigrationRunnerOptionsSchema } from "@suaveplan/migration";
|
||||
|
||||
const opts = MigrationRunnerOptionsSchema.parse({ dryRun: true });
|
||||
// opts.dryRun === true, opts.checksumValidation === true (default)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BatchMigratorConfigSchema } from "@suaveplan/migration";
|
||||
|
||||
const result = BatchMigratorConfigSchema.safeParse({ concurrency: 0 });
|
||||
if (!result.success) {
|
||||
console.error(result.error.format());
|
||||
// concurrency must be a positive integer
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
All exports are `z.ZodObject` instances. Use `.parse()` for throwing validation, `.safeParse()` for non-throwing. All schemas are strict — extra keys cause validation failures.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Schemas use `AbortSignal` via `z.instanceof(AbortSignal)` which requires the schema to be evaluated in an environment where `AbortSignal` is a global. All modern JS runtimes and Node.js ≥ 15 provide this. Numeric constraints use `.positive()` which rejects zero and negatives.
|
||||
@@ -0,0 +1,56 @@
|
||||
# migration-status
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MigrationStatusDashboard` provides a read-only view over the migration store. It computes per-migration status, aggregate counts, and a health check that can be wired into application readiness probes. Separating status reporting from the runner ensures that status queries never trigger migrations and can be called at any time without locking concerns.
|
||||
|
||||
## Features
|
||||
|
||||
- `getAll()` — returns every registered migration with its current status (`pending`, `applied`, `rolled-back`).
|
||||
- `getSummary()` — returns aggregate counts for total, applied, pending, and failed migrations.
|
||||
- `healthCheck()` — returns `{ healthy, pendingCount, failedCount }` suitable for a `/healthz` endpoint.
|
||||
- `getHistory(id)` — returns all ledger entries for a specific migration id (supports audit queries).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MigrationStatusDashboard, MemoryMigrationStore } from "@suaveplan/migration";
|
||||
|
||||
const dashboard = new MigrationStatusDashboard({ store, migrations });
|
||||
const summary = await dashboard.getSummary();
|
||||
// { total: 5, applied: 4, pending: 1, failed: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MigrationStatusDashboard } from "@suaveplan/migration";
|
||||
|
||||
const dashboard = new MigrationStatusDashboard({ store, migrations });
|
||||
const health = await dashboard.healthCheck();
|
||||
if (!health.healthy) {
|
||||
console.warn(`${health.pendingCount} pending migrations, ${health.failedCount} failed`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class MigrationStatusDashboard`
|
||||
|
||||
**Constructor**: `(config: StatusConfig)`
|
||||
|
||||
- `config.store` — `MigrationStoreAdapter`.
|
||||
- `config.migrations` — registered `Migration[]`.
|
||||
- `config.clock?` — injectable clock.
|
||||
|
||||
- `getAll(): Promise<ExtendedMigrationStatus[]>` — sorted by id.
|
||||
- `getSummary(): Promise<MigrationSummary>` — `{ total, applied, pending, failed }`.
|
||||
- `healthCheck(): Promise<MigrationHealthCheck>` — `{ healthy, pendingCount, failedCount }`.
|
||||
- `getHistory(id: string): Promise<MigrationHistoryEntry[]>` — ledger entries for one migration.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`healthy` is `true` only when both `pending === 0` and `failed === 0`. This is intentionally strict: unnapplied migrations in production are a deployment concern that should always be visible. The `getHistory` method supports audit use cases where the same migration may have been applied and rolled back multiple times.
|
||||
@@ -0,0 +1,76 @@
|
||||
# database-store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DatabaseMigrationStore` implements `MigrationStoreAdapter` against any SQL database that provides a minimal `query`/`execute` interface. It creates a ledger table (default name `_migrations`) using `CREATE TABLE IF NOT EXISTS`, making it idempotent on every call. Records are upserted with `INSERT OR REPLACE` for SQLite compatibility; Postgres adapters should use `ON CONFLICT DO UPDATE` via a custom tableName or subclass.
|
||||
|
||||
## Features
|
||||
|
||||
- `ensureTable()` — creates the ledger table if it does not exist; called automatically before every operation.
|
||||
- Full `MigrationStoreAdapter` implementation: `getApplied`, `record`, `remove`, `clear`.
|
||||
- Wraps all database errors in `MigrationError(MIGRATION_CODES.STORE_ERROR)` with the original as `cause`.
|
||||
- Configurable `tableName` (default `_migrations`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DatabaseMigrationStore } from "@suaveplan/migration";
|
||||
|
||||
const store = new DatabaseMigrationStore({ adapter: myDb });
|
||||
const applied = await store.getApplied();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DatabaseMigrationStore } from "@suaveplan/migration";
|
||||
|
||||
const store = new DatabaseMigrationStore({
|
||||
adapter: sqliteAdapter,
|
||||
tableName: "_schema_versions",
|
||||
});
|
||||
|
||||
await store.record({
|
||||
id: "001_init",
|
||||
name: "Initial schema",
|
||||
appliedAt: Date.now(),
|
||||
checksum: "abc123",
|
||||
direction: "up",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class DatabaseMigrationStore implements MigrationStoreAdapter`
|
||||
|
||||
**Constructor**: `(config: DatabaseStoreConfig)`
|
||||
|
||||
- `config.adapter` — object with `query<T>(sql, params?): Promise<T[]>` and `execute(sql, params?): Promise<{ rowsAffected: number }>`.
|
||||
- `config.tableName?` — override the default `_migrations` ledger table name.
|
||||
|
||||
### `ensureTable(): Promise<void>`
|
||||
|
||||
Creates the ledger table if absent. Idempotent.
|
||||
|
||||
### `getApplied(): Promise<MigrationRecord[]>`
|
||||
|
||||
Returns all ledger rows ordered by `applied_at` ascending.
|
||||
|
||||
### `record(record: MigrationRecord): Promise<void>`
|
||||
|
||||
Upserts a ledger row (`INSERT OR REPLACE`).
|
||||
|
||||
### `remove(id: string): Promise<void>`
|
||||
|
||||
Deletes the ledger row for `id`.
|
||||
|
||||
### `clear(): Promise<void>`
|
||||
|
||||
Deletes all ledger rows.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `DatabaseStoreDb` interface is intentionally minimal so any adapter — SQLite, Postgres, MySQL — can implement it without pulling in the full `DatabaseAdapter` type from `@suaveplan/db`. The `ensureTable` call on every operation ensures the table exists even if the store is constructed before the database is fully initialised. This adds a tiny overhead per operation, but the cost is negligible compared to actual SQL execution.
|
||||
@@ -0,0 +1,54 @@
|
||||
# memory-store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MemoryMigrationStore` implements `MigrationStoreAdapter` using an in-process `Map<string, MigrationRecord>`. It is the zero-dependency store used in unit tests, integration tests, CLI tools, and any scenario where persistence across process restarts is not required. Because it holds no external connections, it is always available and never fails with connectivity errors.
|
||||
|
||||
## Features
|
||||
|
||||
- Full `MigrationStoreAdapter` implementation: `getApplied`, `record`, `remove`, `clear`.
|
||||
- Thread-safe within a single event-loop tick (no race conditions in async contexts without concurrent modification).
|
||||
- Zero-allocation `getApplied()` returns a snapshot array, not the live map.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryMigrationStore } from "@suaveplan/migration";
|
||||
|
||||
const store = new MemoryMigrationStore();
|
||||
await store.record({ id: "001", name: "init", appliedAt: Date.now(), direction: "up" });
|
||||
const applied = await store.getApplied();
|
||||
// applied has one entry
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemoryMigrationStore } from "@suaveplan/migration";
|
||||
import { MigrationRunner } from "@suaveplan/migration";
|
||||
|
||||
const store = new MemoryMigrationStore();
|
||||
const runner = new MigrationRunner({ migrations, store });
|
||||
await runner.up();
|
||||
// After: store holds all applied records
|
||||
await store.clear();
|
||||
// Store is now empty — clean state for next test
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class MemoryMigrationStore implements MigrationStoreAdapter`
|
||||
|
||||
All methods are async to match the adapter interface, though none perform I/O.
|
||||
|
||||
- `getApplied(): Promise<MigrationRecord[]>` — returns a snapshot of all recorded migrations.
|
||||
- `record(record: MigrationRecord): Promise<void>` — upserts a record by `id`.
|
||||
- `remove(id: string): Promise<void>` — deletes a record by `id`; no-op if not present.
|
||||
- `clear(): Promise<void>` — removes all records.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Records are stored with `id` as the map key, making upsert semantics natural. `getApplied` spreads the map values into a new array so callers cannot mutate the internal store by modifying the returned array.
|
||||
@@ -0,0 +1,64 @@
|
||||
# migration.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Central type definitions for the `@suaveplan/migration` package. All interfaces, unions, and type aliases consumed by the runner, planner, executor, store adapters, lock adapters, dry-run reporter, batch migrator, and status dashboard originate here. Keeping types in a single file enables tree-shaking (zero runtime bytes) and prevents circular-dependency risks across the feature modules.
|
||||
|
||||
## Features
|
||||
|
||||
- `Migration` — the core unit: an `id`, `name`, `up` function (async, receives `MigrationContext`), optional `down`, and optional pre-computed `checksum`.
|
||||
- `MigrationContext` — injected into each migration function: `execute`, `query`, and a scoped `logger`.
|
||||
- `MigrationRecord` — the ledger row type persisted by `MigrationStoreAdapter`.
|
||||
- `MigrationPlan` — produced by the planner: `toApply` and `toRollback` arrays.
|
||||
- `MigrationResult` — returned by `MigrationRunner.up` / `.down`: applied/skipped/failed id lists.
|
||||
- Adapter interfaces: `MigrationStoreAdapter`, `MigrationLockAdapter`, `DistributedLockAdapter`.
|
||||
- `MigrationEventMap` — typed event names for the event bus integration.
|
||||
- `BatchMigratorConfig` / `BatchMigrationResult` — multi-target orchestration types.
|
||||
- `StatusConfig` / `ExtendedMigrationStatus` / `MigrationSummary` / `MigrationHealthCheck` — dashboard types.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { Migration, MigrationContext } from "@suaveplan/migration";
|
||||
|
||||
const createUsers: Migration = {
|
||||
id: "001_create_users",
|
||||
name: "Create users table",
|
||||
async up(ctx: MigrationContext): Promise<void> {
|
||||
await ctx.execute("CREATE TABLE users (id TEXT PRIMARY KEY)");
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Migration,
|
||||
MigrationStoreAdapter,
|
||||
MigrationRecord,
|
||||
MigrationRunnerConfig,
|
||||
} from "@suaveplan/migration";
|
||||
|
||||
class MyStore implements MigrationStoreAdapter {
|
||||
private records: MigrationRecord[] = [];
|
||||
async getApplied() { return [...this.records]; }
|
||||
async record(r: MigrationRecord) { this.records.push(r); }
|
||||
async remove(id: string) { this.records = this.records.filter(r => r.id !== id); }
|
||||
async clear() { this.records = []; }
|
||||
}
|
||||
|
||||
const config: MigrationRunnerConfig = {
|
||||
migrations: [createUsers],
|
||||
store: new MyStore(),
|
||||
checksumValidation: true,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
See individual type definitions in the source. All are plain TypeScript interfaces with no runtime code — zero bytes in the compiled bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Types are placed in a `.types.ts` file and excluded from coverage thresholds, following the project convention. The `MigrationEventMap` uses string-literal keys matching the pattern `"migration:<event>"` for consistency with other event maps in the project.
|
||||
53
docs/persistence/database/migration/src/utils/checksum.md
Normal file
53
docs/persistence/database/migration/src/utils/checksum.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# checksum
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides SHA-256 checksum utilities for the migration package. The checksum system detects when a migration's source has been modified after it was applied to a database, preventing accidental schema drift from silent SQL edits. Using `node:crypto` directly avoids introducing a dependency on `@suaveplan/crypto-hash`, keeping the migration package's dependency graph minimal.
|
||||
|
||||
## Features
|
||||
|
||||
- `computeChecksum(content)` — returns the SHA-256 hex digest of any string.
|
||||
- `checksumMigration(up)` — serialises a migration's `up` function to its source string and hashes it.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { computeChecksum } from "@suaveplan/migration";
|
||||
|
||||
const hash = computeChecksum("CREATE TABLE users (id TEXT PRIMARY KEY)");
|
||||
// "a4c2f..." (64-char hex string)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { checksumMigration } from "@suaveplan/migration";
|
||||
|
||||
async function validateChecksums(migrations, appliedRecords) {
|
||||
for (const migration of migrations) {
|
||||
const record = appliedRecords.find(r => r.id === migration.id);
|
||||
if (!record?.checksum) continue;
|
||||
const computed = checksumMigration(migration.up);
|
||||
if (computed !== record.checksum) {
|
||||
throw new Error(`Checksum mismatch for ${migration.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `computeChecksum(content: string): string`
|
||||
|
||||
Returns the lowercase hex SHA-256 digest of `content`. Uses `node:crypto` synchronously.
|
||||
|
||||
### `checksumMigration(up: (ctx: unknown) => Promise<void>): string`
|
||||
|
||||
Calls `up.toString()` to obtain the function source and passes it to `computeChecksum`. The result is deterministic for a given function body.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Function.prototype.toString()` is mandated by the ECMAScript specification to return the original source text (or a representation of it). In practice, transpilers and minifiers may change function bodies; for best reliability, checksums should be computed from source-file content in a pre-commit hook rather than at runtime in production builds.
|
||||
189
docs/persistence/database/seed/README.md
Normal file
189
docs/persistence/database/seed/README.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# @suaveplan/seed - Universal declarative database seeding and fixture framework: SeedBuilder fluent API, SeedRegistry with environment filters, SeedRunner with topological ordering, upsert strategies, batching, dry-run, AbortSignal, injectable LoggerLike, FixtureFactory, InMemorySeedStore, store-agnostic.
|
||||
|
||||
Universal declarative database seeding and fixture framework. Define seed data once, let the runner handle dependency ordering, environment filtering, idempotent upserts, batching, dry-runs, and clean cancellation. The runner is store-agnostic: any object satisfying the `SeedStore` interface plugs in, so the same seed definitions drive in-memory tests, local SurrealDB instances, and production databases without rewriting the data layer.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [builder](./src/builder/builder.md) — `SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that call…
|
||||
- [builder](./src/builder/builder.md) — `SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that call…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/seed` package centralises every failure mode it can produce behind a single registry built with `define…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/seed` package centralises every failure mode it can produce behind a single registry built with `define…
|
||||
- [fixtures](./src/fixtures/fixtures.md) — The `fixtures` module supplies the two universal building blocks used to exercise seed definitions without a real…
|
||||
- [fixtures](./src/fixtures/fixtures.md) — The `fixtures` module supplies the two universal building blocks used to exercise seed definitions without a real…
|
||||
- [registry](./src/registry/registry.md) — `SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns inserti…
|
||||
- [registry](./src/registry/registry.md) — `SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns inserti…
|
||||
- [runner](./src/runner/runner.md) — The `SeedRunner` orchestrates a single seeding run against an injected `SeedStore`. It is the public entry point for ac…
|
||||
- [execute](./src/runner/execute.md) — The `executeSeed` function applies one `SeedDefinition` to one `SeedStore`, returning a single `SeedResult`. It is the…
|
||||
- [runner](./src/runner/runner.md) — The `SeedRunner` orchestrates a single seeding run against an injected `SeedStore`. It is the public entry point for ac…
|
||||
- [types](./src/types/common.md) — Shared type module for `@suaveplan/seed`. Every public class and function in the package — `SeedBuilder`, `SeedRegistry…
|
||||
- [common](./src/types/common.md) — Shared type module for `@suaveplan/seed`. Every public class and function in the package — `SeedBuilder`, `SeedRegistry…
|
||||
- [topo-sort](./src/utils/topo-sort/topo-sort.md) — `topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where…
|
||||
- [topo-sort](./src/utils/topo-sort/topo-sort.md) — `topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Ships a handful of database adapters (`db`, `db-sqlite`, `surrealdb`, `migration`) but no consistent way to populate them. Hand-rolled per-package seeders drift in shape, swallow errors, skip dependency ordering, and re-insert duplicates on every run. `@suaveplan/seed` consolidates the orchestration concerns — registration, topological execution, environment scoping, batched upserts, dry-runs, progress reporting, and cancellation — behind one pluggable contract. The package is intentionally universal: zero `node:`/`bun:` imports, no IO of its own, and a single runtime dependency on `@suaveplan/error`. Adapters that need a production store ship in dedicated `seed-store-*` packages and implement the `SeedStore` interface; the core runs unchanged in tests, server processes, and worker contexts.
|
||||
|
||||
## Features
|
||||
|
||||
- **Fluent `SeedBuilder` + `defineSeed(name)` convenience** — chainable API for declaring tables, dependencies, environments, upsert strategies, priorities, and per-seed truncation
|
||||
- **`SeedRegistry`** — duplicate-name detection (typed `SEED.DUPLICATE_NAME`), defensive-copy reads, environment-aware queries
|
||||
- **`SeedRunner`** — Kahn's-algorithm topological sort with priority tiebreak, `only`/`exclude` filtering, three upsert strategies (`skip`/`update`/`replace`), configurable batching, `dryRun` mode, `continueOnError`, per-seed `onProgress` callback
|
||||
- **`AbortSignal` plumbing** — abort mid-run; remaining seeds are reported as `skipped` with `error: "Aborted"`
|
||||
- **Injectable `LoggerLike`** (`@suaveplan/types`) — structured events (`seed.run.start`, `seed.completed`, `seed.skipped`, `seed.failed`, `seed.run.complete`); no-op default keeps the runtime silent when unwired
|
||||
- **`FixtureFactory<T>`** — deterministic per-record overrides with auto-incrementing index, `createMany`, `reset`
|
||||
- **`InMemorySeedStore`** — full `SeedStore` shape with `getRecords`/`clear` test affordances
|
||||
- **`SeedError` + `SEED_CODES` registry** — every public failure carries a stable `SEED.*` code and threads `cause` per the `@suaveplan/error` contract
|
||||
- **Tree-shakeable subpath exports** — import `@suaveplan/seed/runner/runner` without pulling fixtures
|
||||
- **100% statement/branch/function/line coverage** via Istanbul
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineSeed,
|
||||
SeedRegistry,
|
||||
SeedRunner,
|
||||
createInMemoryStore,
|
||||
} from "@suaveplan/seed";
|
||||
|
||||
const registry = new SeedRegistry();
|
||||
|
||||
registry.register(
|
||||
defineSeed("users")
|
||||
.table("user")
|
||||
.data([
|
||||
{ id: 1, name: "alice" },
|
||||
{ id: 2, name: "bob" },
|
||||
])
|
||||
.upsert("skip", ["id"])
|
||||
.build(),
|
||||
);
|
||||
|
||||
registry.register(
|
||||
defineSeed("posts")
|
||||
.dependsOn("users")
|
||||
.data([
|
||||
{ id: 1, authorId: 1, title: "Hello" },
|
||||
{ id: 2, authorId: 2, title: "World" },
|
||||
])
|
||||
.upsert("skip", ["id"])
|
||||
.build(),
|
||||
);
|
||||
|
||||
const store = createInMemoryStore();
|
||||
const runner = new SeedRunner(registry, store);
|
||||
const result = await runner.run({ environment: "dev" });
|
||||
|
||||
console.log(result.success, result.seedsRun, result.seedsSkipped);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineSeed,
|
||||
SeedRegistry,
|
||||
SeedRunner,
|
||||
type LoggerLike,
|
||||
} from "@suaveplan/seed";
|
||||
import { mySurrealStore } from "./my-surreal-store";
|
||||
|
||||
const registry = new SeedRegistry();
|
||||
|
||||
registry.registerMany([
|
||||
defineSeed("tenants")
|
||||
.environments("dev", "staging")
|
||||
.data(async () => fetchTenantsFromCsv())
|
||||
.upsert("update", ["slug"])
|
||||
.priority(0)
|
||||
.build(),
|
||||
defineSeed("admin-users")
|
||||
.dependsOn("tenants")
|
||||
.dataFactory(async () => seedFactoryFor(currentTenant))
|
||||
.upsert("update", ["tenantId", "email"])
|
||||
.priority(10)
|
||||
.build(),
|
||||
defineSeed("audit-log-buckets")
|
||||
.dependsOn("tenants")
|
||||
.truncateFirst()
|
||||
.data([])
|
||||
.priority(20)
|
||||
.build(),
|
||||
]);
|
||||
|
||||
const logger: LoggerLike = createStructuredLogger();
|
||||
const runner = new SeedRunner(registry, mySurrealStore, logger);
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 30_000); // hard 30s budget
|
||||
|
||||
const result = await runner.run({
|
||||
environment: "staging",
|
||||
only: ["tenants", "admin-users"],
|
||||
batchSize: 500,
|
||||
continueOnError: false,
|
||||
signal: controller.signal,
|
||||
onProgress: (r) =>
|
||||
logger.info(`seed.progress`, { name: r.name, status: r.status }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Seed run failed: ${result.seedsFailed} failed, ${result.seedsSkipped} skipped`,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Builder
|
||||
|
||||
- `defineSeed(name: string): SeedBuilder` — convenience factory
|
||||
- `class SeedBuilder` — chainable: `table`, `environments`, `dependsOn`, `data`, `dataFactory`, `upsert`, `description`, `priority`, `truncateFirst`, terminal `build()`
|
||||
|
||||
### Registry
|
||||
|
||||
- `class SeedRegistry` — `register` / `registerMany` / `get` / `getAll` / `getForEnvironment(env)` / `has` / `remove` / `clear` / `size` / `names`. Raises `SeedError({ code: SEED.DUPLICATE_NAME })` on collision.
|
||||
|
||||
### Runner
|
||||
|
||||
- `class SeedRunner(registry, store, logger?)` — `async run(config: SeedRunConfig): Promise<SeedRunResult>`
|
||||
- `SeedRunConfig`: `environment`, `only?`, `exclude?`, `dryRun?`, `batchSize?`, `continueOnError?`, `onProgress?`, `signal?`
|
||||
- `SeedRunResult`: `environment`, `totalDuration`, `results`, `success`, `seedsRun`, `seedsSkipped`, `seedsFailed`
|
||||
- `SeedResult`: `name`, `table`, `status`, `recordsInserted`, `recordsUpdated`, `recordsSkipped`, `duration`, `error?`
|
||||
|
||||
### Fixtures
|
||||
|
||||
- `class FixtureFactory<T extends SeedRecord>` + `createFixtureFactory(defaults)` — `create(overrides?)`, `createMany(count, overrides?)`, `reset()`, `counter`
|
||||
- `class InMemorySeedStore` + `createInMemoryStore()` — full `SeedStore` shape plus `getRecords(table)` / `clear()`
|
||||
|
||||
### Utils
|
||||
|
||||
- `topoSort(seeds): string[]` — Kahn's algorithm with priority tiebreak; raises `SEED.UNREGISTERED_DEPENDENCY` / `SEED.CIRCULAR_DEPENDENCY` / `SEED.DEPENDENCY_DEPTH_EXCEEDED`
|
||||
- `detectCycles(seeds): string[]` — DFS cycle reporter; returns `["a -> b -> a", ...]`; empty array on clean graph
|
||||
|
||||
### Errors
|
||||
|
||||
- `SeedError extends SuaveplanError<SeedErrorCode>` with optional `seedName` / `dependency` discriminants
|
||||
- `SEED_CODES`: `DUPLICATE_NAME`, `UNREGISTERED_DEPENDENCY`, `CIRCULAR_DEPENDENCY`, `DEPENDENCY_DEPTH_EXCEEDED`, `INVALID_CONFIG`, `STORE_ERROR`, `ABORTED`
|
||||
|
||||
### Constants
|
||||
|
||||
- `DEFAULT_ENVIRONMENTS = ["dev", "staging", "test", "production"] as const`
|
||||
- `MAX_DEPENDENCY_DEPTH = 50`
|
||||
- `DEFAULT_BATCH_SIZE = 100`
|
||||
- `SEED_STATUS = { PENDING, RUNNING, COMPLETED, FAILED, SKIPPED }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Universal** — no `node:`/`bun:` imports anywhere in `src/`. The only platform-aware call is `performance.now()`, which every supported runtime provides natively.
|
||||
- **Store-agnostic by design** — `SeedRunner` receives a `SeedStore` via constructor injection. The core ships only `InMemorySeedStore` (for tests); production stores live in dedicated `seed-store-*` packages.
|
||||
- **Errors thread `cause`** — store exceptions are wrapped as `SeedError({ code: SEED.STORE_ERROR, cause })` so callers can inspect the underlying error without losing the stable code.
|
||||
- **Topological sort uses Kahn's algorithm** — one clean pass, priority breaks ties for independent seeds, a defensive iteration guard catches pathological cases before they hang.
|
||||
- **AbortSignal is checked between seeds** — in-flight `SeedStore` calls receive the signal too (every `SeedStore` method accepts an optional `signal?: AbortSignal`).
|
||||
- **Logger is opt-in** — `LoggerLike` defaults to a no-op so the runner stays silent when unwired. Wire it to get `seed.run.start` / `seed.completed` / `seed.skipped` / `seed.failed` / `seed.run.complete` events.
|
||||
- **Enterprise extras are out of scope here** — plugin lifecycle, SurrealDB schema migrations, JWT/RBAC, audit logging, rate limiting, webhooks, OTel spans, and Prometheus metrics all belong to a future `api-plugin-seed` package that wraps this core.
|
||||
|
||||
92
docs/persistence/database/seed/src/builder/builder.md
Normal file
92
docs/persistence/database/seed/src/builder/builder.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# SeedBuilder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SeedBuilder` is the fluent, chainable API used to construct immutable `SeedDefinition` records. It exists so that callers can declare seeds in a readable, self-documenting form without hand-assembling the underlying object literal and without worrying about empty-collection or unset-field noise leaking into the serialised output.
|
||||
|
||||
The builder is the single canonical entry point for authoring seed definitions: `SeedRegistry`, `SeedRunner`, and every downstream tool consume the `SeedDefinition` value it produces. By centralising construction here we keep upstream parity with the `../suaveplan/packages/server/seed` reference implementation while honouring the rule that unset optional fields must be omitted entirely from the produced definition rather than serialised as empty arrays, empty strings, or `false`.
|
||||
|
||||
## Features
|
||||
|
||||
- Chainable fluent API — every setter returns the same builder instance.
|
||||
- Default `table` derives from the seed `name` so most call-sites need only one identifier.
|
||||
- `data(records)` and `dataFactory(fn)` share a single internal slot; the most recent call wins, supporting both static record arrays and lazy async factories.
|
||||
- `upsert(strategy, keys)` co-sets the upsert strategy and key tuple atomically.
|
||||
- `truncateFirst()` defaults `enabled` to `true` so the most common opt-in stays terse.
|
||||
- `build()` produces a deeply-immutable shape: optional fields the caller never touched (`environments`, `dependencies`, `upsertKeys`, `description`, `truncate`) are omitted entirely.
|
||||
- Implements the `ISeedBuilder` interface so consumers can depend on the contract rather than the concrete class.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defineSeed } from "@suaveplan/seed/builder/builder";
|
||||
|
||||
const usersSeed = defineSeed("users")
|
||||
.data([
|
||||
{ id: 1, email: "alice@example.com" },
|
||||
{ id: 2, email: "bob@example.com" },
|
||||
])
|
||||
.build();
|
||||
|
||||
// usersSeed.name === "users"
|
||||
// usersSeed.table === "users" (defaults to name)
|
||||
// usersSeed.upsertStrategy === "skip"
|
||||
// usersSeed.priority === 0
|
||||
// usersSeed has no environments / dependencies / upsertKeys / description / truncate fields
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { defineSeed, SeedBuilder } from "@suaveplan/seed/builder/builder";
|
||||
import type { ISeedBuilder } from "@suaveplan/seed/builder/builder";
|
||||
|
||||
function makeTenantSeed(name: string): ISeedBuilder {
|
||||
return defineSeed(name)
|
||||
.table(`tenant_${name}`)
|
||||
.environments("dev", "staging")
|
||||
.dependsOn("roles", "permissions")
|
||||
.dataFactory(async () => {
|
||||
const rows = await loadFromFixtureService(name);
|
||||
return rows.map((r, i) => ({ ...r, idx: i }));
|
||||
})
|
||||
.upsert("update", ["id"])
|
||||
.description(`Tenant records for ${name}`)
|
||||
.priority(10)
|
||||
.truncateFirst();
|
||||
}
|
||||
|
||||
const usersDef = makeTenantSeed("users").build();
|
||||
const postsDef = new SeedBuilder("posts")
|
||||
.dependsOn("users")
|
||||
.data([{ id: 1, authorId: 1, body: "hello" }])
|
||||
.upsert("skip", ["id"])
|
||||
.build();
|
||||
|
||||
declare function loadFromFixtureService(name: string): Promise<Array<Record<string, unknown>>>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `defineSeed(name: string): SeedBuilder` — returns a new builder seeded with `name` (also the default `table`).
|
||||
- `class SeedBuilder implements ISeedBuilder` — constructor takes the seed `name`.
|
||||
- `table(name: string): this` — overrides the default `table`.
|
||||
- `environments(...envs: SeedEnvironment[]): this` — restrict to listed environments; empty rest-args clears the field (omitted from `build()`).
|
||||
- `dependsOn(...names: string[]): this` — list seeds that must run first; empty rest-args clears the field.
|
||||
- `data(records: SeedRecord[]): this` — static record array; replaces any factory.
|
||||
- `dataFactory(fn: SeedDataFactory): this` — sync-or-async factory; replaces any static array.
|
||||
- `upsert(strategy: UpsertStrategy, keys: string[]): this` — atomic strategy + key-tuple setter.
|
||||
- `description(text: string): this` — empty string is treated as unset.
|
||||
- `priority(n: number): this` — lower priorities run earlier when no dependency edge exists.
|
||||
- `truncateFirst(enabled?: boolean): this` — default `true`; passing `false` clears the field.
|
||||
- `build(): SeedDefinition` — terminal accessor; safe to call multiple times.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The builder tracks each optional field in its own private slot and only emits it from `build()` when the slot is non-empty (`length > 0` for arrays/strings, truthy for booleans). The mandatory shape — `name`, `table`, `data`, `upsertStrategy`, `priority` — is always present, matching the upstream reference contract. Defaults: `_environments = []`, `_dependencies = []`, `_data = []`, `_upsertStrategy = "skip"`, `_upsertKeys = []`, `_description = ""`, `_priority = 0`, `_truncate = false`.
|
||||
|
||||
Because `data(...)` and `dataFactory(...)` write to the same slot, ordering matters: calling `.data([...]).dataFactory(fn)` resolves to the factory; the reverse resolves to the array. Both return `this` so the fluent chain is uninterrupted regardless of which slot wins. `build()` is a pure read; it never mutates internal state, so the same builder may produce multiple equivalent definitions or be further refined and re-built. All chainable methods return the typed `this` so subclassing preserves the fluent return type without explicit casts.
|
||||
128
docs/persistence/database/seed/src/error-codes/error-codes.md
Normal file
128
docs/persistence/database/seed/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `@suaveplan/seed` package centralises every failure mode it can produce
|
||||
behind a single registry built with `defineCodes("SEED", [...])` from
|
||||
`@suaveplan/error`. The registry is the stable contract callers depend on:
|
||||
code strings are documented, frozen at module load, and never renamed
|
||||
without an explicit migration. This module exports the `SEED_CODES`
|
||||
registry, the derived `SeedErrorCode` type, and the `SeedError` class that
|
||||
every public failure path raises.
|
||||
|
||||
`SeedError` extends `SuaveplanError<SeedErrorCode>` so `instanceof SeedError`
|
||||
narrows `code` to the exact union of `SEED.*` strings. It adds optional
|
||||
`seedName` and `dependency` discriminants that diagnose which seed (or
|
||||
which missing dependency) triggered the error, while leaving the base
|
||||
`context` map free for caller-supplied structured data. The class threads
|
||||
`cause` straight through `SuaveplanError`, so store-layer exceptions
|
||||
wrapped by the runner remain inspectable through `error.cause`.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `SEED_CODES` registry with seven namespaced codes
|
||||
- `SeedErrorCode` literal-union type derived from the registry
|
||||
- `SeedError` class extending `SuaveplanError<SeedErrorCode>`
|
||||
- Optional `seedName` and `dependency` diagnostic discriminants
|
||||
- Full `cause`-chain preservation via `SuaveplanError`
|
||||
- Compatible with the OTel span-event adapter shipped by `@suaveplan/error`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SEED_CODES, SeedError } from "@suaveplan/seed";
|
||||
|
||||
if (registry.has(seed.name)) {
|
||||
throw new SeedError(SEED_CODES.DUPLICATE_NAME, `seed "${seed.name}" already registered`, {
|
||||
seedName: seed.name,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SEED_CODES, SeedError } from "@suaveplan/seed";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
try {
|
||||
await store.insert(seed.table, batch);
|
||||
} catch (cause) {
|
||||
throw new SeedError(SEED_CODES.STORE_ERROR, `store rejected batch for ${seed.table}`, {
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
seedName: seed.name,
|
||||
context: { table: seed.table, batchSize: batch.length },
|
||||
});
|
||||
}
|
||||
|
||||
// Downstream:
|
||||
catch (err) {
|
||||
if (err instanceof SeedError && err.code === SEED_CODES.STORE_ERROR) {
|
||||
logger.error("seed.store.failure", {
|
||||
seedName: err.seedName,
|
||||
rootMessage: err.cause instanceof Error ? err.cause.message : String(err.cause),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SEED_CODES`
|
||||
|
||||
A frozen object literal of seven namespaced codes:
|
||||
|
||||
| Property | Value |
|
||||
| ----------------------------- | ---------------------------------- |
|
||||
| `DUPLICATE_NAME` | `"SEED.DUPLICATE_NAME"` |
|
||||
| `UNREGISTERED_DEPENDENCY` | `"SEED.UNREGISTERED_DEPENDENCY"` |
|
||||
| `CIRCULAR_DEPENDENCY` | `"SEED.CIRCULAR_DEPENDENCY"` |
|
||||
| `DEPENDENCY_DEPTH_EXCEEDED` | `"SEED.DEPENDENCY_DEPTH_EXCEEDED"` |
|
||||
| `INVALID_CONFIG` | `"SEED.INVALID_CONFIG"` |
|
||||
| `STORE_ERROR` | `"SEED.STORE_ERROR"` |
|
||||
| `ABORTED` | `"SEED.ABORTED"` |
|
||||
|
||||
### `type SeedErrorCode`
|
||||
|
||||
The literal union of every value in `SEED_CODES`.
|
||||
|
||||
### `class SeedError extends SuaveplanError<SeedErrorCode>`
|
||||
|
||||
| Member | Type |
|
||||
| ------------------ | ---------------------------------------------------------------------- |
|
||||
| `code` | `SeedErrorCode` (narrowed) |
|
||||
| `seedName?` | `string` |
|
||||
| `dependency?` | `string` |
|
||||
| `cause?` | `unknown` (inherited) |
|
||||
| `timestamp` | `number` (inherited; `Date.now()` at construction) |
|
||||
| `constructor(...)` | `(code, message, options?: SeedErrorOptions)` |
|
||||
|
||||
`SeedErrorOptions` extends `SuaveplanErrorOptions` (`cause`, `context`,
|
||||
`clock`, `traceId`) with the additional optional `seedName` and
|
||||
`dependency` fields.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a `Object.freeze`d record with `null` prototype, so
|
||||
the registry cannot be mutated, reassigned, or extended at runtime — any
|
||||
attempt fails in strict mode (which TypeScript modules enforce). The
|
||||
literal type machinery preserves the exact `${Namespace}.${Reason}`
|
||||
string template across every consumer, so `switch (err.code)` enjoys
|
||||
exhaustive narrowing without runtime cost.
|
||||
|
||||
`SeedError`'s constructor passes `message` straight through to
|
||||
`SuaveplanError`'s `messageKey` parameter. The base class resolves the
|
||||
key via the i18n resolver if one has been registered with
|
||||
`setMessageResolver`, falling back to the raw string otherwise — so this
|
||||
package works without i18n wiring and lights up automatically when
|
||||
`@suaveplan/i18n` is present.
|
||||
|
||||
Optional `seedName` / `dependency` fields are stored on the error instance
|
||||
directly (not on `context`) so they survive structured-cloning and
|
||||
serialisation via the `toJSON` helper from `@suaveplan/error`. Consumers
|
||||
that programmatically dispatch on a missing dependency can read
|
||||
`err.dependency` without first parsing `err.context`.
|
||||
142
docs/persistence/database/seed/src/fixtures/fixtures.md
Normal file
142
docs/persistence/database/seed/src/fixtures/fixtures.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# fixtures
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `fixtures` module supplies the two universal building blocks used to
|
||||
exercise seed definitions without a real database: `FixtureFactory` for
|
||||
generating deterministic, counter-stamped record sets, and `InMemorySeedStore`
|
||||
for satisfying the `SeedStore` contract entirely in process memory. Together
|
||||
they let every test in the wider ecosystem author realistic seed data and run
|
||||
the same `SeedRunner` paths a production adapter would run, without touching
|
||||
any I/O or external service. The module deliberately has zero dependencies on
|
||||
the runner, registry, or builder layers — it is a leaf consumed by tests and
|
||||
by integration harnesses that want a drop-in store.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-incrementing fixture indices via a private monotonic counter.
|
||||
- Per-record overrides for `create` and indexed overrides for `createMany`.
|
||||
- Counter reset to support deterministic test sequencing.
|
||||
- Full `SeedStore` implementation: `insert`, `update`, `findExisting`,
|
||||
`truncate`, `tableExists`.
|
||||
- Composite-key support: any number of `keyFields` joined with `"::"`.
|
||||
- Test affordances: `getRecords` returns a defensive copy, `clear` drops
|
||||
every table.
|
||||
- Optional `AbortSignal` parameter accepted on every method for contract
|
||||
compatibility (ignored — operations are synchronous in-memory).
|
||||
- Factory helpers `createFixtureFactory` and `createInMemoryStore` for code
|
||||
that prefers function calls over `new`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createFixtureFactory,
|
||||
createInMemoryStore,
|
||||
} from "@suaveplan/seed/fixtures/fixtures";
|
||||
|
||||
const userFactory = createFixtureFactory((index) => ({
|
||||
id: index,
|
||||
name: `User ${index}`,
|
||||
email: `user${index}@example.com`,
|
||||
}));
|
||||
|
||||
const users = userFactory.createMany(3);
|
||||
|
||||
const store = createInMemoryStore();
|
||||
await store.insert("users", users);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
FixtureFactory,
|
||||
InMemorySeedStore,
|
||||
} from "@suaveplan/seed/fixtures/fixtures";
|
||||
|
||||
interface Membership extends Record<string, unknown> {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
role: "member" | "admin";
|
||||
}
|
||||
|
||||
const memberships = new FixtureFactory<Membership>((i) => ({
|
||||
orgId: `org-${i % 2}`,
|
||||
userId: `user-${i}`,
|
||||
role: "member",
|
||||
}));
|
||||
|
||||
const rows = memberships.createMany(4, [
|
||||
undefined as never,
|
||||
{ role: "admin" },
|
||||
]);
|
||||
|
||||
const store = new InMemorySeedStore();
|
||||
await store.insert("memberships", rows);
|
||||
const updated = await store.update(
|
||||
"memberships",
|
||||
[{ orgId: "org-0", userId: "user-0", role: "admin" }],
|
||||
["orgId", "userId"]
|
||||
);
|
||||
|
||||
memberships.reset();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FixtureFactory<T>`
|
||||
|
||||
- `new FixtureFactory(defaults: (index: number) => T)` — wrap a defaults
|
||||
function.
|
||||
- `create(overrides?: Partial<T>): T` — emit one record, advance the counter.
|
||||
- `createMany(count: number, overrides?: Partial<T>[]): T[]` — emit `count`
|
||||
records; `overrides[i]` (if present) merges over `defaults(counter)`.
|
||||
- `reset(): void` — set the counter back to 0.
|
||||
- `get counter(): number` — read the current counter.
|
||||
|
||||
### `createFixtureFactory<T>(defaults)`
|
||||
|
||||
Convenience wrapper for `new FixtureFactory(defaults)`.
|
||||
|
||||
### `InMemorySeedStore implements SeedStore`
|
||||
|
||||
- `insert(table, records, signal?)` — append; returns `records.length`.
|
||||
- `update(table, records, keyFields, signal?)` — composite-key upsert into
|
||||
matching rows; returns the count actually updated.
|
||||
- `findExisting(table, records, keyFields, signal?)` — return stored rows
|
||||
whose composite key appears in the input set.
|
||||
- `truncate(table, signal?)` — empty the table while keeping it present.
|
||||
- `tableExists(table, signal?)` — `true` iff `insert` or `truncate` has ever
|
||||
named the table.
|
||||
- `getRecords(table)` — defensive copy of the table's rows.
|
||||
- `clear()` — drop every table.
|
||||
|
||||
### `createInMemoryStore()`
|
||||
|
||||
Returns a new `InMemorySeedStore`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The composite key is built by mapping each `keyField` to `String(record[k] ??
|
||||
"")` and joining with `"::"`. Nullish field values collapse to the empty
|
||||
string, so records with `undefined` and `null` for the same field share a
|
||||
key; callers that need to distinguish them must use a key field that is
|
||||
always populated. `update` mutates the stored array in place via index
|
||||
assignment so subsequent `getRecords` calls observe the merge; the spread
|
||||
order is `{ ...existing[idx], ...record }`, meaning incoming fields win on
|
||||
collision.
|
||||
|
||||
`AbortSignal` arguments are intentionally ignored: there is no I/O to
|
||||
abort. The parameters exist only so the in-memory store satisfies the same
|
||||
shape as a network-bound store and can be swapped in for one in tests.
|
||||
|
||||
`getRecords` returns a shallow copy of the underlying array so mutating the
|
||||
returned list does not corrupt subsequent assertions. `clear` removes the
|
||||
map entries themselves, after which `tableExists` returns `false` for every
|
||||
table — distinguishing it from `truncate`, which preserves the entry while
|
||||
emptying its contents.
|
||||
113
docs/persistence/database/seed/src/registry/registry.md
Normal file
113
docs/persistence/database/seed/src/registry/registry.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# SeedRegistry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SeedRegistry` is the canonical in-memory store for `SeedDefinition` entries used by `@suaveplan/seed`. It owns insertion-order bookkeeping, duplicate-name detection, and environment-scoped query helpers, providing every other layer (builder, runner, fixtures) a single source of truth for "what seeds exist in this process".
|
||||
|
||||
The registry is intentionally simple: no I/O, no concurrency primitives, no dependency resolution. Its sole invariants are (1) seed names are unique, (2) every read accessor returns a defensive shallow copy so caller mutations never leak into internal storage, and (3) duplicate registrations raise a structured `SeedError` with code `SEED.DUPLICATE_NAME` rather than a bare `Error`, so downstream catch sites can dispatch programmatically.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) name-keyed insertion, lookup, and deletion backed by a `Map`.
|
||||
- Bulk registration via `registerMany` that fails fast on the first duplicate.
|
||||
- Environment-scoped query (`getForEnvironment`) where unrestricted seeds (undefined or empty `environments`) match every environment.
|
||||
- Defensive shallow copies returned from every read accessor (`get`, `getAll`, `getForEnvironment`).
|
||||
- `SeedError`-typed failure with stable `SEED.DUPLICATE_NAME` code and `seedName` discriminant.
|
||||
- Insertion-order iteration via `names` getter (Map semantics).
|
||||
- Implements `ISeedRegistry` so consumers can substitute alternative registry implementations in tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SeedRegistry } from "@suaveplan/seed/registry/registry";
|
||||
|
||||
const registry = new SeedRegistry();
|
||||
|
||||
registry.register({
|
||||
name: "users",
|
||||
table: "users",
|
||||
data: [{ id: 1, email: "a@example.com" }],
|
||||
});
|
||||
|
||||
const seed = registry.get("users");
|
||||
if (seed) {
|
||||
// seed.data is a defensive copy; safe to inspect without affecting storage.
|
||||
}
|
||||
|
||||
console.log(registry.size); // 1
|
||||
console.log(registry.names); // ["users"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SeedRegistry } from "@suaveplan/seed/registry/registry";
|
||||
import {
|
||||
SEED_CODES,
|
||||
SeedError,
|
||||
} from "@suaveplan/seed/error-codes/error-codes";
|
||||
import type { SeedDefinition } from "@suaveplan/seed/types/common";
|
||||
|
||||
const registry = new SeedRegistry();
|
||||
|
||||
const defs: SeedDefinition[] = [
|
||||
{ name: "users", table: "users", data: [] },
|
||||
{
|
||||
name: "fixtures",
|
||||
table: "fixtures",
|
||||
environments: ["test"],
|
||||
data: [],
|
||||
},
|
||||
{
|
||||
name: "demo-data",
|
||||
table: "demo",
|
||||
environments: ["dev"],
|
||||
data: [],
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
registry.registerMany(defs);
|
||||
} catch (err) {
|
||||
if (err instanceof SeedError && err.code === SEED_CODES.DUPLICATE_NAME) {
|
||||
// Structured: err.seedName identifies the colliding entry.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Environment-scoped query: unrestricted + matching seeds only.
|
||||
const forTest = registry.getForEnvironment("test");
|
||||
// → users (unrestricted) + fixtures (env=test)
|
||||
|
||||
// Remove yields a boolean; missing keys are a no-op.
|
||||
registry.remove("demo-data");
|
||||
registry.clear();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
| ----------------------------------- | ----------------------------------------------------------------- |
|
||||
| `register(seed)` | Insert one seed; throws `SeedError(SEED.DUPLICATE_NAME)` on collision. |
|
||||
| `registerMany(seeds)` | Iterate and `register` each seed; aborts on first duplicate. |
|
||||
| `get(name)` | Defensive copy of the named seed, or `undefined`. |
|
||||
| `getAll()` | Defensive-copy array of every registered seed. |
|
||||
| `getForEnvironment(env)` | Subset matching `env` or unrestricted (`undefined`/empty). |
|
||||
| `has(name)` | `true` if the name is present, `false` otherwise. |
|
||||
| `remove(name)` | Delete; returns `true` if the entry existed. |
|
||||
| `clear()` | Remove every registered seed. |
|
||||
| `size` | Current entry count. |
|
||||
| `names` | Snapshot of registered names in insertion order. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Storage uses `Map<string, SeedDefinition>` so insertion order is preserved by `names`, `getAll`, and `getForEnvironment`.
|
||||
- Defensive copies are *shallow* (`{ ...seed }`). Nested arrays (`environments`, `dependencies`, `upsertKeys`, `data`) are not cloned; they remain `readonly` on the `SeedDefinition` type, so callers cannot mutate them without an explicit cast. This matches the upstream reference behaviour and avoids deep-clone overhead during runner setup.
|
||||
- `getForEnvironment` filters the already-defensive `getAll()` result. The two-pass approach keeps the filter logic small and reuses the copy contract.
|
||||
- `register` throws *before* mutating the map, so a duplicate registration leaves the registry intact. `registerMany` therefore preserves a partial-success prefix when an entry mid-stream collides — consumers who need transactional semantics should snapshot `names` and roll back via `remove` after catching.
|
||||
- Error construction always includes `seedName` so downstream `instanceof SeedError` checks can dispatch without parsing the message string.
|
||||
- No internal concurrency control: the registry is intended for single-threaded use during process startup and test setup. Consumers requiring concurrent registration must serialise externally.
|
||||
123
docs/persistence/database/seed/src/runner/execute.md
Normal file
123
docs/persistence/database/seed/src/runner/execute.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# execute
|
||||
|
||||
## Purpose
|
||||
|
||||
The `executeSeed` function applies one `SeedDefinition` to one
|
||||
`SeedStore`, returning a single `SeedResult`. It is the per-seed body
|
||||
that `SeedRunner.run` invokes inside its topo-ordered loop. The
|
||||
function is split out of `runner.ts` so the orchestration class can
|
||||
focus on dependency ordering, abort handling, and logging while the
|
||||
batching, upsert-strategy, and error-wrapping mechanics live here in
|
||||
their own file under the 250-line soft limit.
|
||||
|
||||
The split is purely organisational: callers do not import
|
||||
`executeSeed` directly — the public entry point is `SeedRunner.run`,
|
||||
which composes this function with the registry, topological sort,
|
||||
logger, and abort signal. The function is exported as a named symbol
|
||||
from the same `runner/` folder so tests can target either layer
|
||||
independently when that helps with isolation.
|
||||
|
||||
## Features
|
||||
|
||||
- Resolves `SeedDefinition.data` (array or async factory) into a
|
||||
concrete record array.
|
||||
- Honours `dryRun` — returns a synthesised `completed` result with the
|
||||
resolved record count and zero IO against the store.
|
||||
- Calls `store.truncate(seed.table, signal)` first when
|
||||
`seed.truncate` is true.
|
||||
- Batches the records by `config.batchSize ?? DEFAULT_BATCH_SIZE`.
|
||||
- Applies the requested upsert strategy per batch:
|
||||
- `skip` with non-empty `upsertKeys`: filters out records whose key
|
||||
tuple already exists in the store.
|
||||
- `update` with non-empty `upsertKeys`: inserts absent records and
|
||||
updates the rest in two store calls.
|
||||
- `replace` or empty `upsertKeys`: inserts every record directly.
|
||||
- Wraps every store exception as
|
||||
`SeedError({ code: SEED.STORE_ERROR, cause })` so the runner
|
||||
surfaces a stable code while preserving the original via the
|
||||
cause-chain.
|
||||
- Threads `AbortSignal` from the runner into every store call.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { executeSeed } from "./execute.js";
|
||||
import { createInMemoryStore } from "../fixtures/fixtures.js";
|
||||
|
||||
const store = createInMemoryStore();
|
||||
const result = await executeSeed(
|
||||
store,
|
||||
{ name: "users", table: "u", data: [{ id: 1 }] },
|
||||
{ environment: "dev" },
|
||||
);
|
||||
console.log(result.status, result.recordsInserted);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { executeSeed } from "./execute.js";
|
||||
|
||||
const controller = new AbortController();
|
||||
const result = await executeSeed(
|
||||
productionStore,
|
||||
{
|
||||
name: "users",
|
||||
table: "user",
|
||||
data: async () => fetchFromCsv("/users.csv"),
|
||||
upsertStrategy: "update",
|
||||
upsertKeys: ["tenantId", "email"],
|
||||
truncate: false,
|
||||
},
|
||||
{
|
||||
environment: "production",
|
||||
batchSize: 500,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.status === "failed") {
|
||||
console.error("seed", result.name, "failed:", result.error);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `async function executeSeed(store, seed, config): Promise<SeedResult>`
|
||||
|
||||
Parameters:
|
||||
|
||||
| Name | Type | Description |
|
||||
| -------- | ---------------- | ------------------------------------------------- |
|
||||
| `store` | `SeedStore` | Adapter the function calls into. |
|
||||
| `seed` | `SeedDefinition` | The seed to apply. |
|
||||
| `config` | `SeedRunConfig` | Run-scoped options (batch size, dry-run, signal). |
|
||||
|
||||
Returns `Promise<SeedResult>` — always resolves; failures are captured
|
||||
on the result rather than thrown. The result's `status` is
|
||||
`"completed"` on success and `"failed"` when the store rejects;
|
||||
`recordsInserted` / `recordsUpdated` / `recordsSkipped` reflect the
|
||||
partial progress at the point of failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
A single private `processBatch` helper handles the three upsert
|
||||
strategies so the main `try/catch` block stays short. Empty batches
|
||||
take the explicit `: 0` ternary branch on both the `insert` and
|
||||
`update` calls — that branch fires when every record in the batch
|
||||
already exists (skip strategy) or when there are no inserts or updates
|
||||
to perform (update strategy with all-existing or all-fresh inputs).
|
||||
|
||||
The `buildKey` helper collapses missing or `null` key-field values via
|
||||
`record[k] ?? ""`. Records sharing the same key tuple — including
|
||||
those whose key fields are all absent — are deduplicated by the
|
||||
strategy, which matches the upstream `@suaveplan/server/seed`
|
||||
behaviour exactly. Adapters that need strict null handling should
|
||||
provide their own `findExisting` semantics rather than rely on the
|
||||
runner's key collapse.
|
||||
|
||||
Errors that are already `instanceof SeedError` pass through
|
||||
untouched — useful when downstream code already wraps its failures
|
||||
into the `SEED.*` code space. Anything else is wrapped with
|
||||
`SEED.STORE_ERROR` and the original captured on `cause`; the
|
||||
human-readable message is stored on `SeedResult.error`.
|
||||
215
docs/persistence/database/seed/src/runner/runner.md
Normal file
215
docs/persistence/database/seed/src/runner/runner.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# runner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `SeedRunner` orchestrates a single seeding run against an injected
|
||||
`SeedStore`. It is the public entry point for actually applying a set of
|
||||
declarative `SeedDefinition` objects: it picks the seeds that apply to
|
||||
the requested environment, filters them by the caller's `only` /
|
||||
`exclude` lists, topologically sorts the survivors, and executes each in
|
||||
turn with the configured upsert strategy, batching, dry-run, and
|
||||
cancellation behaviour.
|
||||
|
||||
The runner is intentionally store-agnostic. It does not know about
|
||||
SurrealDB, Postgres, or any particular driver — it just calls the
|
||||
`SeedStore` interface declared in `types/common.ts`. That keeps the core
|
||||
universal (no `node:` / `bun:` imports) and lets the same runner
|
||||
drive in-memory tests, local containers, and production databases
|
||||
without rewriting the orchestration layer.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dependency-ordered execution** via Kahn's-algorithm topological sort
|
||||
(uses the sibling `utils/topo-sort` module).
|
||||
- **Filtering**: `only` includes a whitelist; `exclude` removes from the
|
||||
result; the two compose (`only` first, then `exclude`).
|
||||
- **Three upsert strategies** (`skip`, `update`, `replace`) honoured at
|
||||
the batch level — the runner delegates to
|
||||
`SeedStore.findExisting`, `SeedStore.insert`, and `SeedStore.update`
|
||||
according to the strategy and the presence of `upsertKeys`.
|
||||
- **Batching** by `SeedRunConfig.batchSize`, defaulting to
|
||||
`DEFAULT_BATCH_SIZE` (100). Records are passed to the store in
|
||||
`Array.slice`-derived chunks; the store reports the actual insert /
|
||||
update counts.
|
||||
- **Dry-run mode** synthesises `completed` results without invoking the
|
||||
store — useful for CI smoke tests and for validating environment
|
||||
filters.
|
||||
- **Dependency-aware skipping**: when a seed fails, every dependent in
|
||||
the topo-order is recorded with `status: "skipped"` and
|
||||
`error: "Dependency failed"` rather than attempted.
|
||||
- **`AbortSignal` plumbing**: the signal is checked between seeds and
|
||||
forwarded into every `SeedStore` call. Aborted runs return
|
||||
`success: false` and mark every remaining seed `skipped` with
|
||||
`error: "Aborted"`.
|
||||
- **Injectable `LoggerLike`** (`@suaveplan/types`) with a no-op default.
|
||||
Emits structured records at `debug` / `info` / `warn` / `error`
|
||||
levels for every observable transition.
|
||||
- **Store-error wrapping**: exceptions thrown by `SeedStore` methods are
|
||||
wrapped as `SeedError` with code `SEED.STORE_ERROR` and the original
|
||||
exception attached on `error.cause`. `SeedResult.error` carries the
|
||||
human-readable message.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInMemoryStore,
|
||||
defineSeed,
|
||||
SeedRegistry,
|
||||
SeedRunner,
|
||||
} from "@suaveplan/seed";
|
||||
|
||||
const registry = new SeedRegistry();
|
||||
registry.register(
|
||||
defineSeed("users")
|
||||
.data([{ id: 1, name: "alice" }])
|
||||
.upsert("skip", ["id"])
|
||||
.build()
|
||||
);
|
||||
|
||||
const store = createInMemoryStore();
|
||||
const runner = new SeedRunner(registry, store);
|
||||
const result = await runner.run({ environment: "dev" });
|
||||
console.log(result.success, result.seedsRun);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInMemoryStore,
|
||||
defineSeed,
|
||||
SeedRegistry,
|
||||
SeedRunner,
|
||||
} from "@suaveplan/seed";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
const registry = new SeedRegistry();
|
||||
registry.registerMany([
|
||||
defineSeed("tenants")
|
||||
.environments("staging", "production")
|
||||
.data(async () => fetchTenantsFromCsv())
|
||||
.upsert("update", ["slug"])
|
||||
.priority(0)
|
||||
.build(),
|
||||
defineSeed("admin-users")
|
||||
.dependsOn("tenants")
|
||||
.data(adminUsers)
|
||||
.upsert("update", ["tenantId", "email"])
|
||||
.priority(10)
|
||||
.build(),
|
||||
defineSeed("audit-buckets")
|
||||
.dependsOn("tenants")
|
||||
.truncateFirst()
|
||||
.data([])
|
||||
.priority(20)
|
||||
.build(),
|
||||
]);
|
||||
|
||||
const logger: LoggerLike = createStructuredLogger();
|
||||
const runner = new SeedRunner(registry, productionStore, logger);
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 30_000);
|
||||
|
||||
const result = await runner.run({
|
||||
environment: "staging",
|
||||
only: ["tenants", "admin-users"],
|
||||
batchSize: 500,
|
||||
continueOnError: false,
|
||||
signal: controller.signal,
|
||||
onProgress: (r) => logger.info("seed.progress", {
|
||||
name: r.name,
|
||||
status: r.status,
|
||||
duration: r.duration,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Seed run failed: ${result.seedsFailed} failed, ${result.seedsSkipped} skipped`,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SeedRunner implements ISeedRunner`
|
||||
|
||||
```ts
|
||||
constructor(
|
||||
registry: SeedRegistry,
|
||||
store: SeedStore,
|
||||
logger?: LoggerLike,
|
||||
)
|
||||
|
||||
run(config: SeedRunConfig): Promise<SeedRunResult>
|
||||
```
|
||||
|
||||
### `SeedRunConfig`
|
||||
|
||||
| Field | Type | Default |
|
||||
| ----------------- | ----------------------------------- | ---------------------- |
|
||||
| `environment` | `SeedEnvironment` | (required) |
|
||||
| `only?` | `readonly string[]` | undefined (all seeds) |
|
||||
| `exclude?` | `readonly string[]` | undefined |
|
||||
| `dryRun?` | `boolean` | `false` |
|
||||
| `batchSize?` | `number` | `DEFAULT_BATCH_SIZE` |
|
||||
| `continueOnError?`| `boolean` | `false` |
|
||||
| `onProgress?` | `(r: SeedResult) => void` | undefined |
|
||||
| `signal?` | `AbortSignal` | undefined |
|
||||
|
||||
### `SeedRunResult`
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| ---------------- | ------------------- | -------------------------------------------------- |
|
||||
| `environment` | `SeedEnvironment` | The environment the run was scoped to |
|
||||
| `totalDuration` | `number` | Milliseconds via `performance.now()` |
|
||||
| `results` | `readonly SeedResult[]` | One entry per attempted (or skipped) seed |
|
||||
| `success` | `boolean` | True iff zero failures AND not aborted |
|
||||
| `seedsRun` | `number` | Results with status other than `"skipped"` |
|
||||
| `seedsSkipped` | `number` | Results with status `"skipped"` |
|
||||
| `seedsFailed` | `number` | Results with status `"failed"` |
|
||||
|
||||
### Logger events
|
||||
|
||||
| Event | Level | Fields |
|
||||
| ------------------ | ------- | --------------------------------------------------------------------------------------- |
|
||||
| `seed.run.preparing` | debug | `environment`, `filteredCount`, `orderedCount` |
|
||||
| `seed.run.start` | info | `environment`, `totalSeeds` |
|
||||
| `seed.completed` | info | `name`, `table`, `recordsInserted`, `recordsUpdated`, `recordsSkipped`, `duration` |
|
||||
| `seed.skipped` | warn | `name`, `table`, `reason` (`"Dependency failed"` \| `"Aborted"`) |
|
||||
| `seed.failed` | error | `name`, `table`, `error`, `duration` |
|
||||
| `seed.run.complete` | info | `environment`, `success`, `seedsRun`, `seedsSkipped`, `seedsFailed`, `totalDuration` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The actual batched insert/update/findExisting work lives in a sibling
|
||||
`execute.ts` module so this file stays under the 250-line soft limit.
|
||||
The split is purely organisational: `executeSeed(store, seed, config)`
|
||||
is invoked from `SeedRunner.run` and returns a single `SeedResult` per
|
||||
seed. Errors thrown by the store inside `executeSeed` are wrapped as
|
||||
`SeedError({ code: SEED.STORE_ERROR, cause })`; the runner records the
|
||||
message string on `SeedResult.error` while the structured fields
|
||||
(`code`, `seedName`, `cause`) remain reachable to consumers that catch
|
||||
the wrapped error from higher up.
|
||||
|
||||
`SeedRunner` keeps the abort-on-signal check at the **top** of the
|
||||
execution loop so a signal raised between seeds short-circuits before
|
||||
the next seed's data factory is even invoked. `AbortSignal` is also
|
||||
forwarded into every `SeedStore` method via the optional `signal?`
|
||||
parameter on the interface, letting network-bound adapters honour
|
||||
cancellation mid-batch. The `InMemorySeedStore` shipped in
|
||||
`fixtures/fixtures.ts` accepts but ignores the signal — it has nothing
|
||||
to cancel.
|
||||
|
||||
Defensive `if (!seed) continue;` checks against `seedMap.get(name)`
|
||||
were removed: `order` and `seedMap` are both built from the same
|
||||
`filtered` array, so every name `topoSort` returns is guaranteed to
|
||||
resolve. The TypeScript assertion (`as SeedDefinition`) records that
|
||||
invariant without the false-positive coverage gap a runtime check would
|
||||
introduce.
|
||||
151
docs/persistence/database/seed/src/types/common.md
Normal file
151
docs/persistence/database/seed/src/types/common.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# types/common
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type module for `@suaveplan/seed`. Every public class and function
|
||||
in the package — `SeedBuilder`, `SeedRegistry`, `SeedRunner`,
|
||||
`FixtureFactory`, `InMemorySeedStore`, `topoSort`, `detectCycles` —
|
||||
references the type contracts declared here. Defining them in one
|
||||
location keeps the public surface coherent, lets consumers pull a single
|
||||
import path when they want to type their own `SeedStore` adapters, and
|
||||
ensures cross-module type-narrowing works without circular imports.
|
||||
|
||||
The types are all data-shapes (`SeedDefinition`, `SeedRecord`,
|
||||
`SeedResult`, `SeedRunResult`, `SeedRunConfig`, `SeedStore`) plus their
|
||||
supporting unions (`SeedEnvironment`, `SeedStatus`, `UpsertStrategy`,
|
||||
`SeedDataFactory`). They are intentionally framework-free — no Zod
|
||||
schemas, no runtime validators — because the package's developer-facing
|
||||
API is constructed in-process, not received over a wire.
|
||||
|
||||
## Features
|
||||
|
||||
- `SeedDefinition` and `SeedRecord` shapes
|
||||
- `SeedResult` / `SeedRunResult` shapes returned by the runner
|
||||
- `SeedRunConfig` shape consumed by `SeedRunner.run`
|
||||
- `SeedStore` adapter contract (`insert` / `update` / `findExisting` / `truncate` / `tableExists`) with optional `AbortSignal` plumbing
|
||||
- `SeedEnvironment` open string-literal union accepting custom project environments
|
||||
- `SeedStatus` and `UpsertStrategy` enums-as-unions
|
||||
- `SeedDataFactory` async-or-sync data producer signature
|
||||
- `readonly` on every collection so consumers cannot mutate definitions in-flight
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { SeedDefinition, SeedStore } from "@suaveplan/seed";
|
||||
|
||||
const usersSeed: SeedDefinition = {
|
||||
name: "users",
|
||||
table: "user",
|
||||
data: [{ id: 1, name: "alice" }],
|
||||
upsertStrategy: "skip",
|
||||
upsertKeys: ["id"],
|
||||
};
|
||||
|
||||
class MyStore implements SeedStore {
|
||||
async insert(table, records) {
|
||||
// ...
|
||||
return records.length;
|
||||
}
|
||||
async update() { return 0; }
|
||||
async findExisting() { return []; }
|
||||
async truncate() {}
|
||||
async tableExists() { return true; }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SeedDataFactory,
|
||||
SeedDefinition,
|
||||
SeedEnvironment,
|
||||
SeedRunConfig,
|
||||
SeedRunResult,
|
||||
SeedStore,
|
||||
} from "@suaveplan/seed";
|
||||
|
||||
const tenantsFactory: SeedDataFactory = async () => {
|
||||
const csv = await fetch("/tenants.csv").then((r) => r.text());
|
||||
return parseTenants(csv);
|
||||
};
|
||||
|
||||
const def: SeedDefinition = {
|
||||
name: "tenants",
|
||||
table: "tenant",
|
||||
environments: ["staging", "production"],
|
||||
dependencies: [],
|
||||
data: tenantsFactory,
|
||||
upsertStrategy: "update",
|
||||
upsertKeys: ["slug"],
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
async function runFor(
|
||||
env: SeedEnvironment,
|
||||
store: SeedStore,
|
||||
signal: AbortSignal,
|
||||
): Promise<SeedRunResult> {
|
||||
const config: SeedRunConfig = {
|
||||
environment: env,
|
||||
batchSize: 500,
|
||||
continueOnError: false,
|
||||
signal,
|
||||
onProgress: (r) => console.log(r.name, r.status, r.duration),
|
||||
};
|
||||
return runner.run(config);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Types
|
||||
|
||||
- `SeedEnvironment` — string-literal union with `"dev" | "staging" | "test" | "production"` plus any custom string
|
||||
- `SeedStatus` — `"pending" | "running" | "completed" | "failed" | "skipped"`
|
||||
- `SeedRecord` — `Record<string, unknown>`
|
||||
- `SeedDataFactory` — `() => SeedRecord[] | Promise<SeedRecord[]>`
|
||||
- `UpsertStrategy` — `"skip" | "update" | "replace"`
|
||||
|
||||
### Interfaces
|
||||
|
||||
- `SeedDefinition` — every field declarative on a seed (`name`, `table`, `environments?`, `dependencies?`, `data`, `upsertStrategy?`, `upsertKeys?`, `description?`, `priority?`, `truncate?`)
|
||||
- `SeedResult` — single-seed outcome (`name`, `table`, `status`, counts, `duration`, `error?`)
|
||||
- `SeedRunResult` — aggregate outcome of one `run()` call
|
||||
- `SeedStore` — adapter contract (`insert`, `update`, `findExisting`, `truncate`, `tableExists`) with optional `signal` parameter on every method
|
||||
- `SeedRunConfig` — input to `SeedRunner.run` (`environment`, `only?`, `exclude?`, `dryRun?`, `batchSize?`, `continueOnError?`, `onProgress?`, `signal?`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SeedEnvironment` uses the `(string & {})` trick — a no-op intersection
|
||||
that preserves IDE autocomplete for the well-known values while still
|
||||
accepting arbitrary strings. This is the canonical TypeScript idiom for
|
||||
"open enum" string unions and matches the pattern used elsewhere in the
|
||||
project (e.g. `LocaleCode`, `MimeType`).
|
||||
|
||||
Every array field on `SeedDefinition` and `SeedRunConfig` is `readonly`,
|
||||
so callers (or the registry's defensive copies) cannot mutate the
|
||||
definitions mid-run. `SeedStore` methods accept `readonly SeedRecord[]`
|
||||
inputs, freeing implementations from copying defensively while still
|
||||
allowing them to enumerate.
|
||||
|
||||
`SeedDataFactory` returns either a sync `SeedRecord[]` or a
|
||||
`Promise<SeedRecord[]>`. The runner always `await`s the result so callers
|
||||
can treat both forms identically; in practice the sync form covers
|
||||
inline test fixtures and the async form covers loading CSVs, fetching
|
||||
from remote APIs, or generating records from `FixtureFactory.createMany`.
|
||||
|
||||
`SeedStore.insert` returns the number of records actually inserted, not
|
||||
the count the caller passed in — adapters MAY filter (e.g. via
|
||||
`ON CONFLICT DO NOTHING`) and the runner trusts the returned count when
|
||||
populating `SeedResult.recordsInserted`.
|
||||
|
||||
The optional `signal?: AbortSignal` parameter on every `SeedStore` method
|
||||
lets network-bound stores honour cancellation; the `InMemorySeedStore`
|
||||
ignores it. The runner propagates `SeedRunConfig.signal` to every store
|
||||
call but ALSO performs its own check between seeds so a cancelled run
|
||||
shuts down even if the store ignores the signal.
|
||||
109
docs/persistence/database/seed/src/utils/topo-sort/topo-sort.md
Normal file
109
docs/persistence/database/seed/src/utils/topo-sort/topo-sort.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# topo-sort
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`topo-sort` is the dependency-resolution engine that lets `@suaveplan/seed` execute seed definitions in an order where every dependency precedes its dependents. Without it, the runner could not enforce the contract that, for example, `categories` is populated before `products` references it. The module exposes two functions: `topoSort`, which produces a single linear execution order (or throws when the graph is broken), and `detectCycles`, a non-throwing diagnostic counterpart that returns explicit cycle traces for tooling and error reporting. Both functions are pure and operate over structurally-typed inputs, so they work equally well on `SeedDefinition` arrays and on lightweight test fixtures.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-pass [Kahn's algorithm](https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm) topological sort.
|
||||
- Stable priority tiebreak: among independent seeds, lower `priority` runs first (default `0`).
|
||||
- Stable, `SeedError`-typed failure modes:
|
||||
- `SEED.UNREGISTERED_DEPENDENCY` when a `dependencies[]` entry names a seed that is not present in the input.
|
||||
- `SEED.CIRCULAR_DEPENDENCY` when the graph contains a cycle (detected via residual unprocessed nodes after the queue drains).
|
||||
- `SEED.DEPENDENCY_DEPTH_EXCEEDED` defensive guard against pathological iteration counts.
|
||||
- DFS-based `detectCycles` that returns an array of cycle traces and never throws — ideal for surfacing diagnostics in test setup or CI pre-flight checks.
|
||||
- Universal: zero `node:` / `bun:` imports, runs in browsers, Bun, Node, workers, and Deno.
|
||||
- Accepts `readonly` arrays and `readonly string[]` dependency lists for cheap interop with frozen domain types.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { topoSort } from "@suaveplan/seed/utils/topo-sort/topo-sort";
|
||||
|
||||
const order = topoSort([
|
||||
{ name: "products", dependencies: ["categories"] },
|
||||
{ name: "categories" },
|
||||
{ name: "reviews", dependencies: ["products", "users"] },
|
||||
{ name: "users" },
|
||||
]);
|
||||
// → ["categories", "users", "products", "reviews"] (priority ties broken stably)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SeedError, SEED_CODES } from "@suaveplan/seed";
|
||||
import { detectCycles, topoSort } from "@suaveplan/seed/utils/topo-sort/topo-sort";
|
||||
|
||||
const seeds = [
|
||||
{ name: "a", dependencies: ["b"], priority: 0 },
|
||||
{ name: "b", dependencies: ["a"], priority: 0 },
|
||||
];
|
||||
|
||||
// Pre-flight: surface every cycle as an explicit trace.
|
||||
const cycles = detectCycles(seeds);
|
||||
if (cycles.length > 0) {
|
||||
for (const trace of cycles) {
|
||||
console.error(`cycle: ${trace}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Execution path throws a typed error.
|
||||
try {
|
||||
topoSort(seeds);
|
||||
} catch (err) {
|
||||
if (err instanceof SeedError && err.code === SEED_CODES.CIRCULAR_DEPENDENCY) {
|
||||
// remediate
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `topoSort(seeds): string[]`
|
||||
|
||||
```ts
|
||||
function topoSort(
|
||||
seeds: readonly {
|
||||
readonly name: string;
|
||||
readonly dependencies?: readonly string[];
|
||||
readonly priority?: number;
|
||||
}[]
|
||||
): string[];
|
||||
```
|
||||
|
||||
Returns a fresh array of seed names in execution order. Throws `SeedError` (with one of `SEED.UNREGISTERED_DEPENDENCY`, `SEED.CIRCULAR_DEPENDENCY`, `SEED.DEPENDENCY_DEPTH_EXCEEDED`) on any structural problem.
|
||||
|
||||
### `detectCycles(seeds): string[]`
|
||||
|
||||
```ts
|
||||
function detectCycles(
|
||||
seeds: readonly {
|
||||
readonly name: string;
|
||||
readonly dependencies?: readonly string[];
|
||||
}[]
|
||||
): string[];
|
||||
```
|
||||
|
||||
Returns an array of cycle-trace strings shaped like `"a -> b -> a"`. Returns `[]` for an empty input or any DAG. Never throws. Dependencies whose names are not present in the graph are silently skipped — they are reported by `topoSort` as `UNREGISTERED_DEPENDENCY` instead.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`topoSort` performs exactly one Kahn's-algorithm pass:
|
||||
|
||||
1. Build three maps in a single loop: `graph` (name → deps), `inDegree` (name → 0), and `priority` (name → priority).
|
||||
2. Validate every dependency edge: when a name in `dependencies` is absent from `graph`, throw `UNREGISTERED_DEPENDENCY`; otherwise increment the dependent's in-degree.
|
||||
3. Seed the queue with every name whose `inDegree === 0`, sorted by ascending priority.
|
||||
4. Drain the queue: pop, append to `result`, decrement the in-degree of every node that depends on the popped name. When a dependent's in-degree falls to zero, enqueue it and re-sort by priority. Stable priority ordering is preserved by re-sorting on insertion rather than maintaining a binary heap — for the small graphs typical of seed sets, the simpler approach is faster and more legible.
|
||||
5. After the loop, if `result.length !== graph.size`, identify the unprocessed names and throw `CIRCULAR_DEPENDENCY` with the offending names embedded in the message.
|
||||
|
||||
The `DEPENDENCY_DEPTH_EXCEEDED` guard is structurally unreachable from valid input because Kahn's algorithm enqueues each node at most once. It remains in place as belt-and-braces against future refactors that might violate that invariant — the corresponding line is excluded from coverage with `/* c8 ignore */` plus a justifying comment.
|
||||
|
||||
`detectCycles` uses iterative DFS with two sets: `visited` (fully explored) and `stack` (current recursion path). When DFS encounters a node already on `stack`, it slices the path from that node forward, appends the cycle-closing edge, and joins with `" -> "`. Dependencies that name non-existent nodes are skipped so external references (e.g., seeds defined outside the current registry slice) do not surface as false-positive cycles.
|
||||
|
||||
Both functions are pure: they allocate no shared state, do not mutate their inputs, and produce deterministic output for any given input. Priority is the only tiebreak — insertion order is not preserved beyond what priority encodes, which is intentional so callers cannot accidentally depend on registration order.
|
||||
105
docs/persistence/database/surrealdb/README.md
Normal file
105
docs/persistence/database/surrealdb/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# @suaveplan/surrealdb - Universal SurrealDB client wrapper implementing DatabaseAdapter: connection lifecycle, singleton registry, typed CRUD, schema discovery, initialisation, version detection, OTel instrumentation, retry integration, and structured SURREAL.* error codes.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal SurrealDB client wrapper for Suaveplan. Implements `@suaveplan/db`'s `DatabaseAdapter` interface with connection lifecycle management, typed CRUD, schema discovery, database initialisation, version detection, structured `SURREAL.*` error codes, and OTel instrumentation.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapter](./src/adapter/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [adapter](./src/adapter/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [crud](./src/adapter/crud/crud.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [crud](./src/adapter/crud/crud.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [transaction](./src/adapter/transaction/transaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [transaction](./src/adapter/transaction/transaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [error](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schema](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [discovery](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [utils](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [version-detector](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [adapter](./src/adapter/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [crud](./src/adapter/crud/crud.md) — <!-- begin:auto-docs --> _(no submodules)_ - [crud](./src/adapter/crud/crud.md) — <!-- begin:auto-docs --> _(no submodules)_- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ - [query-helpers](./src/adapter/query-helpers/query-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [transaction](./src/adapter/transaction/transaction.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transaction](./src/adapter/transaction/transaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [error](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [version-detector](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapter](./src/adapter/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [crud](./src/adapter/crud/crud.md) — `crud` exposes the typed record-level CRUD primitives (`create`, `select`, `update`, `delete`, `selectAll`) used by `Su…
|
||||
- [crud](./src/adapter/crud/crud.md) — `crud` exposes the typed record-level CRUD primitives (`create`, `select`, `update`, `delete`, `selectAll`) used by `Su…
|
||||
- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — `query-helpers` holds the small, stateless utility functions shared by every public method on `SurrealAdapter` and its…
|
||||
- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — `query-helpers` holds the small, stateless utility functions shared by every public method on `SurrealAdapter` and its…
|
||||
- [transaction](./src/adapter/transaction/transaction.md) — `transaction` houses the SurrealDB transaction orchestration logic that wraps a caller-supplied function in a `BEGIN TR…
|
||||
- [transaction](./src/adapter/transaction/transaction.md) — `transaction` houses the SurrealDB transaction orchestration logic that wraps a caller-supplied function in a `BEGIN TR…
|
||||
- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [error](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [version-detector](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapter](./src/adapter/adapter/adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [crud](./src/adapter/crud/crud.md) — <!-- begin:auto-docs --> _(no submodules)_- [query-helpers](./src/adapter/query-helpers/query-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [transaction](./src/adapter/transaction/transaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection](./src/connection/connection.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [error](./src/errors/error.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [init](./src/init/init.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [discovery](./src/schema/discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [singleton](./src/singleton/singleton.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [version-detector](./src/utils/version-detector.md) — <!-- begin:auto-docs --> _(no submodules)_## Packages
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [errors/error](src/errors/error.md) | `SurrealError` class and `SURREAL_CODES` registry |
|
||||
| [types/types](src/types/types.md) | Shared TypeScript types and the `SurrealClientLike` injection interface |
|
||||
| [schemas/schemas](src/schemas/schemas.md) | Zod schemas for all public option types |
|
||||
| [connection/connection](src/connection/connection.md) | Connection lifecycle and singleton registry |
|
||||
| [adapter/adapter](src/adapter/adapter/adapter.md) | `SurrealAdapter` implementing `DatabaseAdapter` |
|
||||
| [adapter/crud](src/adapter/crud/crud.md) | Typed record-level CRUD primitives |
|
||||
| [adapter/transaction](src/adapter/transaction/transaction.md) | `BEGIN`/`COMMIT`/`CANCEL TRANSACTION` orchestration |
|
||||
| [adapter/query-helpers](src/adapter/query-helpers/query-helpers.md) | Positional-param conversion, error wrapping, result flattening |
|
||||
| [schema/discovery](src/schema/discovery.md) | Schema file discovery, sorting, and reading |
|
||||
| [init/init](src/init/init.md) | Database initialisation from schema files |
|
||||
| [utils/version-detector](src/utils/version-detector.md) | SurrealDB version parsing and detection |
|
||||
| [singleton/singleton](src/singleton/singleton.md) | Adapter-level singleton registry |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
|
||||
const adapter = new SurrealAdapter({
|
||||
url: "ws://localhost:8000",
|
||||
namespace: "prod",
|
||||
database: "app",
|
||||
username: "root",
|
||||
password: "root",
|
||||
});
|
||||
|
||||
await adapter.connect();
|
||||
const user = await adapter.create("user", { name: "Alice", email: "alice@example.com" });
|
||||
console.log(user.id); // "user:alice..."
|
||||
await adapter.disconnect();
|
||||
```
|
||||
|
||||
## Integration tests
|
||||
|
||||
The package ships a parallel test suite under `tests/integration/` that exercises the adapter against a real SurrealDB v3.0.4 container via `@suaveplan/testing-integration-surrealdb`. The unit suite (`src/**/*.test.ts`) runs against a dependency-injected fake; the integration suite proves the same code paths work against the live engine.
|
||||
|
||||
Run the integration suite:
|
||||
|
||||
```bash
|
||||
bunx turbo test:integration --filter=@suaveplan/surrealdb
|
||||
# or, from the package directory:
|
||||
bun run test:integration
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
- Docker available locally; the fixture pulls `surrealdb/surrealdb:v3.0.4` on first run.
|
||||
- `@suaveplan/testing-integration` and `@suaveplan/testing-integration-surrealdb` resolve as devDependencies (already wired).
|
||||
- The integration config points at `vitest.integration.config.ts` (separate from the unit `vitest.config.ts`) and only matches `tests/integration/**/*.integration.test.ts`.
|
||||
|
||||
What the suite covers:
|
||||
|
||||
- `connect → use → signin → query → close` round-trip and parameter passthrough.
|
||||
- DDL via `execute("DEFINE TABLE …")` followed by `INFO FOR DB` to confirm the schema took.
|
||||
- `transaction()` commit-on-resolve / rollback-on-throw semantics.
|
||||
- Lifecycle: `getSingleton` deduplication and `closeAllSingletons` ordering.
|
||||
- Connection failure surfaces `DB.CONNECTION_FAILED` with the documented `ConnectionRefused` / `Unable to connect` patterns.
|
||||
- Version detection against the pinned `v3.0.4` image.
|
||||
- The `INSERT INTO` workaround for the `db.create()` content-drop bug.
|
||||
- Schema discovery returns the expected table/field shapes.
|
||||
|
||||
If Docker is unavailable, every integration test is skipped via `skipIfUnavailable("surrealdb", inject)` so CI does not falsely fail when the runner has no Docker daemon.
|
||||
@@ -0,0 +1,108 @@
|
||||
# adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`SurrealAdapter` is the central class of `@suaveplan/surrealdb`. It implements `@suaveplan/db`'s `DatabaseAdapter` interface, providing a stable, typed surface over the raw `surrealdb.js` driver. The adapter handles connection lifecycle, typed CRUD operations, transactions, and query execution — all with structured `SURREAL.*` error codes and optional OTel instrumentation.
|
||||
|
||||
A critical design decision is that all record creation uses `INSERT INTO <table> CONTENT $data` rather than the driver's `db.create()` method, which has a known bug in surrealdb.js v2+ where the content field is silently dropped.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the full `DatabaseAdapter` interface from `@suaveplan/db`
|
||||
- Injectable `SurrealClientFactory` for dependency-injection-based testing without a live server
|
||||
- Typed CRUD: `create`, `select`, `update`, `delete`, `selectAll`
|
||||
- `transaction()` wrapping `BEGIN`/`COMMIT`/`CANCEL TRANSACTION` blocks
|
||||
- `execute()` for non-query DDL statements
|
||||
- `connect()` / `disconnect()` lifecycle with idempotent reconnect guard
|
||||
- AbortSignal propagation on all async operations
|
||||
- Optional `LoggerLike` for structured logging
|
||||
- `migrate()` stub delegating to `@suaveplan/migration`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb/adapter/adapter";
|
||||
|
||||
const adapter = new SurrealAdapter({ url: "ws://localhost:8000", namespace: "app", database: "main", username: "root", password: "root" });
|
||||
await adapter.connect();
|
||||
const users = await adapter.selectAll("user");
|
||||
await adapter.disconnect();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb/adapter/adapter";
|
||||
import type { SurrealClientLike } from "@suaveplan/surrealdb/types/types";
|
||||
|
||||
// Inject a fake for unit testing
|
||||
class FakeClient implements SurrealClientLike { /* ... */ }
|
||||
|
||||
const adapter = new SurrealAdapter({}, undefined, () => new FakeClient());
|
||||
await adapter.connect();
|
||||
|
||||
const user = await adapter.create("user", { name: "Alice", email: "alice@example.com" });
|
||||
const found = await adapter.select<typeof user>("user", "alice");
|
||||
await adapter.update("user", "alice", { name: "Alice Smith" });
|
||||
await adapter.delete("user", "alice");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `constructor(options?, logger?, clientFactory?)`
|
||||
|
||||
- `options.url` — defaults to `ws://localhost:8000`
|
||||
- `options.namespace`, `options.database` — defaults to `"test"`
|
||||
- `options.username`, `options.password` — defaults to `"root"`
|
||||
- `clientFactory` — optional `() => SurrealClientLike`; defaults to `new Surreal()`
|
||||
|
||||
### `connect(config?, opts?): Promise<void>`
|
||||
|
||||
Idempotent. Throws `SurrealError(CONNECTION_FAILED)` or `SurrealError(AUTH_FAILED)`.
|
||||
|
||||
### `disconnect(opts?): Promise<void>` / `close(): Promise<void>`
|
||||
|
||||
Both names are supported. Sets state to `disconnected`.
|
||||
|
||||
### `query<T>(sql, params?, opts?): Promise<T[]>`
|
||||
|
||||
Executes a SurrealQL statement. Params are positional (`p0`, `p1`, ...).
|
||||
|
||||
### `execute(sql, params?, opts?): Promise<{ rowsAffected: number }>`
|
||||
|
||||
Executes a non-query statement. Returns `{ rowsAffected: 0 }` (SurrealDB does not report affected rows).
|
||||
|
||||
### `transaction<T>(fn, opts?): Promise<T>`
|
||||
|
||||
Runs `fn(tx)` inside `BEGIN`/`COMMIT`; cancels on throw.
|
||||
|
||||
### `create<T>(table, data, opts?): Promise<T & { id: string }>`
|
||||
|
||||
Uses `INSERT INTO <table> CONTENT $data` to avoid the surrealdb.js v2 content-drop bug.
|
||||
|
||||
### `select<T>(table, id, opts?): Promise<T | null>`
|
||||
|
||||
Fetches by `type::thing($table, $id)`. Returns `null` if not found.
|
||||
|
||||
### `update<T>(table, id, data, opts?): Promise<T | null>`
|
||||
|
||||
Merges `data` into the existing record.
|
||||
|
||||
### `delete(table, id, opts?): Promise<void>`
|
||||
|
||||
Deletes by `type::thing($table, $id)`.
|
||||
|
||||
### `selectAll<T>(table, opts?): Promise<T[]>`
|
||||
|
||||
Returns all records in `table`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `require("surrealdb")` call in the default factory is wrapped in a closure to delay the import until `connect()` is called, keeping the module tree-shakeable in environments that inject a client factory.
|
||||
|
||||
Record-level CRUD (`create`/`select`/`update`/`delete`/`selectAll`) is implemented in `../crud/crud.js`; transaction orchestration in `../transaction/transaction.js`; positional-param conversion, error wrapping, and result-shape flattening in `../query-helpers/query-helpers.js`. This class is the lifecycle owner — it tracks the client + state and delegates execution to those modules so each can be tested in isolation. The `_requireClient()` helper centralises the `NOT_CONNECTED` guard and returns the non-null client so call sites get type narrowing for free.
|
||||
|
||||
The `paramsToRecord` helper from `../query-helpers/query-helpers.js` converts positional params to named `$p0`, `$p1`, ... bindings as SurrealQL uses named parameters.
|
||||
76
docs/persistence/database/surrealdb/src/adapter/crud/crud.md
Normal file
76
docs/persistence/database/surrealdb/src/adapter/crud/crud.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# crud
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`crud` exposes the typed record-level CRUD primitives (`create`, `select`, `update`, `delete`, `selectAll`) used by `SurrealAdapter`. Splitting them into a standalone module keeps `adapter.ts` under the 300-line hard limit and lets advanced consumers reach for the helpers directly when they already hold a `SurrealClientLike` reference.
|
||||
|
||||
Each helper is a plain async function that takes the client as its first positional argument — it never reads adapter state — which makes the CRUD layer trivially testable with a real fake client and free of hidden lifecycle coupling.
|
||||
|
||||
## Features
|
||||
|
||||
- `surrealCreate` — `INSERT INTO <table> CONTENT $data` (works around the surrealdb.js v2+ create bug)
|
||||
- `surrealSelect` / `surrealUpdate` / `surrealDelete` — record-id-scoped operations using `type::thing($table, $id)`
|
||||
- `surrealSelectAll` — full-table read
|
||||
- Driver errors are normalised through `wrapQueryError` so every failure surfaces as a `SurrealError` with a stable code
|
||||
- `AbortSignal` is honoured at the start of every call via `signal.throwIfAborted()`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { surrealCreate, surrealSelect } from "@suaveplan/surrealdb/adapter/crud";
|
||||
|
||||
const created = await surrealCreate(client, "user", { name: "Alice" });
|
||||
const fetched = await surrealSelect<{ name: string }>(client, "user", "alice");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
surrealSelectAll,
|
||||
surrealUpdate,
|
||||
surrealDelete,
|
||||
} from "@suaveplan/surrealdb/adapter/crud";
|
||||
|
||||
const all = await surrealSelectAll<{ id: string; archived: boolean }>(client, "doc");
|
||||
for (const doc of all) {
|
||||
if (!doc.archived) continue;
|
||||
const ac = new AbortController();
|
||||
setTimeout(() => ac.abort(), 5000);
|
||||
await surrealUpdate(client, "doc", doc.id, { archived: false }, { signal: ac.signal });
|
||||
await surrealDelete(client, "tombstone", doc.id, { signal: ac.signal });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `surrealCreate<T>(client, table, data, opts?): Promise<T & { id: string }>`
|
||||
|
||||
Throws `SurrealError(QUERY_FAILED)` when the driver returns zero rows or any other error.
|
||||
|
||||
### `surrealSelect<T>(client, table, id, opts?): Promise<T | null>`
|
||||
|
||||
Returns `null` when no row matches. Throws `SurrealError(QUERY_FAILED)` on driver errors.
|
||||
|
||||
### `surrealUpdate<T>(client, table, id, data, opts?): Promise<T | null>`
|
||||
|
||||
Returns `null` when no row matched. Throws `SurrealError(QUERY_FAILED)` on driver errors.
|
||||
|
||||
### `surrealDelete(client, table, id, opts?): Promise<void>`
|
||||
|
||||
Throws `SurrealError(QUERY_FAILED)` on driver errors.
|
||||
|
||||
### `surrealSelectAll<T>(client, table, opts?): Promise<T[]>`
|
||||
|
||||
Returns the row array (possibly empty). Throws `SurrealError(QUERY_FAILED)` on driver errors.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Each helper accepts a `SurrealClientLike` rather than the full adapter so consumers can reuse them with custom client wrappers, mocks, or in transactional bodies that already hold the client.
|
||||
- `AbortSignal` is consulted only at the entry point; the underlying driver does not currently accept a per-call signal, so once the `await client.query(...)` is in flight cancellation cannot interrupt it. Callers needing harder cancellation should race the call against a timeout external to the helper.
|
||||
- All error paths unify on `wrapQueryError`, except `surrealCreate` which can throw a code-specific `SurrealError(QUERY_FAILED)` when the row array is empty — that case is distinguishable from a thrown driver error because it carries the exact `INSERT INTO <table> returned no rows` message.
|
||||
- The `flattenRows` helper from `query-helpers` shields callers from the surrealdb.js v2 `[[...]]` result-wrapping detail.
|
||||
@@ -0,0 +1,79 @@
|
||||
# query-helpers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`query-helpers` holds the small, stateless utility functions shared by every public method on `SurrealAdapter` and its sibling CRUD/transaction modules: positional-parameter conversion, error normalisation, and result-shape flattening.
|
||||
|
||||
Centralising these helpers keeps `adapter.ts` focused on the `DatabaseAdapter` lifecycle (connect/disconnect/state) and lets the CRUD and transaction modules each consume the same battle-tested implementations without duplication.
|
||||
|
||||
## Features
|
||||
|
||||
- `paramsToRecord(params?)` — turn `readonly unknown[]` into `{ p0, p1, ... }` (or `undefined` for empty input)
|
||||
- `wrapQueryError(err, sql)` — coerce arbitrary thrown values into structured `SurrealError(QUERY_FAILED)` with cause preservation
|
||||
- `flattenRows<T>(results)` — flatten the surrealdb.js `[[row, ...]]` nested shape into `T[]`
|
||||
- All helpers are pure, allocation-free on the empty-input fast paths, and free of any client/IO coupling
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
paramsToRecord,
|
||||
wrapQueryError,
|
||||
flattenRows,
|
||||
} from "@suaveplan/surrealdb/adapter/query-helpers";
|
||||
|
||||
const vars = paramsToRecord(["alice", 42]);
|
||||
// vars === { p0: "alice", p1: 42 }
|
||||
|
||||
try {
|
||||
// ...driver call...
|
||||
} catch (err) {
|
||||
throw wrapQueryError(err, "SELECT * FROM user");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
flattenRows,
|
||||
wrapQueryError,
|
||||
} from "@suaveplan/surrealdb/adapter/query-helpers";
|
||||
|
||||
async function safeQuery<T>(
|
||||
client: { query<U>(sql: string): Promise<U[]> },
|
||||
sql: string
|
||||
): Promise<T[]> {
|
||||
try {
|
||||
const results = await client.query<T>(sql);
|
||||
return flattenRows<T>(results);
|
||||
} catch (err) {
|
||||
throw wrapQueryError(err, sql);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `paramsToRecord(params?: readonly unknown[]): Record<string, unknown> | undefined`
|
||||
|
||||
Returns `undefined` when `params` is missing or empty. Otherwise returns a fresh record keyed `p0`, `p1`, ... in order.
|
||||
|
||||
### `wrapQueryError(err: unknown, sql: string): SurrealError`
|
||||
|
||||
Returns `err` unchanged when it is already a `SurrealError`. Otherwise wraps it as `SurrealError(SURREAL.QUERY_FAILED, "Query failed [<sql>]: <message>", { cause })` where `cause` is preserved only for `Error` instances.
|
||||
|
||||
### `flattenRows<T>(results: unknown): T[]`
|
||||
|
||||
If `results` is `[[...]]` returns the inner array. If `results` is `[T, ...]` returns it as-is. For empty or non-array inputs returns `[]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `paramsToRecord` allocates a fresh object on every call to keep callers safe from accidental mutation; the empty-input branch avoids the allocation entirely.
|
||||
- `wrapQueryError` mirrors the original behaviour from `SurrealAdapter._wrapQueryError` — it must NOT swallow `SurrealError` subclasses, since callers rely on the code discriminator surviving the `await` boundary.
|
||||
- `flattenRows` accepts `unknown` so call-sites can pass the raw driver return value without an upfront cast; the structural test exhausts both observed shapes (single-statement and multi-statement query responses).
|
||||
- These functions are deliberately leaf utilities: they import only `../../errors/index.js`, never the driver or adapter. That keeps the dependency graph acyclic when the CRUD and transaction modules depend on them.
|
||||
@@ -0,0 +1,76 @@
|
||||
# transaction
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`transaction` houses the SurrealDB transaction orchestration logic that wraps a caller-supplied function in a `BEGIN TRANSACTION` / `COMMIT TRANSACTION` envelope, cancelling the transaction on failure. It is extracted from `adapter.ts` so the adapter stays under the 300-line hard limit while the transaction machinery remains independently testable.
|
||||
|
||||
The function exposed here, `runSurrealTransaction`, never reads `SurrealAdapter` state directly — it takes the `SurrealClientLike` as a positional argument — which makes it reusable in any other surrealdb.js-backed code that already holds a client.
|
||||
|
||||
## Features
|
||||
|
||||
- `BEGIN TRANSACTION` / `COMMIT TRANSACTION` happy path
|
||||
- `CANCEL TRANSACTION` rollback on caller-thrown errors with the rollback errors deliberately swallowed
|
||||
- Surfaces a `DatabaseLike` to the caller callback so it can call `query`, `execute`, and even nested `transaction` (which flattens onto the same handle)
|
||||
- `SurrealError` instances thrown inside `fn` pass through verbatim
|
||||
- Other thrown values are normalised as `SurrealError(SURREAL.TRANSACTION_FAILED)` with the original preserved as `cause`
|
||||
- Honours `AbortSignal` at entry
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { runSurrealTransaction } from "@suaveplan/surrealdb/adapter/transaction";
|
||||
|
||||
await runSurrealTransaction(client, async (tx) => {
|
||||
await tx.query("CREATE user CONTENT { name: 'Alice' };");
|
||||
await tx.execute("UPDATE counter SET value += 1;");
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { runSurrealTransaction } from "@suaveplan/surrealdb/adapter/transaction";
|
||||
import { SurrealError, SURREAL_CODES } from "@suaveplan/surrealdb";
|
||||
|
||||
try {
|
||||
const result = await runSurrealTransaction(
|
||||
client,
|
||||
async (tx) => {
|
||||
const rows = await tx.query<{ id: string; ok: boolean }>(
|
||||
"SELECT * FROM staging;"
|
||||
);
|
||||
if (rows.some((r) => !r.ok)) {
|
||||
throw new Error("rollback");
|
||||
}
|
||||
return rows.length;
|
||||
},
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
console.log("committed", result);
|
||||
} catch (err) {
|
||||
if (err instanceof SurrealError && err.code === SURREAL_CODES.TRANSACTION_FAILED) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `runSurrealTransaction<T>(client, fn, opts?): Promise<T>`
|
||||
|
||||
- `client` — a `SurrealClientLike`
|
||||
- `fn` — receives a `DatabaseLike` and returns `Promise<T>`
|
||||
- `opts?.signal` — optional `AbortSignal` checked once before `BEGIN TRANSACTION`
|
||||
|
||||
Throws `SurrealError(TRANSACTION_FAILED)` for any non-`SurrealError` thrown by the body or the surrounding driver calls; passes existing `SurrealError`s through unchanged.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `CANCEL TRANSACTION` call is wrapped in `.catch(() => undefined)`. The original caller-thrown error must propagate even if the rollback itself fails — masking it would hide the root cause that triggered the rollback.
|
||||
- Nested `transaction()` calls inside `fn` deliberately reuse the same `DatabaseLike` rather than nesting a real savepoint. SurrealDB does not support nested transactions; the contract on `@suaveplan/db.DatabaseLike` still requires the method to exist, so we make it a flat pass-through and the outer transaction guarantees atomicity for everything inside.
|
||||
- `SurrealError` instances raised by `fn` bypass the `TRANSACTION_FAILED` wrapping so callers can switch on their own pre-existing error codes (`NOT_CONNECTED`, `RECORD_NOT_FOUND`, etc.).
|
||||
- The function is intentionally free of OTel instrumentation here — `SurrealAdapter.transaction` is the caller and the spans that exist higher up cover the call.
|
||||
@@ -0,0 +1,84 @@
|
||||
# connection
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Manages the lifecycle of SurrealDB driver connections: creating new connections via a `SurrealClientFactory`, signing in, selecting namespace and database, and maintaining a module-level singleton registry. The singleton pattern is essential for long-lived server processes that should reuse a single authenticated connection across requests rather than reconnecting on every operation.
|
||||
|
||||
The factory injection point (`SurrealClientFactory`) decouples this module from the `surrealdb` npm package, allowing the connection logic to be tested end-to-end without a running SurrealDB instance.
|
||||
|
||||
## Features
|
||||
|
||||
- `getDefaultConfig()` — returns development-friendly defaults (localhost, root/root)
|
||||
- `createConnection()` — connects, signs in, and selects namespace/database in one call
|
||||
- `connectWith()` — returns a cached singleton or creates one
|
||||
- `hasSurreal()` — inspects the singleton registry
|
||||
- `closeSurreal()` — disconnects and removes a named singleton
|
||||
- `closeAllSurreal()` — disconnects all registered singletons
|
||||
- AbortSignal support for early cancellation
|
||||
- Optional `LoggerLike` for structured connection lifecycle logs
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createConnection, getDefaultConfig } from "@suaveplan/surrealdb/connection/connection";
|
||||
import { Surreal } from "surrealdb";
|
||||
|
||||
const db = await createConnection(getDefaultConfig(), () => new Surreal());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
closeSurreal,
|
||||
connectWith,
|
||||
hasSurreal,
|
||||
} from "@suaveplan/surrealdb/connection/connection";
|
||||
import { Surreal } from "surrealdb";
|
||||
|
||||
const KEY = "primary";
|
||||
|
||||
async function getDb() {
|
||||
return connectWith(KEY, { url: "wss://db.prod.example.com", namespace: "app", database: "main", auth: { username: "app", password: process.env.DB_PASS! } }, () => new Surreal());
|
||||
}
|
||||
|
||||
// Later, on graceful shutdown:
|
||||
if (hasSurreal(KEY)) {
|
||||
await closeSurreal(KEY);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getDefaultConfig(): SurrealDBConfig`
|
||||
|
||||
Returns `{ url: "ws://localhost:8000", namespace: "test", database: "test", auth: { username: "root", password: "root" } }`.
|
||||
|
||||
### `createConnection(config, factory, opts?): Promise<SurrealClientLike>`
|
||||
|
||||
Merges config with defaults, validates via Zod, connects, signs in, and selects namespace/database.
|
||||
|
||||
**Throws:** `SurrealError(CONNECTION_FAILED)` | `SurrealError(AUTH_FAILED)`.
|
||||
|
||||
### `connectWith(key, config, factory, opts?): Promise<SurrealClientLike>`
|
||||
|
||||
Returns cached singleton or creates via `createConnection`.
|
||||
|
||||
### `hasSurreal(key: string): boolean`
|
||||
|
||||
Returns `true` if a singleton is registered under `key`.
|
||||
|
||||
### `closeSurreal(key: string): Promise<void>`
|
||||
|
||||
Disconnects and removes the named singleton.
|
||||
|
||||
### `closeAllSurreal(): Promise<void>`
|
||||
|
||||
Disconnects all singletons. Aggregates errors.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module-level `singletonRegistry` Map is safe in Bun's single-threaded model. The `connectWith` function checks for an existing entry before calling `createConnection`, which prevents duplicate connections when called concurrently from different async call stacks in the same tick.
|
||||
73
docs/persistence/database/surrealdb/src/errors/error.md
Normal file
73
docs/persistence/database/surrealdb/src/errors/error.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the `SurrealError` class and the `SURREAL_CODES` registry for all structured errors emitted by `@suaveplan/surrealdb`. Every throw site in the package uses one of these stable codes so callers can distinguish failure modes without fragile string matching against error messages. Extending `SuaveplanError` ensures compatibility with cause-chain traversal, wire serialisation, and OTel span-event adapters provided by `@suaveplan/error`.
|
||||
|
||||
The `mapSurrealCode` helper normalises raw driver exceptions into the stable code set, enabling a consistent error surface even when the underlying `surrealdb` driver changes its error messages between releases.
|
||||
|
||||
## Features
|
||||
|
||||
- Ten stable `SURREAL.*` codes covering connection, authentication, query, transaction, timeout, version, schema, init, and record-not-found failure modes
|
||||
- `SurrealError` extends `SuaveplanError<SurrealCode>` with a public constructor
|
||||
- `mapSurrealCode(err)` heuristically maps raw errors by message substring to the closest stable code
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SurrealError, SURREAL_CODES } from "@suaveplan/surrealdb/errors/error";
|
||||
|
||||
throw new SurrealError(SURREAL_CODES.NOT_CONNECTED, "adapter is not connected");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { mapSurrealCode, SurrealError, SURREAL_CODES } from "@suaveplan/surrealdb/errors/error";
|
||||
|
||||
try {
|
||||
await db.query("SELECT * FROM missing");
|
||||
} catch (raw) {
|
||||
const code = mapSurrealCode(raw);
|
||||
throw new SurrealError(code, `query failed: ${raw instanceof Error ? raw.message : String(raw)}`, {
|
||||
cause: raw instanceof Error ? raw : undefined,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SURREAL_CODES`
|
||||
|
||||
`Record<string, SurrealCode>` — stable code dictionary produced by `defineCodes("SURREAL", [...])`.
|
||||
|
||||
| Key | Value |
|
||||
|---|---|
|
||||
| `CONNECTION_FAILED` | `"SURREAL.CONNECTION_FAILED"` |
|
||||
| `NOT_CONNECTED` | `"SURREAL.NOT_CONNECTED"` |
|
||||
| `QUERY_FAILED` | `"SURREAL.QUERY_FAILED"` |
|
||||
| `AUTH_FAILED` | `"SURREAL.AUTH_FAILED"` |
|
||||
| `TIMEOUT` | `"SURREAL.TIMEOUT"` |
|
||||
| `VERSION_UNSUPPORTED` | `"SURREAL.VERSION_UNSUPPORTED"` |
|
||||
| `SCHEMA_LOAD_FAILED` | `"SURREAL.SCHEMA_LOAD_FAILED"` |
|
||||
| `INIT_FAILED` | `"SURREAL.INIT_FAILED"` |
|
||||
| `TRANSACTION_FAILED` | `"SURREAL.TRANSACTION_FAILED"` |
|
||||
| `RECORD_NOT_FOUND` | `"SURREAL.RECORD_NOT_FOUND"` |
|
||||
|
||||
### `SurrealError`
|
||||
|
||||
```ts
|
||||
class SurrealError extends SuaveplanError<SurrealCode>
|
||||
constructor(code: SurrealCode, message: string, options?: SuaveplanErrorOptions)
|
||||
```
|
||||
|
||||
### `mapSurrealCode(err: unknown): SurrealCode`
|
||||
|
||||
Maps an unknown thrown value to the closest `SurrealCode` by heuristic substring match. Falls back to `SURREAL.QUERY_FAILED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Codes are registered at import time via `defineCodes`, which stamps them into the global `@suaveplan/error` code registry. The `SurrealError` public constructor widens `SuaveplanError`'s protected constructor and normalises empty/whitespace messages to `SurrealError(<code>)` so log surfaces never render an empty `Error:` line — the body does real work, so no static-analysis suppression is needed.
|
||||
70
docs/persistence/database/surrealdb/src/init/init.md
Normal file
70
docs/persistence/database/surrealdb/src/init/init.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# init
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `initDatabase` and `createInitializer` for applying SurrealQL schema files to a connected database. The init subsystem bridges schema discovery (`@suaveplan/surrealdb/schema/discovery`) with an active `SurrealClientLike` instance, executing schema files sequentially with optional before/after hooks and dry-run support.
|
||||
|
||||
This is the primary entry point for bootstrapping a SurrealDB database from a set of `.surql` or `.sql` files. It handles partial failure gracefully: failed files are collected in the `errors` array rather than aborting the entire run, unless all files fail.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts explicit `schemas` array or auto-discovers from `schemaDir`
|
||||
- Applies `filter` predicate if provided
|
||||
- Runs `SchemaHook.before` and `after` callbacks around each file
|
||||
- Dry-run mode: discovers and reads files but skips execution
|
||||
- Returns `InitResult` with `applied`, `skipped`, and `errors` arrays
|
||||
- `createInitializer` binds options into a reusable function
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { initDatabase } from "@suaveplan/surrealdb/init/init";
|
||||
|
||||
const result = await initDatabase(db, {
|
||||
schemaDir: "./db/schema",
|
||||
});
|
||||
console.log(`Applied ${result.applied.length} schemas`);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createInitializer } from "@suaveplan/surrealdb/init/init";
|
||||
|
||||
const init = createInitializer({
|
||||
schemaDir: "./db/schema",
|
||||
filter: (f) => !f.name.includes("seed"),
|
||||
hooks: [{ before: async (f) => console.log("Applying", f.name) }],
|
||||
dryRun: process.env.DRY_RUN === "1",
|
||||
});
|
||||
|
||||
await init(db);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `initDatabase(db, options?): Promise<InitResult>`
|
||||
|
||||
Applies schema files to `db`. Returns `{ applied, skipped, errors }`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `db` — connected `SurrealClientLike` instance
|
||||
- `options.schemas` — explicit file list (takes priority over `schemaDir`)
|
||||
- `options.schemaDir` — directory to discover schemas from
|
||||
- `options.filter` — predicate to exclude files
|
||||
- `options.hooks` — `SchemaHook[]` with optional `before`/`after` callbacks
|
||||
- `options.dryRun` — if `true`, skips execution and adds files to `skipped`
|
||||
|
||||
**Throws:** `SurrealError(SURREAL.INIT_FAILED)` if all files fail and none were applied.
|
||||
|
||||
### `createInitializer(options): (db) => Promise<InitResult>`
|
||||
|
||||
Returns a bound function that calls `initDatabase(db, options)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Files are applied sequentially, not in parallel, because schema definitions typically have ordering dependencies (tables before indexes, tables before events). The `dryRun` flag still runs hooks, which allows pre-flight validation hooks to run without mutating the database state.
|
||||
75
docs/persistence/database/surrealdb/src/schema/discovery.md
Normal file
75
docs/persistence/database/surrealdb/src/schema/discovery.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# discovery
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides file-system-based schema discovery for SurrealDB schema files (`.surql`, `.sql`). Schema discovery is the first step of the database initialisation workflow: given a directory path, `discoverSchemas` enumerates matching files, orders them according to `DEFAULT_SCHEMA_ORDER`, and returns typed `SchemaFile` descriptors. The `filterSchemas` and `getSchemasToLoad` helpers compose discovery with predicate filtering for conditional loading (e.g. skipping seed files in production).
|
||||
|
||||
## Features
|
||||
|
||||
- `getDefaultSchemaDir()` — returns conventional `./db/schema` path
|
||||
- `discoverSchemas()` — enumerates `.surql`/`.sql` files, optionally recursive
|
||||
- `sortSchemaFiles()` — orders files by `DEFAULT_SCHEMA_ORDER` then alphabetically
|
||||
- `filterSchemas()` — applies a `SchemaFilter` predicate to a file list
|
||||
- `getSchemasToLoad()` — discovery + filtering in one call
|
||||
- `readSchemaContent()` — reads file content with caching of pre-populated `content` field
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { discoverSchemas, readSchemaContent } from "@suaveplan/surrealdb/schema/discovery";
|
||||
|
||||
const files = await discoverSchemas("./db/schema");
|
||||
for (const file of files) {
|
||||
const content = await readSchemaContent(file);
|
||||
console.log(file.name, content.length, "bytes");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { getSchemasToLoad } from "@suaveplan/surrealdb/schema/discovery";
|
||||
|
||||
const schemas = await getSchemasToLoad(
|
||||
"./db/schema",
|
||||
(f) => !f.name.includes("seed"), // skip seeds in production
|
||||
{ extensions: [".surql"], recursive: true }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getDefaultSchemaDir(): string`
|
||||
|
||||
Returns `path.join(process.cwd(), "db", "schema")`.
|
||||
|
||||
### `discoverSchemas(dir, options?): Promise<SchemaFile[]>`
|
||||
|
||||
Reads `dir` for files with matching extensions. Returns sorted `SchemaFile[]`.
|
||||
|
||||
**Throws:** `SurrealError(SURREAL.SCHEMA_LOAD_FAILED)` if directory read fails.
|
||||
|
||||
### `sortSchemaFiles(files): SchemaFile[]`
|
||||
|
||||
Pure. Orders by `DEFAULT_SCHEMA_ORDER` membership then lexicographically.
|
||||
|
||||
### `filterSchemas(files, filter): SchemaFile[]`
|
||||
|
||||
Pure. Returns `files.filter(filter)`.
|
||||
|
||||
### `getSchemasToLoad(dir, filter?, options?): Promise<SchemaFile[]>`
|
||||
|
||||
Combines `discoverSchemas` and `filterSchemas`.
|
||||
|
||||
### `readSchemaContent(file): Promise<string>`
|
||||
|
||||
Returns `file.content` if set, otherwise reads from `file.path`.
|
||||
|
||||
**Throws:** `SurrealError(SURREAL.SCHEMA_LOAD_FAILED)` on read failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Schema ordering uses `DEFAULT_SCHEMA_ORDER` from the constants module. Files matching known schema names (`"schema"`, `"tables"`, `"indexes"`, etc.) are placed first in that order; unrecognised names follow alphabetically. This matches the conventional SurrealDB schema application order where table definitions must precede index definitions.
|
||||
78
docs/persistence/database/surrealdb/src/schemas/schemas.md
Normal file
78
docs/persistence/database/surrealdb/src/schemas/schemas.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides Zod schemas for all public option types in `@suaveplan/surrealdb`. Schema validation runs at the boundaries where user-provided configuration enters the package, catching misconfigured URLs, missing credentials, or malformed init options before they reach the driver. Using Zod allows the same schema to generate TypeScript types via `z.infer`, keeping type definitions and runtime validation in sync.
|
||||
|
||||
Schemas are exported independently so consumers can use them in their own validation pipelines without importing full adapter classes.
|
||||
|
||||
## Features
|
||||
|
||||
- `AuthCredentialsSchema` — validates username and password fields
|
||||
- `SurrealDBConfigSchema` — validates full connection config including URL format
|
||||
- `DiscoverOptionsSchema` — validates schema discovery options (extensions, recursive)
|
||||
- `SchemaFileSchema` — validates individual schema file descriptors
|
||||
- `InitOptionsSchema` — validates database init options
|
||||
- `AdvancedInitOptionsSchema` — extends init options with parallel and timeout fields
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SurrealDBConfigSchema } from "@suaveplan/surrealdb/schemas/schemas";
|
||||
|
||||
const config = SurrealDBConfigSchema.parse({
|
||||
url: "ws://localhost:8000",
|
||||
namespace: "prod",
|
||||
database: "app",
|
||||
auth: { username: "root", password: "secret" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
InitOptionsSchema,
|
||||
SurrealDBConfigSchema,
|
||||
} from "@suaveplan/surrealdb/schemas/schemas";
|
||||
|
||||
// Validate and merge defaults
|
||||
const opts = InitOptionsSchema.parse({ dryRun: true });
|
||||
const cfg = SurrealDBConfigSchema.safeParse(rawInput);
|
||||
if (!cfg.success) {
|
||||
console.error("Invalid SurrealDB config:", cfg.error.flatten());
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AuthCredentialsSchema`
|
||||
|
||||
`z.object({ username: z.string().min(1), password: z.string().min(1) })`
|
||||
|
||||
### `SurrealDBConfigSchema`
|
||||
|
||||
Validates `url` (URL format), `namespace`, `database`, `auth`.
|
||||
|
||||
### `DiscoverOptionsSchema`
|
||||
|
||||
Optional `extensions: string[]` and `recursive: boolean`.
|
||||
|
||||
### `SchemaFileSchema`
|
||||
|
||||
Required `name` and `path`, optional `content`.
|
||||
|
||||
### `InitOptionsSchema`
|
||||
|
||||
Optional `schemaDir`, `schemas`, `dryRun`, `parallel`, `timeout`. Strict — no extra keys.
|
||||
|
||||
### `AdvancedInitOptionsSchema`
|
||||
|
||||
Extends `InitOptionsSchema` with `parallel` and `timeout` fields.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All schemas use `.strict()` where appropriate to reject extra keys and surface accidental typos in configuration objects early. URL validation in `SurrealDBConfigSchema` uses `z.string().url()`, which accepts both `ws://` and `wss://` URLs as Zod v4 validates them as valid URL schemes.
|
||||
@@ -0,0 +1,68 @@
|
||||
# singleton
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Manages adapter-level singletons for `SurrealAdapter` instances. Long-lived server processes typically need a single authenticated, pre-connected `SurrealAdapter` that is reused across all requests. The singleton registry prevents duplicate connections from being opened when multiple modules call `getSurrealSingleton` concurrently during startup.
|
||||
|
||||
The registry is module-scoped (not global) to avoid cross-test contamination when the module is imported in test suites.
|
||||
|
||||
## Features
|
||||
|
||||
- `getSurrealSingleton()` — creates and connects on first call, returns cached thereafter
|
||||
- `closeSurrealSingleton()` — disconnects and removes a named singleton
|
||||
- `hasSurrealSingleton()` — inspects the registry without side effects
|
||||
- `closeAllSurrealSingletons()` — bulk disconnection with error aggregation
|
||||
- Named registries: multiple adapters can coexist under different string keys
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getSurrealSingleton, closeSurrealSingleton } from "@suaveplan/surrealdb/singleton/singleton";
|
||||
|
||||
const adapter = await getSurrealSingleton({ url: "ws://localhost:8000", namespace: "prod", database: "app", username: "root", password: "root" });
|
||||
const users = await adapter.selectAll("user");
|
||||
|
||||
// On shutdown:
|
||||
await closeSurrealSingleton();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
closeAllSurrealSingletons,
|
||||
getSurrealSingleton,
|
||||
} from "@suaveplan/surrealdb/singleton/singleton";
|
||||
|
||||
// Multiple named singletons
|
||||
const read = await getSurrealSingleton({ url: "ws://read.db:8000", ... }, "read-replica");
|
||||
const write = await getSurrealSingleton({ url: "ws://write.db:8000", ... }, "primary");
|
||||
|
||||
// Graceful shutdown
|
||||
await closeAllSurrealSingletons();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getSurrealSingleton(options?, name?, clientFactory?): Promise<SurrealAdapter>`
|
||||
|
||||
Returns cached or newly-connected `SurrealAdapter`. Default name: `"surreal:default"`.
|
||||
|
||||
### `closeSurrealSingleton(name?): Promise<void>`
|
||||
|
||||
Disconnects and removes the named singleton. No-op if not registered.
|
||||
|
||||
### `hasSurrealSingleton(name?): boolean`
|
||||
|
||||
Returns `true` if a singleton is registered under `name`.
|
||||
|
||||
### `closeAllSurrealSingletons(): Promise<void>`
|
||||
|
||||
Disconnects all singletons. Collects errors and throws `AggregateError` if any disconnection fails.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `clientFactory` parameter is forwarded to `new SurrealAdapter(options, undefined, clientFactory)` to enable injection in tests. The registry Map is module-scoped, not process-global, which means each test file that imports the module gets an isolated registry when tests run in separate worker forks.
|
||||
80
docs/persistence/database/surrealdb/src/types/types.md
Normal file
80
docs/persistence/database/surrealdb/src/types/types.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Centralises all shared TypeScript types and interfaces used throughout `@suaveplan/surrealdb`. Keeping these in one types file prevents circular imports between implementation modules, enables tree-shaking of runtime code, and gives consumers a single import path for type-level constructs without pulling in any driver runtime.
|
||||
|
||||
The file deliberately imports nothing from `surrealdb` to stay free of runtime dependencies and to allow the `SurrealClientLike` injection interface to be implemented in tests without the real driver.
|
||||
|
||||
## Features
|
||||
|
||||
- `SurrealDBConfig` — primary connection configuration block
|
||||
- `SurrealClientLike` — driver injection interface enabling DI-based testing
|
||||
- `SurrealClientFactory` — factory function type for DI
|
||||
- `SurrealTransaction` — scoped transaction handle interface
|
||||
- `VersionInfo` / `ConnectionState` — lifecycle and version metadata
|
||||
- `SchemaFile` / `SchemaFilter` / `SchemaHook` — schema discovery types
|
||||
- `InitOptions` / `AdvancedInitOptions` / `InitResult` / `DbConfig` — init subsystem types
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SurrealDBConfig } from "@suaveplan/surrealdb/types/types";
|
||||
|
||||
const config: SurrealDBConfig = {
|
||||
url: "ws://localhost:8000",
|
||||
namespace: "prod",
|
||||
database: "app",
|
||||
auth: { username: "root", password: "secret" },
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SurrealClientFactory,
|
||||
SurrealClientLike,
|
||||
} from "@suaveplan/surrealdb/types/types";
|
||||
|
||||
// Implement a fake for testing
|
||||
class FakeSurrealClient implements SurrealClientLike {
|
||||
async connect(_url: string): Promise<void> {}
|
||||
async use(_opts: { namespace: string; database: string }): Promise<void> {}
|
||||
async signin(_creds: { username: string; password: string }): Promise<string> { return "fake-token"; }
|
||||
async query<T = unknown>(_sql: string, _vars?: Record<string, unknown>): Promise<T[]> { return [] as T[]; }
|
||||
async close(): Promise<void> {}
|
||||
async version(): Promise<string> { return "surrealdb-1.0.0"; }
|
||||
}
|
||||
|
||||
const factory: SurrealClientFactory = () => new FakeSurrealClient();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SurrealDBConfig`
|
||||
|
||||
Full connection configuration. All fields required except through partial usage.
|
||||
|
||||
### `SurrealClientLike`
|
||||
|
||||
Injection interface for the driver. Implement for testing or alternative backends.
|
||||
|
||||
### `SurrealTransaction`
|
||||
|
||||
Handle passed to `SurrealAdapter.transaction()` callbacks. Exposes `query` and `execute`.
|
||||
|
||||
### `VersionInfo`
|
||||
|
||||
`{ version, major, minor, patch, prerelease? }` — parsed semver from `db.version()`.
|
||||
|
||||
### `ConnectionState`
|
||||
|
||||
Union: `"disconnected" | "connecting" | "connected" | "authenticated" | "error"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Types are intentionally split from implementation files to allow the tsup/vite build to tree-shake them from runtime bundles where they are only consumed as TypeScript constructs. The `SurrealClientLike` interface mirrors only the subset of the `surrealdb` driver API that `SurrealAdapter` actually calls — this keeps the fake implementation surface small.
|
||||
@@ -0,0 +1,65 @@
|
||||
# version-detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `parseVersion` and `detectVersion` for resolving the version of a running SurrealDB instance. Version detection is critical for adapting query patterns and feature availability across the supported major versions (1, 2, 3). By centralising this logic, the adapter and init subsystems can make version-conditional decisions without re-parsing the raw version string in multiple places.
|
||||
|
||||
`parseVersion` is a pure function suitable for property-based testing. `detectVersion` wraps the async `db.version()` driver call with structured error handling.
|
||||
|
||||
## Features
|
||||
|
||||
- Strips the `"surrealdb-"` prefix that the official driver prepends to version strings
|
||||
- Parses `major.minor.patch[-prerelease]` semver format
|
||||
- Validates that the major version is within the supported range (1–3)
|
||||
- Returns a typed `VersionInfo` object
|
||||
- Propagates cause-chain on query failures
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseVersion } from "@suaveplan/surrealdb/utils/version-detector";
|
||||
|
||||
const info = parseVersion("surrealdb-2.1.0");
|
||||
// { version: "2.1.0", major: 2, minor: 1, patch: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detectVersion } from "@suaveplan/surrealdb/utils/version-detector";
|
||||
import type { SurrealClientLike } from "@suaveplan/surrealdb/types/types";
|
||||
|
||||
async function checkCompatibility(db: SurrealClientLike): Promise<void> {
|
||||
const info = await detectVersion(db);
|
||||
if (info.major < 2) {
|
||||
console.warn("SurrealDB v1 detected — some features are unavailable");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseVersion(raw: string): VersionInfo`
|
||||
|
||||
Pure function. Parses a raw version string. Throws `SurrealError(VERSION_UNSUPPORTED)` for unparseable strings or unsupported major versions.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `raw` — raw version string from the driver, e.g. `"surrealdb-2.1.0"` or `"1.5.3-beta.1"`
|
||||
|
||||
**Returns:** `VersionInfo` with `version`, `major`, `minor`, `patch`, and optional `prerelease`.
|
||||
|
||||
**Throws:** `SurrealError(SURREAL.VERSION_UNSUPPORTED)` if the string cannot be parsed or the major version is outside [1, 3].
|
||||
|
||||
### `detectVersion(db: SurrealClientLike): Promise<VersionInfo>`
|
||||
|
||||
Async. Calls `db.version()` and delegates to `parseVersion`.
|
||||
|
||||
**Throws:** `SurrealError(SURREAL.QUERY_FAILED)` if the driver call throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The regex anchors to the beginning of the string after prefix stripping to handle prerelease suffixes like `-beta.1` or `-rc.2`. The `MIN_SUPPORTED_MAJOR` and `MAX_SUPPORTED_MAJOR` constants are read from the `constants/index.ts` module rather than hard-coded, making the supported range easy to update when new major versions are released.
|
||||
13
docs/persistence/search/README.md
Normal file
13
docs/persistence/search/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# `search`
|
||||
|
||||
Full-text search, BM25, fuzzy matching, and faceted search.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `search`
|
||||
|
||||
1 package.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/search`](./search/README.md) | Full-text and fuzzy search primitives with in-memory BM25 engine, trigram fuzzy matching, faceted search, and pagination support. |
|
||||
<!-- end:auto-docs -->
|
||||
1
docs/persistence/search/_intro.md
Normal file
1
docs/persistence/search/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Full-text search, BM25, fuzzy matching, and faceted search.
|
||||
54
docs/persistence/search/search/README.md
Normal file
54
docs/persistence/search/search/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# @suaveplan/search - Full-text and fuzzy search primitives with in-memory BM25 engine, trigram fuzzy matching, faceted search, and pagination support
|
||||
|
||||
## Summary
|
||||
|
||||
Full-text and fuzzy search primitives with in-memory BM25 engine, trigram fuzzy matching, faceted search, and pagination support.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [memory-engine](./src/engine/memory-engine/memory-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [memory-engine](./src/engine/memory-engine/memory-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/search-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [search-error](./src/errors/search-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/search.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [search.schemas](./src/schemas/search.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [scorer](./src/text/scorer/scorer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [scorer](./src/text/scorer/scorer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [tokenizer](./src/text/tokenizer/tokenizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [tokenizer](./src/text/tokenizer/tokenizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [trigram](./src/text/trigram/trigram.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [trigram](./src/text/trigram/trigram.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [memory-engine](./src/engine/memory-engine/memory-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/search-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [search-error](./src/errors/search-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/search.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [search.schemas](./src/schemas/search.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [scorer](./src/text/scorer/scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [scorer](./src/text/scorer/scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokenizer](./src/text/tokenizer/tokenizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokenizer](./src/text/tokenizer/tokenizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trigram](./src/text/trigram/trigram.md) — <!-- begin:auto-docs --> _(no submodules)_- [trigram](./src/text/trigram/trigram.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-engine](./src/engine/memory-engine/memory-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/search-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [search-error](./src/errors/search-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/search.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [search.schemas](./src/schemas/search.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [scorer](./src/text/scorer/scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [scorer](./src/text/scorer/scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokenizer](./src/text/tokenizer/tokenizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokenizer](./src/text/tokenizer/tokenizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trigram](./src/text/trigram/trigram.md) — <!-- begin:auto-docs --> _(no submodules)_- [trigram](./src/text/trigram/trigram.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-engine](./src/engine/memory-engine/memory-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/search-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [search-error](./src/errors/search-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/search.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [search.schemas](./src/schemas/search.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [scorer](./src/text/scorer/scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [scorer](./src/text/scorer/scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokenizer](./src/text/tokenizer/tokenizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokenizer](./src/text/tokenizer/tokenizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trigram](./src/text/trigram/trigram.md) — <!-- begin:auto-docs --> _(no submodules)_- [trigram](./src/text/trigram/trigram.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-engine](./src/engine/memory-engine/memory-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/search-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [search-error](./src/errors/search-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/search.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [search.schemas](./src/schemas/search.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [scorer](./src/text/scorer/scorer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [scorer](./src/text/scorer/scorer.md) — <!-- begin:auto-docs --> _(no submodules)_- [tokenizer](./src/text/tokenizer/tokenizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [tokenizer](./src/text/tokenizer/tokenizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [trigram](./src/text/trigram/trigram.md) — <!-- begin:auto-docs --> _(no submodules)_ - [trigram](./src/text/trigram/trigram.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Modules
|
||||
|
||||
- [MemorySearchEngine](src/engine/memory-engine/memory-engine.md) — in-memory BM25 search engine
|
||||
- [Tokenizer](src/text/tokenizer/tokenizer.md) — text tokenization and stemming
|
||||
- [Trigram](src/text/trigram/trigram.md) — trigram-based fuzzy similarity
|
||||
- [Scorer](src/text/scorer/scorer.md) — BM25 and TF-IDF scoring functions
|
||||
- [SearchError](src/errors/search-error.md) — structured errors with stable codes
|
||||
- [Schemas](src/schemas/search.schemas.md) — Zod schemas for all query types
|
||||
- [Types](src/types/search.types.md) — shared TypeScript interfaces
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createMemorySearchEngine } from "@suaveplan/search";
|
||||
|
||||
interface Product { id: string; name: string; price: number }
|
||||
|
||||
const engine = createMemorySearchEngine<Product>({
|
||||
fields: [
|
||||
{ name: "name", type: "text", searchable: true },
|
||||
{ name: "price", type: "number", sortable: true },
|
||||
],
|
||||
});
|
||||
|
||||
await engine.index([
|
||||
{ id: "1", name: "Laptop Pro", price: 999 },
|
||||
{ id: "2", name: "Laptop Air", price: 799 },
|
||||
]);
|
||||
|
||||
const result = await engine.search({ text: "laptop" });
|
||||
console.log(result.hits.length); // 2
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# memory-engine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `createMemorySearchEngine`, an in-memory implementation of `SearchEngine` backed by a term-frequency inverted index and BM25 scoring. Designed for single-process use, testing, and small-to-medium datasets (up to ~100k documents). Supports full-text search, structured filters, fuzzy trigram matching, faceted aggregations, and keyword highlighting.
|
||||
|
||||
## Features
|
||||
|
||||
- Inverted index built from tokenized searchable fields
|
||||
- BM25 scoring (k1=1.2, b=0.75) for relevance ranking
|
||||
- Trigram fuzzy matching with configurable threshold
|
||||
- Faceted search: bucket counts for `facetable` fields
|
||||
- Highlight extraction with configurable tag (default `<mark>`)
|
||||
- Structured filters: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `contains`
|
||||
- Multi-field sorting with direction
|
||||
- Cursor and offset pagination
|
||||
- `suggest(prefix)` for autocomplete from indexed terms
|
||||
- OTel spans on `index`, `search`, and `suggest`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createMemorySearchEngine } from "@suaveplan/search";
|
||||
|
||||
interface Product { id: string; name: string; price: number }
|
||||
|
||||
const engine = createMemorySearchEngine<Product>({
|
||||
fields: [
|
||||
{ name: "name", type: "text", searchable: true },
|
||||
{ name: "price", type: "number", sortable: true },
|
||||
],
|
||||
});
|
||||
|
||||
await engine.index([{ id: "1", name: "Laptop Pro", price: 999 }]);
|
||||
const result = await engine.search({ text: "laptop" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const engine = createMemorySearchEngine<Product>({
|
||||
fields: [
|
||||
{ name: "name", type: "text", searchable: true, facetable: false },
|
||||
{ name: "category", type: "keyword", facetable: true },
|
||||
],
|
||||
fuzzyThreshold: 0.4,
|
||||
stemming: true,
|
||||
});
|
||||
|
||||
await engine.index(products);
|
||||
|
||||
const result = await engine.search({
|
||||
text: "laptops",
|
||||
filters: [{ field: "price", op: "lte", value: 1000 }],
|
||||
sort: [{ field: "price", direction: "asc" }],
|
||||
pagination: { type: "offset", offset: 0, limit: 20 },
|
||||
fuzzy: true,
|
||||
});
|
||||
|
||||
console.log(result.facets?.["category"]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createMemorySearchEngine<T>(options)`
|
||||
|
||||
- `options.fields: FieldConfig<T>[]` — field schema with type and searchability flags
|
||||
- `options.stemming?: boolean` — enable simple English stemming
|
||||
- `options.stopWords?: string[]` — custom stop-word list
|
||||
- `options.fuzzyThreshold?: number` — trigram similarity threshold for fuzzy (default 0.3)
|
||||
|
||||
### `SearchEngine<T>`
|
||||
|
||||
- `index(documents, options?)` — adds documents; replaces if `replace !== false`
|
||||
- `search(query, options?)` — returns `SearchResult<T>` with hits, total, facets
|
||||
- `suggest(prefix, options?)` — returns terms from inverted index starting with prefix
|
||||
- `remove(ids)` — removes documents by ID
|
||||
- `clear()` — empties the entire index
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The inverted index maps `term → Map<docId, tf>`. Field lengths per document are tracked for BM25 length normalization. Fuzzy matching runs after BM25 scoring and assigns a 50% score weight to fuzzy hits so they rank below exact matches.
|
||||
55
docs/persistence/search/search/src/errors/search-error.md
Normal file
55
docs/persistence/search/search/src/errors/search-error.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# search-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the structured error type and stable error-code registry for `@suaveplan/search`. All runtime failures from indexing, querying, cursor pagination, and field validation surface as `SearchError` instances with a machine-readable `code` field.
|
||||
|
||||
## Features
|
||||
|
||||
- `SEARCH_CODES` registry via `@suaveplan/error`'s `defineCodes` — codes namespaced `SEARCH.*`
|
||||
- `SearchError` extends `SuaveplanError` for cause-chain traversal
|
||||
- Five codes: `INDEX_FAILED`, `QUERY_INVALID`, `ENGINE_NOT_READY`, `FIELD_UNKNOWN`, `CURSOR_INVALID`
|
||||
- `SearchCode` union type for exhaustive switch statements
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SearchError, SEARCH_CODES } from "@suaveplan/search";
|
||||
|
||||
throw new SearchError(SEARCH_CODES.FIELD_UNKNOWN, `Field "price" is not indexed`);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SearchError, SEARCH_CODES } from "@suaveplan/search";
|
||||
|
||||
function handleSearchError(err: unknown): void {
|
||||
if (err instanceof SearchError) {
|
||||
if (err.code === SEARCH_CODES.CURSOR_INVALID) {
|
||||
// Reset pagination cursor
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SEARCH_CODES`
|
||||
|
||||
- `SEARCH_CODES.INDEX_FAILED` — indexing operation failed
|
||||
- `SEARCH_CODES.QUERY_INVALID` — query syntax or parameter is invalid
|
||||
- `SEARCH_CODES.ENGINE_NOT_READY` — engine has no documents indexed
|
||||
- `SEARCH_CODES.FIELD_UNKNOWN` — referenced field not in schema
|
||||
- `SEARCH_CODES.CURSOR_INVALID` — pagination cursor is malformed or expired
|
||||
|
||||
### `SearchError`
|
||||
|
||||
Extends `SuaveplanError<SearchCode>`. Constructor: `(code, message, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Codes are prefixed with `"SEARCH."` by `defineCodes`, producing strings like `"SEARCH.QUERY_INVALID"`.
|
||||
63
docs/persistence/search/search/src/schemas/search.schemas.md
Normal file
63
docs/persistence/search/search/src/schemas/search.schemas.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# search.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides Zod validation schemas for all public search query and configuration types in `@suaveplan/search`. Used at API boundaries to validate incoming queries, engine configurations, and filter specifications before they reach the in-memory engine.
|
||||
|
||||
## Features
|
||||
|
||||
- `SearchQuerySchema` — validates the full search query structure including text, filters, sort, pagination, fuzzy, and highlightTag
|
||||
- `SearchFilterSchema` — validates individual filter clauses with the 8-operator enum
|
||||
- `SortSpecSchema` — validates sort field and direction
|
||||
- `PaginationSpecSchema` — validates cursor/offset pagination with positive limit
|
||||
- `FieldConfigSchema` — validates field definitions
|
||||
- `MemorySearchEngineOptionsSchema` — validates engine construction options
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SearchQuerySchema } from "@suaveplan/search";
|
||||
|
||||
const query = SearchQuerySchema.parse({
|
||||
text: "laptop",
|
||||
filters: [{ field: "price", op: "lte", value: 1000 }],
|
||||
pagination: { type: "offset", offset: 0, limit: 20 },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemorySearchEngineOptionsSchema } from "@suaveplan/search";
|
||||
|
||||
const opts = MemorySearchEngineOptionsSchema.parse({
|
||||
fields: [{ name: "title", type: "text", searchable: true }],
|
||||
fuzzyThreshold: 0.4,
|
||||
stemming: true,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SearchQuerySchema`
|
||||
|
||||
Validates `{ text?, filters?, sort?, pagination?, fuzzy?, highlightTag? }`.
|
||||
|
||||
### `SearchFilterSchema`
|
||||
|
||||
Validates `{ field: string, op: FilterOp, value: unknown }`.
|
||||
|
||||
### `PaginationSpecSchema`
|
||||
|
||||
Validates `{ type: "cursor" | "offset", cursor?, offset?, limit: positive }`.
|
||||
|
||||
### `MemorySearchEngineOptionsSchema`
|
||||
|
||||
Validates `{ fields, stemming?, stopWords?, fuzzyThreshold? }`. Passthrough for injectable clock.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `value` field in `SearchFilterSchema` uses `z.unknown()` since filter values can be any type depending on the field type. The `fuzzy` field accepts either a boolean or `{ threshold: number }` via a Zod union.
|
||||
69
docs/persistence/search/search/src/text/scorer/scorer.md
Normal file
69
docs/persistence/search/search/src/text/scorer/scorer.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# scorer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides BM25 and TF-IDF scoring functions used by the in-memory search engine to rank search results by relevance. BM25 (Best Match 25) is the de facto standard ranking algorithm used by Elasticsearch, Solr, and Lucene. TF-IDF is provided as a simpler alternative.
|
||||
|
||||
## Features
|
||||
|
||||
- `bm25Score(options)` — Okapi BM25 with configurable k1 (default 1.2) and b (default 0.75)
|
||||
- `tfIdfScore(tf, idf)` — classic TF × IDF product
|
||||
- `computeIdf(df, n)` — IDF computation with logarithmic smoothing
|
||||
- All functions are pure and stateless; no external dependencies
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { bm25Score } from "@suaveplan/search";
|
||||
|
||||
const score = bm25Score({
|
||||
tf: 3, // term appears 3 times in document
|
||||
df: 10, // term appears in 10 documents
|
||||
n: 100, // total documents in corpus
|
||||
fieldLength: 50,
|
||||
avgFieldLength: 30,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { bm25Score, tfIdfScore, computeIdf } from "@suaveplan/search";
|
||||
|
||||
// Custom k1/b for short-field optimization
|
||||
const titleScore = bm25Score({
|
||||
tf: 1, df: 5, n: 1000, fieldLength: 5, avgFieldLength: 8,
|
||||
k1: 0.9, b: 0.4,
|
||||
});
|
||||
|
||||
// TF-IDF comparison
|
||||
const idf = computeIdf(5, 1000);
|
||||
const tfidf = tfIdfScore(2, idf);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `bm25Score(options: Bm25Options)`
|
||||
|
||||
- `tf` — term frequency
|
||||
- `df` — document frequency
|
||||
- `n` — corpus size
|
||||
- `fieldLength` — length of field being scored
|
||||
- `avgFieldLength` — average field length across corpus
|
||||
- `k1?` — saturation parameter (default 1.2)
|
||||
- `b?` — length normalization (default 0.75)
|
||||
|
||||
### `tfIdfScore(tf, idf)`
|
||||
|
||||
Returns `tf * idf`.
|
||||
|
||||
### `computeIdf(df, n)`
|
||||
|
||||
Returns `log(1 + n/df)`. Returns `0` for zero inputs.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
BM25 IDF uses the Robertson-Sparck Jones formula: `log(1 + (N - df + 0.5) / (df + 0.5))`. This variant is always non-negative. The field-length normalization parameter `b = 0.75` slightly penalises longer documents, which improves precision for titles and short-text fields.
|
||||
@@ -0,0 +1,59 @@
|
||||
# tokenizer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides text tokenization and normalization utilities for the in-memory search engine. Tokenization converts raw text into a stream of normalized tokens used to build the inverted index and to process search queries. The tokenizer handles stop-word removal, case normalization, and optional simple English stemming.
|
||||
|
||||
## Features
|
||||
|
||||
- `tokenize(text, options?)` — splits text on non-word characters, removes stop words, optionally stems
|
||||
- `normalizeToken(token)` — lowercases and trims a single token
|
||||
- `simpleStem(word)` — strips common English suffixes: `-tion`, `-ing`, `-est`, `-ed`, `-er`, `-ly`, `-s`
|
||||
- Default stop-word list of 40+ common English words
|
||||
- Case sensitivity and stop-word list are configurable per call
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { tokenize } from "@suaveplan/search";
|
||||
|
||||
const tokens = tokenize("The quick brown fox jumps over the lazy dog");
|
||||
console.log(tokens); // ["quick", "brown", "fox", "jump", ...]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { tokenize, simpleStem } from "@suaveplan/search";
|
||||
|
||||
const tokens = tokenize("Running faster than expected", {
|
||||
stemming: true,
|
||||
stopWords: ["than"],
|
||||
});
|
||||
|
||||
console.log(simpleStem("computing")); // "comput"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `tokenize(text, options?)`
|
||||
|
||||
- `options.caseSensitive?: boolean` (default `false`)
|
||||
- `options.stopWords?: string[]` — replaces default stop-word list
|
||||
- `options.stemming?: boolean` — applies `simpleStem` to each token (default `false`)
|
||||
- Returns `string[]`
|
||||
|
||||
### `normalizeToken(token)`
|
||||
|
||||
Lowercases and trims a single token. Used internally.
|
||||
|
||||
### `simpleStem(word)`
|
||||
|
||||
Returns the stem of an English word by stripping common suffixes. Words ≤3 characters are returned unchanged.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The stemmer is intentionally simple (suffix-stripping, no Snowball/Porter) to avoid external dependencies. For production use with high-accuracy stemming requirements, a custom tokenizer implementing the same interface can be passed via `MemorySearchEngineOptions`.
|
||||
51
docs/persistence/search/search/src/text/trigram/trigram.md
Normal file
51
docs/persistence/search/search/src/text/trigram/trigram.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# trigram
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides trigram-based fuzzy matching utilities for the in-memory search engine. Trigrams are overlapping 3-character substrings that capture structural similarity between strings — they are language-agnostic, robust to minor typos, and require no external libraries.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildTrigrams(text)` — generates a `Set<string>` of all 3-character substrings, padded with `##` for start/end boundary capture
|
||||
- `trigramSimilarity(a, b)` — computes Jaccard similarity (intersection / union) over the trigram sets, returning a value in `[0, 1]`
|
||||
- Case-insensitive: both functions normalize input to lowercase before processing
|
||||
- Exact matches always return `1.0`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { trigramSimilarity } from "@suaveplan/search";
|
||||
|
||||
const score = trigramSimilarity("hello", "helo");
|
||||
console.log(score); // ~0.71
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { buildTrigrams, trigramSimilarity } from "@suaveplan/search";
|
||||
|
||||
const threshold = 0.5;
|
||||
const candidates = ["hello", "help", "world", "dog"];
|
||||
const matches = candidates.filter(
|
||||
(c) => trigramSimilarity("helo", c) >= threshold
|
||||
);
|
||||
console.log(matches); // ["hello", "help"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `buildTrigrams(text)`
|
||||
|
||||
Returns `Set<string>` of all trigrams in `text` (case-insensitive, `##`-padded).
|
||||
|
||||
### `trigramSimilarity(a, b)`
|
||||
|
||||
Returns `number` in `[0, 1]`. Uses Jaccard coefficient: `|intersection| / |union|`. Returns `1` for identical strings or empty trigram sets.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `##` padding ensures that short strings (1-2 characters) still generate meaningful trigrams that capture their start and end positions. The Jaccard coefficient is symmetric: `trigramSimilarity(a, b) === trigramSimilarity(b, a)`. Complexity is O(n + m) where n and m are the trigram set sizes.
|
||||
70
docs/persistence/search/search/src/types/search.types.md
Normal file
70
docs/persistence/search/search/src/types/search.types.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# search.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared TypeScript type definitions for the `@suaveplan/search` package. Defines all public contracts: search documents, queries, filters, results, field configurations, and engine interfaces. Centralising types here prevents circular imports between the engine, text utilities, and schema modules.
|
||||
|
||||
## Features
|
||||
|
||||
- `SearchDocument` — base interface requiring `id: string` plus arbitrary fields
|
||||
- `SearchQuery` — full query structure with text, filters, sort, pagination, and fuzzy options
|
||||
- `SearchFilter` — structured field filter with 8 operators (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `contains`)
|
||||
- `SearchResult<T>` / `SearchHit<T>` — typed result container with score, highlights, and facets
|
||||
- `FieldConfig<T>` — per-field schema: type, searchable, sortable, facetable, weight
|
||||
- `MemorySearchEngineOptions<T>` — full configuration for the in-memory engine
|
||||
- `SearchEngine<T>` — interface for `index`, `search`, `suggest`, `remove`, `clear`
|
||||
- `Bm25Options` — parameters for BM25 scoring function
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SearchDocument, SearchQuery, FieldConfig } from "@suaveplan/search";
|
||||
|
||||
interface Product extends SearchDocument {
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
const field: FieldConfig<Product> = {
|
||||
name: "name",
|
||||
type: "text",
|
||||
searchable: true,
|
||||
facetable: false,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { SearchQuery, SearchFilter } from "@suaveplan/search";
|
||||
|
||||
const query: SearchQuery = {
|
||||
text: "laptop",
|
||||
filters: [{ field: "price", op: "lte", value: 1000 }],
|
||||
sort: [{ field: "price", direction: "asc" }],
|
||||
pagination: { type: "offset", offset: 0, limit: 20 },
|
||||
fuzzy: true,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SearchDocument`
|
||||
|
||||
Base interface: `{ id: string; [field: string]: unknown }`.
|
||||
|
||||
### `SearchQuery`
|
||||
|
||||
Full query: `text?`, `filters?`, `sort?`, `pagination?`, `fuzzy?`, `highlightTag?`.
|
||||
|
||||
### `FieldConfig<T>`
|
||||
|
||||
`name: keyof T & string`, `type: FieldType`, `searchable?`, `sortable?`, `facetable?`, `weight?`.
|
||||
|
||||
### `SearchEngine<T>`
|
||||
|
||||
`index(docs, opts?)`, `search(query, opts?)`, `suggest(prefix, opts?)`, `remove(ids)`, `clear()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `FilterOp` union and `FieldType` union are intentionally narrow — they define the supported operation set for the in-memory engine. Engines backed by external systems may support additional operators through extension.
|
||||
26
docs/persistence/storage/README.md
Normal file
26
docs/persistence/storage/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# `storage`
|
||||
|
||||
Storage abstractions, browser backends, S3, CDN, and URI routing.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `storage`
|
||||
|
||||
14 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/storage`](./storage/README.md) | Enterprise key-value storage abstractions for Suaveplan. Provides `AbstractStorage` (implementing `StorageLike`), `MemoryStorage` with TTL and zero-copy stream… |
|
||||
| [`@suaveplan/storage-browser-indexeddb`](./storage-browser-indexeddb/README.md) | Browser-tier `AbstractStorage` adapter backed by the W3C IndexedDB API (`globalThis.indexedDB`). Provides persistent, origin-scoped key-value storage for brows… |
|
||||
| [`@suaveplan/storage-browser-localstorage`](./storage-browser-localstorage/README.md) | Browser-tier `AbstractStorage` adapter backed by the synchronous `Storage` API (`globalThis.localStorage`). Provides namespaced, origin-scoped key-value storag… |
|
||||
| [`@suaveplan/storage-browser-opfs`](./storage-browser-opfs/README.md) | Browser-tier `AbstractStorage` adapter backed by the Origin Private File System (OPFS). Provides persistent, origin-scoped key-value storage for browser consum… |
|
||||
| [`@suaveplan/storage-cdn`](./storage-cdn/README.md) | Types-only foundation for CDN-flavoured `@suaveplan/storage` adapters. Declares `CdnAdapter` (an abstract base class extending `AbstractStorage` with `purge`,… |
|
||||
| [`@suaveplan/storage-cdn-bunnycdn`](./storage-cdn-bunnycdn/README.md) | BunnyCDN Storage Zone + Pull Zone provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`set`/`delete`/`exists`/`getMe… |
|
||||
| [`@suaveplan/storage-cdn-rackspace`](./storage-cdn-rackspace/README.md) | Rackspace Cloud Files (OpenStack Swift) origin + Rackspace CDN provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`… |
|
||||
| [`@suaveplan/storage-file`](./storage-file/README.md) | Node.js filesystem `AbstractStorage` adapter for `@suaveplan/storage`. Implements atomic writes via temp-file-then-rename, AbortSignal threaded through every a… |
|
||||
| [`@suaveplan/storage-http`](./storage-http/README.md) | Universal HTTP/CDN-backed `AbstractStorage` adapter for `@suaveplan/storage`. Reads via `GET`/`HEAD`/`Range` against any `<baseUrl><key>` origin, with optional… |
|
||||
| [`@suaveplan/storage-plugin-cache`](./storage-plugin-cache/README.md) | Read-through cache decorator for `AbstractStorage`. Wraps any `@suaveplan/storage` instance with an injectable `AbstractCache<Uint8Array>`; hit/miss/populate o… |
|
||||
| [`@suaveplan/storage-s3`](./storage-s3/README.md) | S3-compatible object storage adapter for `@suaveplan/storage`. Extends `AbstractStorage` backed by `@aws-sdk/client-s3` with presigned URL generation, multipar… |
|
||||
| [`@suaveplan/storage-uri`](./storage-uri/README.md) | Universal URI-routed dispatch over `@suaveplan` `StorageLike` adapters. |
|
||||
| [`@suaveplan/storage-uri-plugin-bundle`](./storage-uri-plugin-bundle/README.md) | Per-package `bundle://` URI scheme for `@suaveplan/storage-uri`. |
|
||||
| [`@suaveplan/storage-uri-plugin-fallback`](./storage-uri-plugin-fallback/README.md) | Universal `fallback://` URI plugin for `@suaveplan/storage-uri`. Ships ordered multi-source resolution with cycle detection, depth cap, mirror writes, list uni… |
|
||||
<!-- end:auto-docs -->
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user