Storage via Bus
The bus is the seam for storage in the framework. Storage handlers are bus
request handlers — consumers call bus.request() against storage subjects
without knowing whether the backend is in-memory, SQLite, or something else.
This means storage backends are swappable at composition time. Tests use in-memory handlers; production uses Drizzle/SQLite. Extensions can register their own storage namespaces and swap backends without changing consumers.
Defining a Storage Namespace
import { createStorageNamespace } from '@makaio/storage-core';
const SessionStorageNamespace = createStorageNamespace('session', { schemas: { get: { request: z.object({ sessionId: z.string() }), response: z.object({ session: MakaioSessionSchema.nullable() }), }, set: { request: z.object({ sessionId: z.string(), session: MakaioSessionSchema }), response: z.object({ success: z.boolean() }), }, delete: { request: z.object({ sessionId: z.string() }), response: z.object({ deleted: z.boolean() }), }, list: { request: z.object({ status: z.string().optional() }), response: z.object({ sessions: z.array(MakaioSessionSchema) }), }, },});
export const SessionStorageSubjects = SessionStorageNamespace.subjects;// Subjects: storage:session.get, storage:session.set, etc.createStorageNamespace prepends storage: to the domain name and registers
the namespace on the bus. The resulting subjects are typed request subjects —
consumers get full type inference on both request payloads and responses.
Implementing a Handler
A storage handler is a function that registers bus request handlers for the storage subjects. It returns a cleanup function that unsubscribes all handlers:
export function registerMemorySessionStorage(bus: IMakaioBus): () => void { const store = new Map<string, IMakaioSession>(); const unsubs: Array<() => void> = [];
unsubs.push( bus.on(SessionStorageSubjects.get, (ctx) => { const session = store.get(ctx.payload.sessionId); ctx.setResult({ session: session ?? null }); }), );
unsubs.push( bus.on(SessionStorageSubjects.set, (ctx) => { store.set(ctx.payload.sessionId, ctx.payload.session); ctx.setResult({ success: true }); }), );
return () => unsubs.forEach((fn) => fn());}Drizzle Handler Factories
For production use, the framework ships handler factories that generate bus-backed CRUD handlers from a Drizzle table definition:
createDrizzleCrudHandlers— generatesget,set,deletehandlerscreateDrizzleListHandler— generateslisthandlers with filtering
These live in @makaio/storage-handlers and return a registration function
(bus, db) => () => void — the additional db parameter is the only
difference from the in-memory version above. Consumers cannot tell which
backend serves their requests.
Consuming Storage
import { SessionStorageSubjects } from '@makaio/contracts/session';
// The consumer imports only the subject — never the handler implementationconst { session } = await bus.request( SessionStorageSubjects.get, { sessionId: '123' },);The consumer talks to contracts. The handler is swapped at composition time:
registerMemorySessionStorage for tests, the Drizzle handler for production.
Extension Storage
Extensions define their own storage namespaces using the same pattern. The
MakaioExtension.storage surface lets extensions declare Drizzle migrations
and register storage handlers during extension boot:
const myExtension: MakaioExtension = { name: 'my-extension', storage: { packageRoot: import.meta.dirname, migrations: './drizzle', registerHandlers: (bus, db, ctx) => registerMyStorageHandlers(bus, db), }, // ...};packageRoot anchors relative storage paths. migrations is a relative path
from that package root to the Drizzle migration folder.
registerHandlers receives the bus, a database instance, and a host context —
it returns an optional cleanup function.
The kernel runs migrations in dependency order during boot, then calls each
extension’s registerHandlers to wire up handlers. Your extension’s consumers
use bus.request(MyStorageSubjects.get, ...) — they never import your handler
implementation.
Why Bus-Mediated Storage
Swappable backends. Tests run against in-memory handlers. Production runs against SQLite/Drizzle. A future extension could register a PostgreSQL handler at higher priority and transparently replace the default backend.
Cross-process transparency. When a transport bridges the bus across processes, storage requests route through the same dispatch mechanism as any other bus request. A browser client can request session data from the server without a dedicated REST endpoint — the bus handles it.
Priority layering. A caching extension can register a high-priority handler
that serves from cache and calls ctx.next() on miss (see
Handler Priority and Chaining).
The primary storage handler never knows about the cache.