@elysiumjs/mnemosyne-drizzle
Drizzle ORM driver for @elysiumjs/mnemosyne.
Installation
bun add @elysiumjs/mnemosyne-drizzle
Usage
Add DrizzleModule to your application. It declares the connections as global services, registers the tenancy strategy and provides the migration:generate and migration:run commands.
import { Application } from '@elysiumjs/core';
import { DrizzleModule } from '@elysiumjs/mnemosyne-drizzle';
@Application.register({
modules: [
DrizzleModule.with({
default: 'main',
connections: {
main: { connection: process.env.DATABASE_URL! }
}
})
]
})
export class App extends Application {}
with() also accepts a function, which is called when the module is booted: DrizzleModule.with(() => ({ ... })). Set tenancy to switch between schema (default) and RLS tenancy.
Define models with the enhanced pgTable:
import { DrizzleModel, DrizzleRepository, pgTable } from '@elysiumjs/mnemosyne-drizzle';
import { uuid, varchar } from 'drizzle-orm/pg-core';
const users = pgTable('users', {
id: uuid().primaryKey().defaultRandom(),
name: varchar({ length: 255 }).notNull()
});
export class UserModel extends DrizzleModel(users, { supportTenancy: true }) {}
export class UserRepository extends DrizzleRepository(UserModel) {}
The repository finds rows by the table's primary key. A table without an id column, or with a composite primary key, takes the key as an object; key names other columns to use instead:
export class MembershipRepository extends DrizzleRepository(MembershipModel) {}
await memberships.find({ teamId, userId });
export class SessionRepository extends DrizzleRepository(SessionModel, { key: ['token'] }) {}
await sessions.find('abc123');
list(query, scope) and count(query, scope) take a condition the rows must also meet, on top of the filters of the query:
public listFor(userId: string, query: ParsedQuery<'cursor'>) {
return this.list(query, eq(posts.authorId, userId));
}
Migration hooks
migration:run --tenant <name> applies the pending migrations of a tenant in one transaction, under a lock per tenant. It works on the connection given by --connection (default when omitted), whose driver runs the migrations: bun-sql, postgres-js and node-postgres do, and hyperdrive hands them to the driver it wraps, which needs its binding. A custom driver runs them by implementing migrations(connection), with templateClientMigrations() or nodePostgresMigrations() for clients like those.
A migration hook runs SQL around the migrations, in the same transaction: when a hook fails, the whole run is rolled back. Register it with @Migration.hook() and list it in the services of a module. Every method is optional:
import type { MigrationHook, MigrationRun, MigrationStep } from '@elysiumjs/mnemosyne-drizzle';
import { Migration, sql } from '@elysiumjs/mnemosyne-drizzle';
@Migration.hook()
export class DatabaseHook implements MigrationHook {
public async before({ db }: MigrationRun) {
await db.execute(sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`);
}
public async beforeEach({ name }: MigrationStep) {}
public async afterEach({ db, name }: MigrationStep) {
const [{ count }] = await db.execute<{ count: number }>(
sql`SELECT count(*)::int AS count FROM pg_constraint WHERE ...`
);
if (count > 0) throw new Error(`${name} adds a foreign key across schemas`);
}
public async after({ db, tenant }: MigrationRun) {
await db.execute(sql`GRANT USAGE ON SCHEMA ${sql.identifier(tenant)} TO app`);
}
}
beforeruns once the tenant schema and the migrations table exist, andafterat the end. Both also run when no migration is pending, soafterfits what must stay in place, such as grants or partitions.beforeEachruns before each migration, andafterEachonce it is recorded.- A
MigrationRunholds the transaction (db), thetenant, theconnection, thebatch, thependingmigrations and the onesappliedso far. AMigrationStepholds the transaction, thenameandpathof the migration, and itsrun. db.execute(query)runs one statement written with thesqltemplate of Drizzle and returns its rows.db.script(text)runs several statements without parameters.
Hooks run for every tenant, in the order the modules register them: read tenant to act on some tenants only.
Table markers
DrizzleTable.define() marks a table with what migration:generate adds to the migration that creates it. Later migrations are left as they are.
import { DrizzleTable } from '@elysiumjs/mnemosyne-drizzle';
import { pgSchema, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core';
const kernel = pgSchema('kernel');
export const audit = DrizzleTable.define(
DrizzleTable.partitioned({ range: 'recorded_at' }),
DrizzleTable.afterCreate(
({ qualified }) =>
`CREATE TRIGGER audit_append_only BEFORE UPDATE OR DELETE ON ${qualified} FOR EACH ROW EXECUTE FUNCTION forbid_change()`
)
)(
kernel.table(
'audit',
{
id: uuid('id').notNull().defaultRandom(),
action: text('action').notNull(),
recordedAt: timestamp('recorded_at').notNull().defaultNow()
},
(t) => [primaryKey({ columns: [t.id, t.recordedAt] })]
)
);
DrizzleTable.partitioned({ range })addsPARTITION BY RANGE (...)to theCREATE TABLE.rangeis a column name in the database, a function returning the column ((t) => t.recordedAt), or several of them. The primary key and unique constraints must include those columns.DrizzleTable.afterCreate(build)adds SQL right after the table is created, as its own statement.buildreceives theschema(undefinedforpublic), thenameand the quoted, schema-qualifiedqualifiedname, and returns a string or asqltemplate.DrizzleTable.define()returns the table it marks, so its type is unchanged. Marking a table again adds to its markers.
License
This project is licensed under the Apache License, Version 2.0. See the LICENSE file for more info.