Skip to content

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.

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.

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 singletons

infra 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.

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.)

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.

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.

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.