Quik Framework :: Database
- Codename: Berlin
- Version: 0.2.0-beta.76
- License: Check license here
The Database package provides Quik's model, repository, query-builder, and migration primitives. It supports typed models, relation loading, soft deletes, pagination helpers, and migration or seed execution.
Features
- Model-based persistence with
QModel. - Repository pattern with
QRepository. - Query composition with
QWhereBuilder,Conditions, andClauses. - Relation loading with nested relation definitions.
- Pagination and search helpers with
QPaginatedParameters. - Automatic soft-delete filtering: all
findqueries exclude soft-deleted rows by default; passwithDeleted: trueinQFindOptionsto opt out. - Migrations and seeds through registered CLI commands.
Installation
pnpm add @quik/database
Configuration
Key environment variables:
DATABASE_CONFIGURATIONwithmysql,postgres, orsqlite.DATABASE_MYSQL_HOSTNAME,DATABASE_MYSQL_PORT,DATABASE_MYSQL_USERNAME,DATABASE_MYSQL_PASSWORD,DATABASE_MYSQL_DATABASE.DATABASE_POSTGRES_HOSTNAME,DATABASE_POSTGRES_PORT,DATABASE_POSTGRES_USERNAME,DATABASE_POSTGRES_PASSWORD,DATABASE_POSTGRES_DATABASE,DATABASE_POSTGRES_SCHEMA.DATABASE_SQLITE_FILENAME.DATABASE_LOGGING,DATABASE_DEBUGGING.
Migration-related config keys:
db.migrations.enabled.db.migrations.table.
See the package defaults for the source of truth:
Usage
Define a Model
import { DateTime } from 'luxon';
import { Column, Model, QModel } from '@quik/database';
@Model()
export class User extends QModel {
@Column.PrimaryGenerated()
public id: number;
@Column.String({ length: 500 })
public name: string;
@Column.String({ length: 500 })
public email: string;
@Column.CreatedAt()
public createdAt: DateTime;
}
Define a Repository
import { QRepository } from '@quik/database';
import { User } from './models/User.ts';
export class UserRepository extends QRepository<User> {
public constructor() {
super(User);
}
}
Register or Inject a Repository
You can instantiate repositories directly, but the package also exposes RepositoryStore and @GetRepository(...) for lazy resolution.
import { GetRepository, RepositoryStore } from '@quik/database';
import { UserRepository } from './repositories/UserRepository.ts';
RepositoryStore.register(UserRepository);
RepositoryStore.has(UserRepository); // true
RepositoryStore.all(); // [UserRepository]
class UsersService {
@GetRepository(UserRepository)
public repository!: UserRepository;
}
Query with a Repository
import { QWhereBuilder } from '@quik/database';
import { Entity, Fields, QEntity } from '@quik/entity';
const repository = new UserRepository();
const users = await repository.get({
where: QWhereBuilder.New()
.Equal('status', 'active')
.And(QWhereBuilder.New().NotNull('email')),
sort: [
{ field: 'createdAt', direction: 'desc' }
],
relations: [
'profile',
{ posts: [ 'author', 'tags' ] }
]
});
const user = await repository.findById(1, {
relations: [ 'profile', { posts: [ 'tags' ] } ]
});
const dailyRequests = await repository.countBy<{
date: string;
status: string;
count: number;
}>({
where: QWhereBuilder.New().NotNull('createdAt'),
select: [
{ field: 'createdAt', as: 'date', transform: 'date' },
'status'
],
count: { field: 'id' },
sort: [
{ field: 'date', direction: 'asc' },
{ field: 'status', direction: 'asc' }
]
});
@Entity('RequestCountRow')
class RequestCountRow extends QEntity {
@Fields.String()
public date: string;
@Fields.String()
public status: string;
@Fields.Integer()
public count: number;
}
const entityRows = await repository.countBy(RequestCountRow, {
select: [
{ field: 'createdAt', as: 'date', transform: 'date' },
'status'
],
count: { field: 'id' }
});
Paginate with QPaginatedParameters
QRepository.paged() accepts a QPaginatedParameters entity, not a plain object.
import {
QPaginatedParameters,
QSearchOperator
} from '@quik/database';
import { EntityStore } from '@quik/entity';
const parameters = EntityStore.create(QPaginatedParameters, {
page: 1,
size: 10,
sortBy: [
{ field: 'name', direction: 'asc' }
],
searchBy: [
{ field: 'status', operator: QSearchOperator.EQ, value: 'active' },
{ field: 'email', operator: QSearchOperator.LIKE, value: '%@example.com' }
],
strict: true
});
const pagedUsers = await repository.paged(parameters, {
relations: [ 'profile' ]
});
QSearchField also supports an optional table property when you need to target a joined table or relation alias such as posts_RelPost.title.
Use Query Helpers
QWhereBuilder is the main public API for filtering. Conditions and Clauses are lower-level helpers when you want plain condition objects or prebuilt builders.
import { Clauses, Conditions, QWhereBuilder } from '@quik/database';
const builder = QWhereBuilder.New()
.Equal('status', 'active')
.Or(
QWhereBuilder.New().And(
Conditions.GreaterThan('score', 10),
Conditions.NotNull('nickname')
)
);
const archived = Clauses.Not('status', 'archived');
.And(...)/.Or(...) accept Conditions.* objects directly — you don't need to wrap each one in QWhereBuilder.New().
Nested groups (like an OR sub-clause inside an AND chain) can be built inline with a callback instead of a
separate QWhereBuilder.New() call, so a filter combining several plain conditions and one nested group fits in a
single .And(...):
const builder = QWhereBuilder.New()
.Equal('ProjectTeam.projectId', projectId)
.And(
Conditions.Equal('ProjectTeamMember.userId', userId),
Conditions.LessThanOrEqual('ProjectTeamMember.startDate', date),
(group) => group.Or(
Conditions.IsNull('ProjectTeamMember.endDate'),
Conditions.GreaterThanOrEqual('ProjectTeamMember.endDate', date)
)
);
// WHERE ProjectTeam.projectId = ? AND ProjectTeamMember.userId = ? AND ProjectTeamMember.startDate <= ?
// AND (ProjectTeamMember.endDate IS NULL OR ProjectTeamMember.endDate >= ?)
When you know the model type and want field-name autocomplete for direct model fields, use the typed helper view:
import type { User } from './models/User.ts';
import { Clauses, Conditions } from '@quik/database';
const where = Clauses.Typed<User>().Equal('email', 'demo@example.com');
const condition = Conditions.Typed<User>().GreaterThan('id', 10);
Use the typed helpers for direct model fields. Use the untyped Clauses or Conditions helpers when you need dotted relation paths or relation aliases such as profile.email or posts_RelPost.title.
Join Queries with a Custom Result Shape
QRepository.joinQuery(resultEntity) is for queries that join tables not declared as @Relations on the model
(get/find/paged only join declared relations) and/or return a shape other than the model itself. Field names
passed to .where() should be fully qualified as table.column when the query spans more than one table.
import { Entity, Fields, QEntity } from '@quik/entity';
import { Conditions, QRepository, QWhereBuilder } from '@quik/database';
@Entity('ActiveMembershipRow')
class ActiveMembershipRow extends QEntity {
@Fields.Integer()
public id: number;
@Fields.Integer()
public userId: number;
}
class ProjectTeamMemberRepository extends QRepository<ProjectTeamMember> {
async findActiveMembership(projectId: number, userId: number, date: string) {
return this.joinQuery(ActiveMembershipRow)
.join('ProjectTeam', 'ProjectTeam.id', 'ProjectTeamMember.projectTeamId')
.where(QWhereBuilder.New()
.Equal('ProjectTeam.projectId', projectId)
.And(
Conditions.Equal('ProjectTeamMember.userId', userId),
Conditions.LessThanOrEqual('ProjectTeamMember.startDate', date),
(group) => group.Or(
Conditions.IsNull('ProjectTeamMember.endDate'),
Conditions.GreaterThanOrEqual('ProjectTeamMember.endDate', date)
)
))
.select('ProjectTeamMember.*')
.first(); // or .get() for all matching rows
}
}
resultEntity can be any plain @Entity()-decorated class — it doesn't need to be a @Model()/table-backed
model. Rows are mapped with the same mechanism countBy(entity, ...) uses for its custom-shaped results
(EntityStore.create(entity, row, true, { applyDefaults: false })), so unrecognized/absent fields don't trigger
default-filling or validation errors. .first()/.get() are terminal.
Join types: .join()/.innerJoin() (alias), .leftJoin(), .rightJoin(), .fullOuterJoin(). Each accepts
either (table, first, second), (table, first, operator, second), or (table, callback) for a multi-condition
ON clause via Knex's own join callback:
.join('ProjectTeam', (on) => {
on.on('ProjectTeam.id', 'ProjectTeamMember.projectTeamId').andOn('ProjectTeam.archived', knex.raw('false'));
})
Typed field names: field name strings passed to .where()/.having()/.select()/.groupBy()/.orderBy()
are plain, unchecked strings by default. Field<TModel>(table, field) builds a table.field string with the
field name checked against TModel's actual fields at compile time (the table name itself is not verified —
pass the same string you used in .join()):
import { Field } from '@quik/database';
.where(QWhereBuilder.New().Equal(Field<ProjectTeam>('ProjectTeam', 'projectId'), projectId))
Select and aliasing: .select() accepts a mix of plain table.column/table.* strings, { field, as }
pairs (quoted as a single identifier — safe for plain columns, not for aggregate expressions), and Knex.Raw for
aggregates:
.select(
'ProjectTeamMember.projectTeamId',
this.knex.raw('COUNT(??) as ??', ['ProjectTeamMember.id', 'memberCount'])
)
Aggregates: .groupBy(...columns) and .having(whereBuilder) (filtering on SELECT aliases, e.g.
memberCount above — unlike .where(), .having() does not auto-qualify unprefixed field names with the base
table, since aliases aren't columns of any one table).
Use Relation Helpers
RelationsUtils.fromList() converts flat relation paths into the nested structure used by repository queries.
import { RelationsUtils } from '@quik/database';
const relations = RelationsUtils.fromList([
'profile',
'posts.author',
'posts.tags'
]);
const users = await repository.get({ relations });
Define Relations
Relations are defined with decorators. For many-to-many relations, you can override the link table name with link.
import { ManyToMany } from '@quik/database';
@ManyToMany({ entity: () => Tag, column: 'tagId', link: 'UserTagLink' })
public tags: Tag[];
API Reference
Core Types
QModel: Base class for database models.QRepository<TModel, TKey>: Base class for repositories.QQueryBuilder: Low-level SQL builder used by repository methods.QWhereBuilder: Fluent where-clause builder.QPaginatedParameters: Query-parameter entity for pagination and filtering.QSortField,QSearchField,QSearchByEntity: Entities used byQPaginatedParameters.
QRepository
CRUD and lookup methods:
get(findOptions?).find(findOptions?).findOrNull(findOptions?).findFirst(findOptions?).findFirstOrNull(findOptions?).findLast(findOptions?).findLastOrNull(findOptions?).findById(id, options?).create(data).bulkCreate(data).bulkUpdate(ids, data).update(id, data).bulkDelete(ids, force?).delete(id, force?).deleteBy(where, force?).restore(id).findDeleted(findOptions?)— return only soft-deleted rows.findWithDeleted(findOptions?)— return all rows including soft-deleted.process(...items).count(findOptions?).countBy(findOptions).paged(parameters?, relations?).
Query and Relation Types
The repository module publicly exports:
QFindOptions.QGroupedCountOptions,QGroupSelectOption,QCountAggregateOption.QRelation,QFindRelations,QRelationOptions,QRelationFindOption.QWhereCondition,QWhereClause,QWhereOperator.QSortOption.
Store and Decorators
RepositoryStore.register(...repositories).RepositoryStore.load(folder).RepositoryStore.constructor(repository).RepositoryStore.get(repository).RepositoryStore.has(repository).RepositoryStore.all().RepositoryStore.clear(repository?).GetRepository(repository).
Helper Namespaces
Conditions: Factory functions returning plainQWhereConditionobjects.Clauses: Factory functions returningQWhereBuilderinstances.Conditions.Typed<TModel>(): Typed condition helpers with direct model-field autocomplete.Clauses.Typed<TModel>(): Typed clause helpers with direct model-field autocomplete.RelationsUtils.fromList(relations): Convert flat relation paths into nested relation definitions.
Migrations and Seeds
The package ships migration and seed engines together with CLI commands registered on boot.
Available commands:
migration:new <name>.migration:generate <name> --module <module>.migration:show.migration:apply [--force].migration:undo.migration:update-table [--force].seeds:new <name>.seeds:show.seeds:run.
Migration and seed classes must include a timestamp suffix in the class name, for example CreateUsers_20250101093000, to be discovered.
Applied migrations store execution metadata including execution time, batch id, and source checksum. Use migration:update-table before applying new migrations if you need to upgrade an existing migrations table to the latest metadata format. Pass --force to migration:update-table to refresh stored checksums for applied migrations whose source files changed, or to migration:apply when you intentionally want to continue a migration run after reconciling those checksum errors.
The migrations table is also upgraded automatically: every time migration:apply/migration:undo (or any other
engine operation) resolves the migrations table, missing columns (executedAt, batchId, checksum, schema,
...) are added additively before continuing. Running migration:update-table manually is only needed to force a
checksum refresh or to run the upgrade ahead of time; it is not required for the table to pick up new metadata
columns such as schema.
Generating Migrations from Model Changes
migration:generate <name> --module <module> diffs the currently registered @Model() classes' column
definitions against a stored snapshot — not the live database — and writes a migration file with the
resulting up()/down() schema-builder calls, updating the snapshot to match. This is snapshot-diffing, the
same approach Prisma Migrate/Django use, chosen over live-DB introspection because it needs no database
connection to run and isn't dialect-fragile.
migration:generate create-order --module core
Notes and current limitations:
- The snapshot is global, not scoped per module — it covers every registered model regardless of which
module's migration folder the generated file lands in, since models have no module association today. Running
migration:generatefor module A will still pick up schema changes in models that belong to module B. - All relevant model files must be imported/registered before running the command — decorators only run when
their class file is loaded (the same precondition as
getModels()). - Snapshot location:
db.migrations.snapshotFileconfig (DATABASE_MIGRATIONS_SNAPSHOT_FILE), defaulting to./migrations/.schema-snapshot.json. Commit this file — it's the diff baseline for the next run, not a build artifact. - Generated migrations are best-effort and must be reviewed before running. New tables/columns render
directly as
table.<type>(...)calls. Column type/nullable/default changes on existing columns render as.alter()— Knex's own limitations around.alter()apply (notably on SQLite). Enum value-set changes route throughalterEnumColumn()instead of.alter(), defaulting to the CHECK-constraint strategy; addnativeType: true(and the originaltypeName) in the generated call if the column uses a native Postgres enum type. Dropped models/columns generatedropTable/dropColumn— review before running, this is destructive. - Not covered yet: indexes, foreign key constraints, primary key changes beyond what a plain
.alter()handles, and an apply-directly / auto-sync mode (migration:generateonly ever writes a file).
Listing Registered Models
getModels() and getModelNames() (exported from @quik/database) read from EntityMetadataStore and return
only classes decorated with @Model()/@BaseModel() — plain @Entity()-decorated classes (DTOs, query
parameters, etc.) are excluded, since @Model() tags its metadata with a model flag.
import { getModelNames, getModels } from '@quik/database';
getModelNames(); // ['User', 'builder.BuilderModule', ...]
getModels(); // full EntityMetadata for each registered model
Models are only visible once their class file has been imported (decorators run at module evaluation time), so
call this after all model files are loaded — e.g. after RepositoryStore.load(...)/module setup.
Postgres Schema Support
@Model()/@BaseModel() accept an optional schema config field (Postgres only) that qualifies the resolved
table name, e.g. @Model({ name: "BuilderModule", schema: "builder" }) resolves to table builder.BuilderModule.
This relies on Knex's native dot-qualified identifier handling on the pg client; it is not portable to MySQL,
where a dotted name means a cross-database reference instead.
Migrations can be scoped to a schema with MigrationStore.setSchema(module, schema). Before that module's
migrations run, the engine issues CREATE SCHEMA IF NOT EXISTS against a Postgres connection (once per module
per run); on any other dialect a schema-scoped module throws QMigrationError. The migrations bookkeeping
table records the schema alongside the module for each applied migration.
uuidDefault(engine) (exported from @quik/database) returns a dialect-aware Knex.Raw default expression for
UUID primary keys in hand-written migrations — gen_random_uuid() on Postgres (PG13+, or pgcrypto on older
versions) and (UUID()) on MySQL:
import { uuidDefault } from '@quik/database';
public async up(schema, engine) {
return schema.createTable('Example', (table) => {
table.uuid('id').primary().defaultTo(uuidDefault(engine));
});
}
Column.Json(options?) and Column.Jsonb(options?) add sugar factories for JSON/JSONB columns, mirroring
Column.Text's { name?, nullable?, unique?, select?, default? } options shape.
Altering enum columns
alterEnumColumn(engine, options) (exported from @quik/database) changes the allowed value set of an existing
enum column in a hand-written migration, using a dialect-appropriate strategy:
- MySQL — runs
ALTER TABLE ... MODIFY COLUMN ... ENUM(...)directly; MySQL has no restriction on adding/removing/reordering enum values. - Postgres, CHECK-constraint-backed column (Knex's default
table.enum(column, values), withoutuseNative: true) — drops and recreates the CHECK constraint. This is the default (nativeTypenot set). - Postgres, native enum type (
table.enum(column, values, { useNative: true, enumName })) — Postgres has noALTER TYPE ... REMOVE VALUE/reorder support for native enum types, so this uses the standard "recreate type" strategy: create a new type with the desired values, cast the column across withUSING column::text::new_type, drop the old type, then rename the new type into place. PassnativeType: trueand the originaltypeName.
import { alterEnumColumn } from '@quik/database';
public async up(schema, engine) {
await alterEnumColumn(engine, {
table: 'Order',
column: 'status',
values: ['pending', 'shipped', 'delivered', 'cancelled'],
nativeType: true,
typeName: 'order_status'
});
}
On MySQL, pass nullable/default explicitly if the column has them — MODIFY COLUMN redefines the column from
scratch and drops attributes that aren't restated.
Testing and Coverage
Run package tests with:
pnpm test
See the root instructions for coverage details.
API Reference
Generated API documentation is available in the database API section.