Persistence Resolution
Slingshot’s adapter model lets you write business logic once and run it against any supported database. Here’s how the dispatch works.
The shape
Section titled “The shape”Every persistence concern is expressed as RepoFactories<T>:
type RepoFactories<T> = Record<StoreType, (infra: StoreInfra) => T>;
type StoreType = 'memory' | 'redis' | 'sqlite' | 'postgres' | 'mongo';A RepoFactories<T> maps every supported store type to a factory that produces a fresh adapter. Business logic receives the adapter as T and never sees the store type.
resolveRepo()
Section titled “resolveRepo()”At startup, resolveRepo() reads the configured store type, calls the matching factory, and returns a fresh adapter:
import { resolveRepo } from '@lastshotlabs/slingshot-core';
declare const factories: import('@lastshotlabs/slingshot-core').RepoFactories<unknown>;declare const storeType: import('@lastshotlabs/slingshot-core').StoreType;declare const infra: import('@lastshotlabs/slingshot-core').StoreInfra;
const adapter = resolveRepo(factories, storeType, infra);// Returns a fresh T — no shared state, no singletonsinfra provides connection handles: getRedis(), getMongo(), getSqliteDb(), getPostgres(). Factories use them to create adapters against real connections in production and in-memory state in tests.
StoreInfra is intentionally scoped to persistence concerns only. Other bootstrap-time framework
data, such as the opaque root ws config draft and the cloned ctx.wsEndpoints map that plugins
can patch during setupPost, travels alongside infra on frameworkConfig / SlingshotContext
instead of being injected through repository factories.
buildContext() is the boundary where that app-level runtime draft is copied into context; adapter
factories never receive it through StoreInfra.
That same boundary now owns registry-backed events too. buildContext() attaches the canonical
ctx.events publisher next to ctx.bus, and ctx.destroy() emits the framework app:shutdown
event before teardown begins. Persistence factories still stay transport-agnostic; shutdown and
external delivery policy live at the context layer, not in repository resolution.
For SQLite entity work, createContextStoreInfra() installs one app-owned FIFO coordinator and
transaction provider. Standard entity factories discover the coordinator through a private Reflect
symbol and gate CRUD, list, clear, lazy initialization, and named operations. Scoped adapters get
an infrastructure view bound to the held lease, so transactions.run('sqlite', ...) and
declarative op.transaction share the same BEGIN IMMEDIATE boundary. During context destruction,
buildContext() shuts the coordinator down before closing the database, rejecting queued work
instead of leaving unresolved waiters.
Writing a set of factories
Section titled “Writing a set of factories”import type { RepoFactories } from '@lastshotlabs/slingshot-core';
interface PostAdapter { readonly kind: 'post-adapter';}declare function createMemoryPostAdapter(): PostAdapter;declare function createSqlitePostAdapter( db: ReturnType<import('@lastshotlabs/slingshot-core').StoreInfra['getSqliteDb']>,): PostAdapter;declare function createPostgresPostAdapter( db: ReturnType<import('@lastshotlabs/slingshot-core').StoreInfra['getPostgres']>,): PostAdapter;declare function createMongoPostAdapter( db: ReturnType<import('@lastshotlabs/slingshot-core').StoreInfra['getMongo']>,): PostAdapter;
export const postAdapterFactories: RepoFactories<PostAdapter> = { memory: _infra => createMemoryPostAdapter(), sqlite: infra => createSqlitePostAdapter(infra.getSqliteDb()), postgres: infra => createPostgresPostAdapter(infra.getPostgres()), mongo: infra => createMongoPostAdapter(infra.getMongo()), redis: _infra => { throw new Error('Redis is not supported for post storage'); },};If a store type doesn’t make sense for a given concern (Redis for relational data, for instance), throw at startup. Don’t silently fall back.
Using inside a package or createEntityPlugin()
Section titled “Using inside a package or createEntityPlugin()”The canonical way to wire a repo is from inside a definePackage(...) module — the package
compiler hands the resolved adapter to your entity hooks. When you drop down to the lower-level
createEntityPlugin() escape hatch directly, call resolveRepo() inside the buildAdapter
callback:
createEntityPlugin({ name: 'blog', entities: [ { config: Post, operations: postOperations.operations, buildAdapter: (storeType, infra) => resolveRepo(createEntityFactories(Post, postOperations.operations), storeType, infra), }, ],});createEntityFactories() generates a RepoFactories<EntityAdapter> from an entity definition and its operations. The adapters are pure TypeScript — no ORM required. (MongoDB and Postgres adapters use Mongoose and Drizzle internally.)
No shared state between adapter instances
Section titled “No shared state between adapter instances”Each resolveRepo() call returns a fresh adapter. Two concurrent requests don’t share state. Two parallel test suites don’t share state. That’s the factory pattern’s guarantee.
Test isolation
Section titled “Test isolation”Use ctx.clear() to reset in-memory adapters between test cases:
afterEach(() => { ctx.clear(); // wipes all in-memory stores, resets event subscriptions});ctx.destroy() is the full app cleanup path. It runs plugin teardown first, shuts down the event bus and any WS transport, then closes DB connections. Call it in afterAll or process teardown handlers.
Transaction-bound event outbox work
Section titled “Transaction-bound event outbox work”The framework transaction manager owns one internal scoped-work scheduler in
addition to scope-aware entity adapters. The governed event publisher uses that
same scheduler for delivery: 'outbox', so it cannot trust a caller-provided
store label or create a second scope registry. The scheduler authenticates the
scope, resolves the transaction-bound StoreInfra, and tracks the insert as
pending work before the publisher returns.
PostgreSQL inserts therefore use the checked-out transaction client, and SQLite inserts use the connection while its coordinator lease is held. A callback that throws, retains a scope, supplies a forged scope, or targets another store cannot leave an outbox row independently of its domain write.
After migrations and repository binding, buildContext() starts the
application-owned outbox dispatcher. Claims are short SQL transactions;
broker publication occurs after those transactions commit, and finalization
checks lease ownership. Context destruction stops and drains this worker before
shutting down the event bus or closing PostgreSQL/SQLite, then releases any
unfinished owner leases for immediate recovery.
The transactional inbox bridge is bound to the same StoreInfra after
migrations complete. Each durable named consumer opens an authentic framework
transaction, inserts its (consumer_name, event_id) receipt with conflict-safe
semantics, and invokes the handler only when that insert wins. The receipt and
all handler writes therefore commit or roll back together. Concurrent delivery
and broker redelivery skip an existing receipt, while a failed handler leaves
no receipt and can be retried safely.
The same store-bound operations layer powers event readiness, metrics, replay, and retention. Readiness reads bounded aggregate queries and cached transport health; it never waits for a broker reconnect. Hourly retention uses bounded batches and can delete only delivered outbox rows or expired inbox receipts. Operator replay changes dead rows back to pending without rewriting the stored envelope or event ID and records the actor, reason, and affected count in the replay-audit table.
See also
Section titled “See also”- Context and Registrar — how
StoreInfrais provided - Reflect Symbol DI — how entity registration flows through infra