Skip to content

Storage and Adapter Wiring

The entity system separates your data model from your storage backend. Define the entity once, then point it at memory for development, SQLite for prototyping, or Postgres for production — the entity code stays the same.

Standard wiring checks the complete resolved entity and operation contract before opening a backend connection. If the selected backend cannot honor required semantics, startup throws UnsupportedEntityBackendError with every missing capability and the configuration that required it. This prevents a backend switch from silently weakening behavior. See the generated entity backend support matrix for the exact semantic claims and unsupported reasons for every standard store.

When you do not configure a database, entities use in-memory storage:

app.config.ts
// @skip-typecheck
import { defineApp, definePackage, entity } from '@lastshotlabs/slingshot';
import { Task } from './src/entities/task';
export default defineApp({
port: 3000,
packages: [definePackage({ name: 'app', entities: [entity({ config: Task })] })],
});

Data lives in memory and resets on restart. This is perfect for development and tests.

One config change:

app.config.ts
// @skip-typecheck
export default defineApp({
port: 3000,
db: { default: { adapter: 'sqlite', url: './data/app.db' } },
packages: [definePackage({ name: 'app', entities: [entity({ config: Task })] })],
});

The entity definition and all your routes stay exactly the same. Data now persists to disk.

app.config.ts
// @skip-typecheck
export default defineApp({
port: 3000,
db: { default: { adapter: 'postgres', url: process.env.DATABASE_URL! } },
packages: [definePackage({ name: 'app', entities: [entity({ config: Task })] })],
});

Same entity code. Same routes. Different backend.

app.config.ts
// @skip-typecheck
export default defineApp({
port: 3000,
db: { default: { adapter: 'mongo', url: process.env.MONGO_URL! } },
packages: [definePackage({ name: 'app', entities: [entity({ config: Task })] })],
});

Best for cache-shaped entities like sessions, rate limits, or ephemeral state:

app.config.ts
// @skip-typecheck
export default defineApp({
port: 3000,
db: { default: { adapter: 'redis', url: process.env.REDIS_URL! } },
packages: [definePackage({ name: 'app', entities: [entity({ config: Task })] })],
});

Different entities can use different backends:

app.config.ts
// @skip-typecheck
export default defineApp({
port: 3000,
db: {
default: { adapter: 'postgres', url: process.env.DATABASE_URL! },
cache: { adapter: 'redis', url: process.env.REDIS_URL! },
},
packages: [
definePackage({
name: 'app',
entities: [
entity({ config: Post }), // uses default (Postgres)
entity({ config: Session, adapter: 'cache' }), // uses Redis
],
}),
],
});

For advanced control, use createEntityFactories to get a set of backend-specific adapter constructors:

src/entities/post-factories.ts
// @skip-typecheck
import { createEntityFactories } from '@lastshotlabs/slingshot';
import { Post } from './post';
import { PostOps } from './post-operations';
export const postFactories = createEntityFactories(Post, PostOps);

This gives you typed factory methods:

// @skip-typecheck
postFactories.memory(); // in-memory adapter
postFactories.sqlite(infra); // SQLite adapter
postFactories.postgres(infra); // Postgres adapter
postFactories.mongo(infra); // MongoDB adapter
postFactories.redis(infra); // Redis adapter

Wire it into the entity module with wiring: { mode: 'factories' }:

src/packages/blog.ts
// @skip-typecheck
entity({
config: Post,
operations: PostOps,
wiring: {
mode: 'factories',
factories: postFactories,
},
});

This is useful when you need to test against a specific backend or access the adapter directly in test code.

Use the same immutable profiles that startup validation uses:

import { ENTITY_BACKEND_PROFILES, getEntityBackendProfile } from '@lastshotlabs/slingshot-entity';
const redis = getEntityBackendProfile('redis');
redis.capabilities['constraint.unique'].status; // "unsupported"
ENTITY_BACKEND_PROFILES.postgres.capabilities['transaction.rollback'].status; // "supported"

Current important boundaries:

  • Redis standard wiring rejects entities with secondary or compound unique constraints.
  • Memory, MongoDB, and Redis reject composite op.transaction because rollback is unavailable.
  • A standard op.custom requires a factory for the active backend.
  • Primary-key create is insert-only on every standard adapter; MongoDB and Redis do not overwrite existing records.
  • Framework transaction scopes contain only opaque identity. They never expose a PostgreSQL client, SQLite handle, Drizzle transaction, raw query function, or MongoDB session.
  • Resolve scoped adapters through package or hook entities.get(..., { scope }); adapters resolved before transactions.run() remain unscoped.

Use manual adapter wiring when your application intentionally provides semantics outside the standard factory contract.

This PostgreSQL entity configuration is accepted because PostgreSQL claims and proves configured uniqueness:

import {
assertEntityBackendRequirements,
defineEntity,
field,
} from '@lastshotlabs/slingshot-entity';
const Account = defineEntity('Account', {
fields: {
id: field.string({ primary: true }),
email: field.string(),
},
uniques: [{ fields: ['email'] }],
});
assertEntityBackendRequirements('postgres', Account);

The same configuration is rejected for standard Redis wiring before the Redis client is opened:

import {
UnsupportedEntityBackendError,
assertEntityBackendRequirements,
defineEntity,
field,
} from '@lastshotlabs/slingshot-entity';
const Account = defineEntity('Account', {
fields: {
id: field.string({ primary: true }),
email: field.string(),
},
uniques: [{ fields: ['email'] }],
});
try {
assertEntityBackendRequirements('redis', Account);
} catch (error) {
if (error instanceof UnsupportedEntityBackendError) {
console.error(error.code); // "SLINGSHOT_ENTITY_BACKEND_UNSUPPORTED"
console.error(error.missing[0]?.capability); // "constraint.unique"
}
}

When a package owns several related entities and you want one combined adapter:

// @skip-typecheck
import { createCompositeFactories } from '@lastshotlabs/slingshot';
import { LineItem } from './line-item';
import { LineItemOps } from './line-item-operations';
import { Order } from './order';
import { OrderOps } from './order-operations';
const orderFactories = createCompositeFactories({
orders: { config: Order, operations: OrderOps },
lineItems: { config: LineItem, operations: LineItemOps },
});

The composite adapter gives you orderFactories.memory().orders and orderFactories.memory().lineItems from a single factory surface.

For complete control over adapter construction — useful when integrating with an existing database or custom ORM:

// @skip-typecheck
entity({
config: Post,
wiring: {
mode: 'manual',
buildAdapter: (storeType, infra) => {
// Build your own adapter implementation
return createCustomPostAdapter(infra);
},
},
});

The adapter must implement the standard entity adapter interface (get, list, create, update, delete, plus any operation methods).

Backend swapping works because the entity definition is a contract, not an implementation. Fields, indexes, operations, and route policy describe what — the adapter handles how.

That separation means:

  • Your dev environment uses memory (fast, no setup)
  • Your CI uses SQLite (persistent, no Docker)
  • Your staging uses Postgres (production-like)
  • Your production uses Postgres + Redis (performance)

All running the same entity code.

Concurrency-enabled entities are supported by memory, SQLite, PostgreSQL, and MongoDB adapters. Their update and delete implementations compare the expected version and mutate atomically; SQLite and PostgreSQL preserve those semantics inside transaction scopes. Redis rejects the configuration before connecting because it cannot yet provide the required atomic guarantee. See Optimistic Concurrency and the generated backend support matrix.