Skip to content

Persistence & Shared Stores

Guide 21

Ndigo is memory-first: every shared capability ships an in-process default, and external backends are explicit opt-ins. This guide covers the persistence baseline: durable sessions on LibSQL, the shared Redis cache, and cross-node WebSocket/STOMP fan-out — plus what stays deliberately deferred .

Terminal window
pnpm add @ndigo/redis @ndigo/orm-drizzle @ndigo/cache @ndigo/socket
# node only: @ndigo/redis requires Node 24+; standalone Redis 7.2+

Audience: Node application developers and operators · Prerequisites: Guides 19 and 26 · Reference app: apps/16-ndigo-redis-fanout, apps/18-ndigo-auth-playground

  • Apply the memory-first backend doctrine.
  • Persist sessions in LibSQL.
  • Share the cache through Redis.
  • Respect owned vs borrowed client lifecycle.
  • Observe backend failures instead of hiding them.
  • Capability-owned contracts, no universal store. Sessions implement SessionStore (@ndigo/auth-core), messages implement QueueMessageStore, caches implement Cache — there is no generic storage superclass and no @ndigo/store package. Add a backend where a contract already exists.
  • Memory is the default; backends are explicit. Installing @ndigo/redis never connects to anything and never changes auto-configuration.
  • Ownership is explicit. Created clients are owned (you close them); injected clients are borrowed unless ownsClient: true transfers ownership. Disposal is idempotent; disposing an adapter never closes a client another adapter uses. Stop/drain producers before disposal — disposal does not guarantee pending writes complete.
  • Failures are observed, never hidden. Backend faults are not empty history, memory fallback, or unhandled rejections; initialization failures surface on first use (operations await the original initialization promise).
  • No bean auto-registration. Container wiring is application-owned; lifecycle must bridge to the container’s Closeable teardown explicitly.

LibSQLSessionStore (@ndigo/orm-drizzle) implements the same SessionStore contract as the reference InMemorySessionStore — same kind-guarded surfaces, same rotation semantics, durable across restarts. It is the one backend the auth stack ships; the auth contract itself is unchanged.

import { LibSQLSessionStore } from '@ndigo/orm-drizzle';
import { CredentialsAuthAdapter } from '@ndigo/auth-server';
import { ScryptPasswordEncoder } from '@ndigo/auth-core/scrypt'; // Node-only (node:crypto)
const sessions = new LibSQLSessionStore({
url: 'file:./sessions.db', // or an injected { client } with intMode: 'number'
maxSessions: 512, // default 512; shared live-session cap
});
await sessions.initialize(); // explicit: provisions ndigo_sessions + expiry index
const auth = new CredentialsAuthAdapter({
userStore: undefined /* your user store */,
passwordEncoder: new ScryptPasswordEncoder({ ln: 14 }),
sessionStore: sessions, // the durable store; memory remains the default
});

Guarantees (verified in ):

  • Explicit provisioning. initialize idempotently creates only ndigo_sessions and its expiry index — never drops or migrates application tables. An application-owned migration may provision the same layout.
  • Atomic rotation. Refresh issues a new id via one conditional UPDATE … WHERE session_id = ? AND expires_at > ? — exactly one contender wins; expired or unknown ids never resurrect; the original absolute expiry is preserved and reported (no TTL reset on refresh).
  • Strict decode. Records contain only principal id/name/roles, token version, issue timestamp, absolute expiry; malformed rows reject rather than becoming silent auth misses.
  • Cap + purge. Creation purges expired rows and enforces maxSessions atomically; instances sharing a database should agree on the cap and keep clocks synchronized.
  • Lifecycle. close bridges dispose to the container’s Closeable teardown; owned clients close exactly once, borrowed clients stay usable. LibSQL close is synchronous — drain consumers first.

Namespaces: the fixed table is a database-wide namespace — isolate applications/envs by database (or by migration-owned schema), exactly like the queue store. No auth contract changes were made for persistence.

@ndigo/redis is the Node-only Redis foundation: RedisConnection lifecycle plus the capability subpaths. Root export does not load capability contracts, so the package is dependency-light for connection-only use.

import { RedisConnection } from '@ndigo/redis';
const publisher = new RedisConnection({ url: process.env.REDIS_URL! });
await publisher.connect();
// … await publisher.close(); // owned; drained, no QUIT (close, not QUIT)

