Persist state with a database
A provider that needs to remember things between calls — cached records, per-user
settings, sync cursors — declares its own database with defineDatabase and reaches it
through DatabaseService. This guide covers declaring the schema, writing migrations, and
obtaining the typed Kysely instance inside an action.
Databases are private implementation detail
Section titled “Databases are private implementation detail”A database is not one of the primitives. It is internal storage, and it must stay internal: expose behaviour over persisted state through actions, entities, and events — never hand a caller the database itself. Callers see “search contacts” or “recent messages”, not tables. This keeps you free to change the storage layout without breaking anyone.
Declare the database
Section titled “Declare the database”Define a schema type and pass it to defineDatabase with an id and a map of migrations.
The id names the database instance; migrations are keyed by an ordered name.
import { defineDatabase, type JSONColumnType } from '@grundlag/core';
import { init } from './migrations/001-init.js';
type ContactRow = { id: string; displayName: string; // JSON columns are typed with JSONColumnType<T>: store with JSON.stringify, // parse on read. emails: JSONColumnType<string[]>; metadata: JSONColumnType<{ source: string; syncedAt: string }>;};
type DatabaseSchema = { contacts: ContactRow;};
const contactsDatabase = defineDatabase<DatabaseSchema>({ id: 'contacts', migrations: { '001-init': init, },});
export { contactsDatabase };The schema type parameter is what makes the Kysely instance you get back fully typed, so
declare a row type per table and gather them into one DatabaseSchema.
Write a migration
Section titled “Write a migration”A migration is a { up, down } pair built from Kysely’s schema builder. Migration is
re-exported from the core package.
import { Migration } from '@grundlag/core';
const init: Migration = { up: async (db) => { await db.schema .createTable('contacts') .addColumn('id', 'varchar', (col) => col.primaryKey()) .addColumn('displayName', 'varchar') .addColumn('emails', 'jsonb') .addColumn('metadata', 'jsonb') .execute(); }, down: async (db) => { await db.schema.dropTable('contacts').ifExists().execute(); },};
export { init };Migrations run automatically, in key order, before the first use of the database.
Once a migration has been released, treat the set as append-only: never edit or reorder
a shipped migration — add a new one (002-..., 003-...) instead. Editing a migration
that has already run against a real database leaves that database in a state your code no
longer expects.
Use the database from an action
Section titled “Use the database from an action”The database creator itself is supplied by the host, not the provider — the host sets a
databaseCreator on the Services container (see
Services and dependency injection). Your provider never opens
a connection directly; it asks DatabaseService for an instance and gets back a typed
Kysely client.
import { defineAction, DatabaseService, z } from '@grundlag/core';
import { contactsDatabase } from '../database/database.js';
const upsertContact = defineAction({ id: 'upsertContact', name: 'Upsert contact', description: 'Stores or updates a contact in the local cache.', input: z.object({ id: z.string(), displayName: z.string(), emails: z.array(z.string()), }), output: z.object({ id: z.string() }), execute: async ({ input, services }) => { const db = await services.get(DatabaseService).getInstance(contactsDatabase);
await db .insertInto('contacts') .values({ id: input.id, displayName: input.displayName, // JSON columns are stored as stringified JSON. emails: JSON.stringify(input.emails), metadata: JSON.stringify({ source: 'sync', syncedAt: new Date().toISOString() }), }) .onConflict((oc) => oc.column('id').doUpdateSet({ displayName: input.displayName })) .execute();
return { id: input.id }; },});getInstance is lazy and cached by id: the first call for a given database runs its
migrations and opens the connection; every later call — from any action — returns the same
instance. You do not manage the connection lifecycle yourself.
The Kysely type carries your DatabaseSchema, so table names, columns, and result rows are
all checked. When you write a JSONColumnType<T> column, pass a JSON.stringify’d value as
shown above.
On read the type is honest: Postgres parses jsonb itself, so a JSONColumnType<T> column
really does arrive as T. What the type cannot promise is that the JSON matches T — the
column holds whatever was written into it, including by an older version of your code or by
hand. Narrow at the edge of your store when that matters:
// The value is parsed; this only checks it is the shape this module expects.const hydrate = (row: Selectable<ContactRow>): Selectable<ContactRow> => ({ ...row, emails: Array.isArray(row.emails) ? row.emails.filter((e) => typeof e === 'string') : [],});A mismatch surfaces as a validation error from the action or entity that returns the value, which is the argument for narrowing every read in one place rather than at each use.
Guidelines
Section titled “Guidelines”- Keep the database private. Expose behaviour through actions, entities, and events — never the database itself.
- Declare a schema type. It is what makes the Kysely instance typed end to end.
- Migrations are append-only once released. Add new ones; never edit or reorder shipped migrations.
- Let the host own the creator. The provider asks
DatabaseServicefor an instance; the host supplies thedatabaseCreatoronServices. - Rely on lazy caching.
getInstanceruns migrations once and reuses the connection — no manual lifecycle management.