Wrapper Plugins
Wrapper plugins add behavior around any IDbPlugin. Import the generic wrappers from @routier/core/plugins:
import {
BatchingDbPlugin,
CacheDbPlugin,
ConcurrencyDbPlugin,
RetryDbPlugin,
} from "@routier/core/plugins";CacheDbPlugin
new CacheDbPlugin(inner, { max: 100 })A read-through least-recently-used cache. max is the number of query results (default 100, minimum 1). A write invalidates every cached query for each schema it touches.
Use it only when stale reads from external writers are acceptable. It cannot observe a write performed by another process, tab, store, or by code that bypasses this wrapper. Non-structured-cloneable query results are returned but not cached.
RetryDbPlugin
new RetryDbPlugin(inner, {
attempts: 3,
delayMs: attempt => 50 * 2 ** (attempt - 2),
shouldRetry: (error, attempt) => isTransient(error),
})Retries failed reads only. attempts includes the initial call and defaults to 3. The default delay is 50 ms before attempt 2, then 100 ms, 200 ms, and so on. The default retries every read error; use shouldRetry to exclude permanent errors.
Writes are never retried: a generic wrapper cannot know whether a failed non-atomic batch partly landed, and repeating an identity insert can duplicate data.
ConcurrencyDbPlugin
new ConcurrencyDbPlugin(inner)Adds optimistic concurrency without changing schemas or entity types. The wrapper maintains a hidden __version field. An update to a row previously read through this wrapper includes the observed version; a stale write rejects the save with OptimisticConcurrencyError and writes nothing when the backend is transactional.
Recovery is explicit: catch the error, re-read current data, reapply the intended change, and save again.
import { OptimisticConcurrencyError } from "@routier/core/errors";
try {
await store.saveChangesAsync();
} catch (error) {
if (OptimisticConcurrencyError.is(error)) {
console.log(error.collectionName, error.conflicts);
}
}The inner plugin must enforce conditional updates. SQLite (except D1), PostgreSQL, MySQL, MongoDB, memory, browser-storage, and file-system support the contract. Dexie and PouchDB do not. Existing SQL tables need an added nullable numeric __version column; newly created tables receive it automatically.
A row attached and updated without first being read by this wrapper has no observed version and is initialized unchecked. It is protected after the next read.
BatchingDbPlugin
new BatchingDbPlugin(inner, {
isAtomic: true,
maxBatchSize: 100,
})Serializes overlapping writes. With isAtomic omitted or false it sends one queued save at a time. With isAtomic: true, overlapping saves may be coalesced into one inner write, up to maxBatchSize (default 100). It never sleeps to fill a batch; only work already waiting is grouped.
isAtomic: true is a promise made by your application: when inner.bulkPersist reports failure, none of that save was applied. SQLite, PostgreSQL, and MySQL make a save transactional. Do not enable coalescing around a plugin that can partially apply a failed batch.
Replication wrappers
@routier/replication-plugin exports higher-level wrappers:
HttpDbPluginsends queries and writes directly over HTTP.HttpTransportDbPlugintransports the plugin event protocol.HttpSwrDbPluginserves a local mirror and revalidates from HTTP.OptimisticUpdatesDbPluginserves fast optimistic values while a source persists them.PluginSyncEngineexposes source/mirror failure and acknowledgement policies.
See Replication Plugin, HTTP Transport, and Plugin Compositions.
Ordering
Read the stack from outside to inside:
new CacheDbPlugin(
new RetryDbPlugin(
new ConcurrencyDbPlugin(
new PostgresDbPlugin(config)
)
)
)Here cache hits bypass retry/concurrency reads; misses are retried, concurrency sees returned rows, and PostgreSQL performs storage. Writes invalidate the cache, pass through retry unchanged, gain a version check, then run in PostgreSQL.
Prefer the smallest stack that states the guarantees you need. Every wrapper should have a reason and an understood failure boundary.