Skip to content

Database ​

MySQL/MariaDB ORM layer extending Deepkit's database with transaction hooks, session locks, raw query helpers, entity creation utilities, dirty tracking, relationship resolution, and a migration system.

Creating a Database ​

Use createMySQLDatabase() to define a database class with your entities:

typescript
import { createMySQLDatabase } from '@zyno-io/dk-server-foundation';

class AppDB extends createMySQLDatabase({ enableLocksTable: true }, [User, Post, Comment]) {}

Pass the database class to createApp():

typescript
const app = createApp({
    config: AppConfig,
    db: AppDB
});

createMySQLDatabase(config, entities?) ​

OptionTypeDefaultDescription
enableLocksTablebooleanfalseCreate the _locks table for session-level locking.

All other MySQL connection options are configured via environment variables (see Configuration).

Choosing the dialect at runtime — createDatabase() ​

For dialect-agnostic apps, use createDatabase() to select MySQL or PostgreSQL via configuration instead of importing one factory or the other.

typescript
import { createDatabase } from '@zyno-io/dk-server-foundation';

// Reads DB_ADAPTER ('mysql' or 'postgres') from the environment at module load
class AppDB extends createDatabase({ enableLocksTable: true }, [User, Post, Comment]) {}

// Or be explicit (each form keeps its dialect-specific pool config)
class AppDB extends createDatabase('mysql', { connectionLimit: 20, enableLocksTable: true }, [User]) {}
class AppDB extends createDatabase('postgres', { max: 20, enableLocksTable: true }, [User]) {}

The shared form only accepts enableLocksTable; pool tuning is done via the MYSQL_* / PG_* env vars. The shared form requires DB_ADAPTER to be set and throws at call time if it isn't — use the explicit form when you don't want that dependency.

Both forms delegate to createMySQLDatabase or createPostgresDatabase and return the same kind of class — a subclass of BaseDatabase. The existing single-dialect factories remain available.

Entity Creation ​

Type-safe entity creation with automatic inference of optional fields (auto-increment, nullable, or HasDefault).

Create Without Persisting ​

typescript
import { createEntity, createEntities } from '@zyno-io/dk-server-foundation';

const user = createEntity(User, { email: 'a@b.com', name: 'Alice' });
const users = createEntities(User, [
    { email: 'a@b.com', name: 'Alice' },
    { email: 'b@c.com', name: 'Bob' }
]);

Create and Queue for Persistence ​

Entities are added to the session's unit of work but not flushed until session.flush():

typescript
import { createQueuedEntity } from '@zyno-io/dk-server-foundation';

await db.transaction(async session => {
    const user = createQueuedEntity(User, { email: 'a@b.com', name: 'Alice' }, session);
    const post = createQueuedEntity(Post, { userId: user.id, title: 'Hello' }, session);
    // Both persisted on commit
});

Create and Persist Immediately ​

typescript
import { createPersistedEntity, createPersistedEntities } from '@zyno-io/dk-server-foundation';

const user = await createPersistedEntity(User, { email: 'a@b.com', name: 'Alice' }, session);

Persist Existing Entities ​

typescript
import { persistEntity, persistEntities } from '@zyno-io/dk-server-foundation';

const user = createEntity(User, { email: 'a@b.com', name: 'Alice' });
await persistEntity(user, session);

Entity Retrieval ​

typescript
import { getEntityOr404, getEntityOrUndefined, getEntity, entityExists } from '@zyno-io/dk-server-foundation';

// Throws HttpNotFoundError if not found
const user = await getEntityOr404(User, { id: 1 });

// Returns undefined if not found
const user = await getEntityOrUndefined(User, { id: 1 });

// Throws Deepkit ItemNotFound if not found
const user = await getEntity(User, { id: 1 });

// Check existence
const exists = await entityExists(User, { email: 'a@b.com' });

Transactions and Sessions ​

Transactions ​

typescript
await db.transaction(async session => {
    // All operations within this callback are wrapped in a transaction
    const user = createQueuedEntity(User, { name: 'Alice' }, session);
    // Auto-commits on success, auto-rollbacks on error
});

Sessions (No Transaction) ​

typescript
await db.session(async session => {
    // Unit of work without an explicit transaction
});