Shared cache lives in @ndigo/redis/cache (RedisCacheManager / RedisCache) against the awaitable Cache contract — same API as MemoryCacheManager, saw the async cutover under . Redis values are restricted to JSON-value trees (plus a tagged whole-entry undefined); unsupported and corrupt values reject rather than silently losing data. See guide 19 §6 for the full backend policy (no per-cache eviction, server-side expiry, namespace-safe clear/size, no FLUSHDB).

Shared-store rate limiting is the same opt-in shape: @ndigo/redis/rate-limit — RedisRateLimiter — implements the RateLimiter SPI from @ndigo/rate-limit: fixed-window budgets that every process counts against (keys ndigo:rl:<namespace>:<windowMs>:<key>, windows anchored at the first request, expiry server-side by TTL). It is fail-closed by default — an unreachable store rejects the request instead of passing it unthrottled; onError: 'fail-open' logs through @ndigo/log and allows (the documented availability escape hatch). Override the platform’s RATE_LIMITER bean with it:

import { RedisConnection } from '@ndigo/redis';
import { RedisRateLimiter } from '@ndigo/redis/rate-limit';
const connection = new RedisConnection({ url: process.env.REDIS_URL! });
await connection.connect(); // borrowed; readiness is the app's contract
const shared = new RedisRateLimiter({ connection, namespace: 'prod' });
// …register `shared` under the RATE_LIMITER token (or hand it to the
// RateLimitInterceptor constructor directly)…

See guide 26 §5 for the @rateLimit metadata and the 429 + Retry-After denial.

SocketFanout (@ndigo/socket) is a capability-owned transport contract and RedisSocketFanout (@ndigo/redis/pubsub) is its live implementation: subscribe(scope, receive) / publish(scope, { key, body, headers? }) for paths, rooms, and STOMP /topic/*. Wire it through new NodeSocketSessionRegistry({ fanout }) and/or new StompBroker({ fanout }); the server must have the registry before serving, and all consumers connect before server.start.

Details — delivery semantics (live at-most-once, no replay/reconnect/global ordering), echo suppression, Redis limits, stop/shutdown ordering, and deployment policy — are in guide 42, cross-node live fan-out.

Stop producers and serving first, then close consumers, then transports, inside try/finally, before synchronous context.close:

  1. registry.close / broker.close — unsubscribes consumers
  2. fanout.close — unsubscribes its owned subscriber
  3. publisher.close — by its owner (never the fanout)
  4. sessions.close (or container Closeable teardown) after draining

Startup failure requires the same cleanup path. Container destruction recognizes Closeable; a dispose-only object is not proof of cleanup.

  • Redis 7.2+ standalone; no modules, no cluster/sentinel guarantees, no Redis 8.4-only commands. Base client @redis/client pinned at 6.3.0.
  • Namespaces are your isolation unit: cache namespaces (app:production) separate keys; pubsub namespaces ([A-Za-z0-9][A-Za-z0-9._-]{0,127}, no colon) separate channels and are not authorization — protect channels with Redis ACLs and trusted network/TLS.
  • Message limits: pubsub envelopes ≤ 1 MiB (complete UTF-8 JSON, including base64 + headers); keys 1–1024 chars; ≤ 128 headers, values ≤ 8192 chars; invalid outbound envelopes reject before I/O.
  • No localhost defaults anywhere — URLs are explicit (redis:///rediss://, file paths for LibSQL); adapters never connect automatically.

Not built, with an explicit decision and revisit triggers:

  • Distributed scheduling — no leader election, no durable job queue; the in-process TaskScheduler remains (guide 23). Revisit only when a real consumer needs at-most-once-per-schedule across workers, restart-surviving queued jobs, deployable schedule definitions, or cross-process task observation — then the precedent is ’s hook seam plus the @ndigo/redis / SQL-store patterns above.
  • Cross-node queue consumption — LibSQL /queue persistence is storage, not distributed consumption; durable pub/sub replay, offline queues, and global ordering are out of scope for the live fan-out.
  • Decisions: , , (async cutover)
  • Roadmap: docs/plans/2026-10-01_SHARED_PERSISTENCE_ROADMAP.md
  • Related guides: 15 — WebSockets (fan-out), 19 — Caching §6, 26 — Auth & Sessions
  • Packages: @ndigo/redis (README), @ndigo/orm-drizzle (README: LibSQLSessionStore, LibSQLQueueMessageStore), @ndigo/socket, @ndigo/cache