Skip to main content

The optional shared L2

By default stalefree is a pure in-memory L1 per instance. Adding a Drizzle-backed L2 table gives you a shared tier: a fresh instance starts warm (its first get fills L1 from L2), and instances share loader work instead of each recomputing the same miss.

Each dialect subpath exports a table factory and a store:

ImportStoreDriver
@stalefree/core/sqliteSqliteCacheStorebetter-sqlite3 (synchronous)
@stalefree/core/postgresPostgresCacheStorepg via drizzle-orm/node-postgres
@stalefree/core/mysqlMysqlCacheStoremysql2

drizzle-orm is an optional peer — the root @stalefree/core import pulls no Drizzle at all and stays zero-dependency.

Add the table to your schema​

stalefreeCacheTable() returns a Drizzle table you add to your schema and migrate with drizzle-kit — the store is constructed with that exact table instance, so app and engine share byte-identical DDL:

import {PostgresCacheStore, stalefreeCacheTable} from '@stalefree/core/postgres';

// in your Drizzle schema file — drizzle-kit picks it up like any other table
export const cacheTable = stalefreeCacheTable(); // 'stalefree_cache' by default

const cache = new StalefreeCache({
store: new PostgresCacheStore(db, cacheTable),
bus,
defaultTtlMs: 30_000,
});

Every dialect uses the same shape: key (primary key), value (JSON-serialized), expires_at (absolute epoch milliseconds — an entry can move between tiers without renegotiating its remaining lifetime), and tags (a delimited list, matched with a dialect-safe LIKE … ESCAPE '!' — one mechanism, identical behaviour on all three dialects).

Then generate the migration as usual:

npx drizzle-kit generate

Postgres: make the table UNLOGGED​

Cache rows are transient, so for write-heavy workloads consider hand-editing the generated migration:

-- generated: CREATE TABLE "stalefree_cache" (…)
-- edit it by hand to: CREATE UNLOGGED TABLE "stalefree_cache" (…)

UNLOGGED skips WAL — roughly 2× write throughput — at the cost of the table being truncated on crash recovery, which for a cache is exactly right: losing the contents costs a cold cache, never correctness. drizzle-kit cannot emit UNLOGGED, hence the hand edit.

MySQL: the COLLATE utf8mb4_bin edit is REQUIRED​

MySQL 8's default collation (utf8mb4_0900_ai_ci) is case- and accent-insensitive: User:1 and user:1 would collide on the primary key, and the upsert would overwrite one entry with the other — a wrong-value serve, not mere staleness. drizzle-kit cannot emit a collation, so edit the generated migration by hand (the same convention as the Postgres UNLOGGED edit):

`key` varchar(191) COLLATE utf8mb4_bin NOT NULL

Two MySQL-specific limits to know:

  • The key column is varchar(191) — the indexable limit under utf8mb4. Keys longer than 191 characters are rejected loudly by the store at runtime (the shared validator allows up to 256; MySQL's cap is lower). The cache reports the error via onError and fails open, rather than silently never persisting to L2.
  • There is no MySQL invalidation bus in v1 — MySQL deployments use the L2 plus the socket bus on one machine, or rely on the TTL backstop across machines.

Pruning expired rows​

Expiry is enforced by the cache on read (an expired L2 row is a miss), but expired rows still occupy space until removed. Schedule store.prune(now) — it deletes every row with expires_at <= now:

setInterval(() => {
void store.prune(Date.now());
}, 60_000).unref();

Any scheduler works (a cron, @nestjs/schedule, your job runner) — the store doesn't schedule itself.

Transactional invalidation needs the store too​

On Postgres, when you use the bus's publishInTx, you must also delete the L2 rows in the same transaction with PostgresCacheStore.invalidateTagsInTx(tx, tags) — the bus message only evicts L1s, and without the paired delete every instance would refill its L1 from the stale L2 row on the next read:

await db.transaction(async (tx) => {
await tx.update(projects)...; // the business write
await store.invalidateTagsInTx(tx, tags); // L2 dies with the commit
await bus.publishInTx(tx, {tags}); // L1s evict on commit
});

See Coherence for the full recipe.

Store semantics​

  • Stores are dumb by design: they return rows as stored and every method runs a single statement, so concurrent instances never race a read-modify-write. Freshness policy lives in the cache.
  • The cache calls stores fail-open: a store error is reported to onError and the call proceeds (a broken L2 is a miss, not an outage).
  • SqliteCacheStore is fully synchronous (better-sqlite3 is); the cache awaits whatever it gets, so the seam is identical either way.
  • Tag invalidation in L2 is O(rows) per tag — the L2 is a warm-start tier, not the hot path. The hot path is L1's reverse tag index (O(affected keys)).