Reuse or Create ​

typescript
// Uses existing session if provided, otherwise creates a new transaction
await db.withTransaction(existingSession, async session => {
    // ...
});

// Same for sessions (no transaction)
await db.withSession(existingSession, async session => {
    // ...
});

Transaction Hooks ​

typescript
await db.transaction(async session => {
    session.addPreCommitHook(async () => {
        // Runs before the transaction commits
    });

    session.addPostCommitHook(async () => {
        // Runs after successful commit
        // Good for sending notifications, invalidating caches
    });
});

Session Locks ​

Acquire database-level locks that are held until the transaction completes. Requires enableLocksTable: true in database config.

typescript
await db.transaction(async session => {
    await session.acquireSessionLock(['wallet', walletId]);
    // Lock held until commit/rollback
    // Other transactions attempting the same lock will wait
});

Lock keys can be a single value or an array that gets flattened: ['wallet', 123] becomes wallet:123.

Raw Queries ​

Tagged Template Literals ​

Use Deepkit's sql tagged template for parameterized queries:

typescript
import { sql } from '@deepkit/sql';

const users = await db.rawFind<User>(sql`SELECT * FROM users WHERE active = ${true} AND age > ${18}`);

await db.rawExecute(sql`UPDATE users SET last_login = NOW() WHERE id = ${userId}`);

Single Result ​

typescript
const user = await db.rawFindOne<User>(sql`SELECT * FROM users WHERE id = ${id}`);
// Returns undefined if not found

Unsafe (Manual Bindings) ​

typescript
const users = await db.rawFindUnsafe<User>('SELECT * FROM users WHERE name LIKE ?', ['%alice%']);

Execute Results ​

typescript
const result = await db.rawExecute(sql`INSERT INTO users ...`);
// result: { affectedRows, insertId, warningStatus }

Dirty Tracking ​

Track changes to entities loaded from the database:

typescript
import {
    isEntityDirty,
    getDirtyFields,
    getDirtyDetails,
    isFieldDirty,
    getFieldOriginal,
    getEntityOriginal,
    revertDirtyEntity
} from '@zyno-io/dk-server-foundation';

user.name = 'Bob';

isEntityDirty(user); // true
getDirtyFields(user); // ['name']
isFieldDirty(user, 'name'); // true
getFieldOriginal(user, 'name'); // 'Alice'

getDirtyDetails(user);
// { name: { original: 'Alice', current: 'Bob' } }

getEntityOriginal(user);
// { id: 1, name: 'Alice', email: '...' }

revertDirtyEntity(user);
// user.name is now 'Alice' again

Bulk Loading ​

Keyed Entities ​

Load entities indexed by a field:

typescript
import { getKeyedEntities, getKeyedGroupedEntities, getEntitiesById } from '@zyno-io/dk-server-foundation';

// Returns { [userId]: User }
const usersById = await getKeyedEntities({
    schema: User,
    ids: [1, 2, 3],
    keyField: 'id'
});

// Returns { [departmentId]: User[] }
const usersByDept = await getKeyedGroupedEntities({
    schema: User,
    ids: [10, 20],
    keyField: 'departmentId'
});

// Returns User[]
const users = await getEntitiesById({
    schema: User,
    ids: [1, 2, 3],
    fields: ['id', 'name', 'email']
});

Relationship Resolution ​

Resolve one-to-one/many-to-one and many-to-many relationships:

typescript
import { resolveRelated, resolveRelatedByPivot } from '@zyno-io/dk-server-foundation';

// Many-to-one: attach department to each user
await resolveRelated({
    src: users,
    srcIdField: 'departmentId',
    targetField: 'department',
    targetSchema: Department,
    targetFields: ['id', 'name']
});
// users[0].department = Department { id, name }

// Many-to-many via pivot table
await resolveRelatedByPivot({
    src: posts,
    srcIdField: 'id',
    pivotSchema: PostTag,
    pivotIdKey: 'postId',
    pivotRelatedKey: 'tagId',
    targetField: 'tags',
    targetSchema: Tag,
    targetFields: ['id', 'name']
});
// posts[0].tags = [{ ...Tag, pivot: PostTag }, ...]

Custom Types ​

Coordinate (POINT) ​

typescript
import { Coordinate, MySQLCoordinate, NullableMySQLCoordinate } from '@zyno-io/dk-server-foundation';

class Location {
    coords!: MySQLCoordinate; // NOT NULL POINT
    altCoords!: NullableMySQLCoordinate; // NULLABLE POINT
}

DateString ​

typescript
import { DateString } from '@zyno-io/dk-server-foundation';

class Event {
    date!: DateString; // MySQL DATE column, stored as 'YYYY-MM-DD'
}

UuidString ​

typescript
import { UuidString } from '@zyno-io/dk-server-foundation';

class Resource {
    id!: UuidString; // Type annotation for UUID fields
}

Length ​

typescript
import { Length } from '@zyno-io/dk-server-foundation';

class Token {
    code!: Length<6>; // Validated fixed-length string
}

HasDefault ​

Mark fields that have application-level defaults so they become optional in createEntity():

typescript
import { HasDefault } from '@zyno-io/dk-server-foundation';

class User {
    role!: string & HasDefault; // Optional in createEntity()
}

Schema Builder (multi-dialect) ​

db.schema is a Laravel-style fluent schema builder that emits dialect-appropriate SQL at runtime, so a single migration file works on both MySQL and PostgreSQL.

typescript
export default createMigration(async db => {
    await db.schema.create('users', t => {
        t.id();
        t.string('email', 255).notNull().unique();
        t.string('name', 255).nullable();
        t.boolean('active').notNull().default(false);
        t.json('metadata').nullable();
        t.enum('status', ['active', 'pending']).notNull().default('pending');
        t.dateTime('createdAt').notNull().defaultRaw('CURRENT_TIMESTAMP');
        t.dateTime('updatedAt').notNull().defaultRaw('CURRENT_TIMESTAMP').onUpdate('CURRENT_TIMESTAMP');
    });

    await db.schema.create('posts', t => {
        t.id();
        t.bigInteger('userId').unsigned().notNull();
        t.string('title', 200).notNull();
        t.text('body').nullable();
        t.foreign('userId').references('id').on('users').onDelete('cascade');
        t.index('userId');
    });

    // Escape hatches
    await db.schema.raw(`UPDATE users SET active = TRUE WHERE id < 100`);
    await db.schema.onlyOn('postgres', () => db.rawExecute(`CREATE EXTENSION IF NOT EXISTS pg_trgm`));
});

Column types ​

MethodMySQLPostgres
id(name='id')BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEYBIGSERIAL PRIMARY KEY
string(name, len=255)VARCHAR(len)VARCHAR(len)
char(name, len=1)CHAR(len)CHAR(len)
text(name)TEXTTEXT
tinyText(name)TINYTEXTTEXT
mediumText(name)MEDIUMTEXTTEXT
longText(name)LONGTEXTTEXT
tinyint, smallint, integer, bigIntegerTINYINT, SMALLINT, INT, BIGINTSMALLINT, INTEGER, BIGINT (tinyint → SMALLINT)
boolean(name)TINYINT(1)BOOLEAN
float, double, decimal(name, p?, s?)FLOAT, DOUBLE, DECIMAL(p,s)REAL, DOUBLE PRECISION, NUMERIC(p,s)
date(name)DATEDATE
dateTime(name)DATETIMETIMESTAMP
timestamp(name)TIMESTAMPTIMESTAMP
timestamptz(name)TIMESTAMPTIMESTAMPTZ
binary(name, len=16)BINARY(len)BYTEA
blob(name)BLOBBYTEA
json(name)JSONJSON
jsonb(name)JSONJSONB
uuid(name)BINARY(16) (canonical)UUID
uuidString(name)CHAR(36)CHAR(36)
enum(name, values, typeName?)ENUM(...) inlineCREATE TYPE (deduped) + qualified ref
point(name)POINTthrows (MySQL-only)

Modifiers ​

.nullable(), .notNull(), .default(value), .defaultRaw(expression), .unsigned() (MySQL-only, ignored on PG), .onUpdate(expression) (MySQL-only), .autoIncrement(), .primary(), .unique(name?), .index(name?), .references(col).on(table).onDelete(action).onUpdate(action).

Table-level ​

.timestamps() (createdAt + updatedAt with CURRENT_TIMESTAMP), .primary([cols]) (composite PK), .index(cols, name?), .unique(cols, name?), .spatialIndex(cols, name?) (MySQL POINT), .foreign(cols, name?).references(...).on(...).

Schema operations ​

db.schema.create(name, fn), db.schema.alter(name, fn), db.schema.drop(name), db.schema.dropIfExists(name), db.schema.rename(from, to), db.schema.enumType(name, values) (PG explicit type), db.schema.raw(sql), db.schema.onlyOn(dialect, fn).

Introspection (for idempotent migrations) ​

typescript
if (!(await db.schema.hasTable('users'))) {
    /* ... */
}
if (await db.schema.hasColumn('users', 'phone')) {
    /* ... */
}
if (await db.schema.hasIndex('users', 'users_email_unique')) {
    /* ... */
}

Altering tables — db.schema.alter() ​

typescript
await db.schema.alter('users', t => {
    // Add columns (same syntax as create)
    t.string('phone', 20).nullable();
    t.boolean('archived').notNull().default(false);

    // Modify an existing column (Laravel-style .change() suffix)
    t.string('email', 500).notNull().change();

    // Drop / rename columns
    t.dropColumn('legacyField');
    t.renameColumn('old_name', 'new_name');

    // Indexes & foreign keys
    t.index('phone');
    t.dropUnique('users_email_unique');
    t.foreign('orgId').references('id').on('orgs').onDelete('cascade');
    t.dropForeign('users_old_fk');

    // Primary key
    t.dropPrimary();
    t.primary(['a', 'b']);
});

Operations execute in dependency-safe order: drop FKs → drop indexes → drop PK → drop columns → rename columns → PG enum type prep → add columns → modify columns → add PK → add indexes → defer added FKs to flush. Added FKs are deferred (same as create()) so cross-table refs resolve.

How FK ordering works ​

Inline FKs declared via t.foreign(...) are deferred and emitted as ALTER TABLE ... ADD CONSTRAINT after all CREATE TABLEs in the migration complete. The migration runner calls db.schema.flush() automatically; you can also call it manually if you need FKs applied mid-migration. PG enum types are deduplicated per-migration via the enumTypeName (so the same shared enum across two tables emits only one CREATE TYPE + CREATE CAST).

Migrations ​

Generating Migrations ​

The migration:create command diffs entity definitions against the live database and generates a migration file:

bash
# Interactive mode (prompts for column renames)
node app.js migration:create

# Non-interactive (CI-safe, treats ambiguous changes as drop+add)
node app.js migration:create --non-interactive

It detects: table creation/removal, column additions/removals/modifications/renames, index and foreign key changes, primary key changes, and PostgreSQL enum type management. Both MySQL and PostgreSQL are supported.

Writing Migrations Manually ​

typescript
// src/migrations/20240101_120000_add_users.ts
import { createMigration } from '@zyno-io/dk-server-foundation';

export default createMigration(async db => {
    await db.rawExecute(sql`
        CREATE TABLE users (
            id INT AUTO_INCREMENT PRIMARY KEY,
            email VARCHAR(255) NOT NULL,
            name VARCHAR(255) NOT NULL
        )
    `);
});

Running Migrations ​

bash
# Via CLI
node app.js migration:run

# Programmatically
import { runMigrations } from '@zyno-io/dk-server-foundation';
await runMigrations();

Reset Migrations ​

Removes all existing migrations and generates a single base migration from entity definitions:

bash
node app.js migration:reset

Character Set Standardization ​

bash
node app.js migration:charset [charset] [collation]
# Defaults: utf8mb4, utf8mb4_0900_ai_ci

Or programmatically:

typescript
import { standardizeDbCollation } from '@zyno-io/dk-server-foundation';
await standardizeDbCollation(db);

Entity Utility Functions ​

typescript
import { getPKFieldForEntity, getEntityFields, logSql } from '@zyno-io/dk-server-foundation';

// Get primary key field name
const pk = getPKFieldForEntity(User); // 'id'

// Extract only data fields (no methods or relations)
const fields = getEntityFields(user);

// Debug SQL output
logSql('SELECT * FROM users WHERE id = ?', [1]);

Released under the MIT License.