A small ActiveRecord/ActiveModel-style ORM for TypeScript, built on top of Knex. Models are plain classes; schema, validations, and lifecycle hooks are declared with decorators. Model (persisted, ActiveRecord-style) is built on AttributeModel (validated and dirty-tracked, but not persisted — ActiveModel-style, usable standalone for form objects and DTOs).
npm install @mikecx/thunderstorm knexknex is a peer dependency — bring your own version (3.x) plus whichever DB driver you need (pg, sqlite3, mysql2, ...). See Connecting below.
Requires Node 22+.
@Column()/@Validates()/etc. are standard TC39 (Stage 3) decorators — the TypeScript 5+ default. Do not set "experimentalDecorators": true in your own tsconfig.json; that opts into the older, incompatible decorator model and these decorators won't work correctly under it. No other special tsconfig.json settings are required — plain defaults are fine.
- Installation
- Connecting
- Defining a model
- AttributeModel: attributes without persistence
- CRUD
- Mass-assignment protection
- Convenience methods
- Serialization
- Querying
- Validations
- Callbacks
- Associations
- Avoiding N+1 queries
- Dirty tracking
- Attribute casting/serialization
- Virtual attributes and defaults
Collapsed below by default — click a heading to expand it.
- Many-to-many associations
- Polymorphic associations
- Column encryption
- Custom accessors/setters
- Delegate
- Scopes
- Default scopes
- Single-table inheritance
- Timestamps
- Enums
- SecurePassword
- SecureToken
- Optimistic locking
- Soft delete
- File attachments
- Dependent records on destroy
- Counter cache
- Touch
- Transactions
- Query logging
- Migrations
- Escape hatch: raw SQL
- TypeScript typing notes
connect() takes a Knex instance and stores it as the module-level connection every model uses. Call it once, before touching any model.
import knexFactory from 'knex';
import { connect } from './src/Model';
const knex = knexFactory({
client: 'sqlite3', // or 'pg', 'mysql2', ...
connection: { filename: './dev.sqlite3' },
useNullAsDefault: true,
});
connect(knex);import { Model } from './src/Model';
import { Column, PrimaryKey, Validates } from './src/decorators';
class User extends Model {
static tableName = 'users';
@PrimaryKey()
id!: number;
@Column()
@Validates({ presence: true, length: { min: 2, max: 50 } })
name!: string;
@Column()
@Validates({ presence: true })
@Validates({ format: { with: /^[^@\s]+@[^@\s]+\.[^@\s]+$/ }, message: 'is not a valid email address' })
email!: string;
}@Column()maps a class field to a database column; only columns registered this way are read/written bysave().@PrimaryKey()is@Column({ primary: true }). Defaults toidif no column is marked primary.- Each subclass owns its own column/validation/callback metadata — subclassing
Modelagain for an unrelated table starts from a clean slate.
Model is two layers: AttributeModel (attributes, validations, dirty tracking, serialization — no notion of a database) and Model extends AttributeModel, which adds persistence, querying, associations, and lifecycle callbacks. This mirrors Rails' ActiveModel/ActiveRecord split.
Extend AttributeModel directly for something validated and tracked like a record but that never gets saved — a form object, a search/filter object, an API request DTO:
import { AttributeModel } from './src/AttributeModel';
import { Column, Validates } from './src/decorators';
class SearchFilter extends AttributeModel {
@Column()
@Validates({ presence: true, length: { min: 2 } })
query!: string;
@Column({ default: 'relevance' })
sortBy!: string;
}
const filter = new SearchFilter({ query: 'thunderstorm' });
filter.isValid(); // true — @Column, @Validates, isValid(), errors, changes/isChanged, toJSON() all workNo tableName, no database connection, and none of Model's persistence surface (save, destroy, isPersisted, dup, reload, query, find, where) exists on it at all — nothing to accidentally call on something that was never going to be saved.
One consequence of having no save(): dirty tracking's snapshot never resets, since nothing ever establishes a new "saved" baseline. isChanged stays true for as long as any attribute differs from how the instance was originally constructed — there's no clean state to return to the way a persisted record has right after save().
Lifecycle callbacks (@BeforeSave/@AfterCreate/etc.) stay Model-only: they're tied to the save/destroy lifecycle by design (there's no beforeValidation/afterValidation — see Callbacks), so a plain AttributeModel gets none of them, matching Rails: ActiveModel::Callbacks isn't automatic on a plain object either, you'd have to declare your own callback names.
const user = await User.create({ name: 'Alice', email: 'alice@example.com' });
user.isPersisted; // true
const found = await User.find(user.id); // undefined if no match
const everyone = await User.all();
found!.email = 'alice@newdomain.com';
await found!.save(); // true, or false if invalid — see Validations
await found!.destroy(); // true, or false if a beforeDestroy callback blocked itsave() inserts when the record has no primary key yet, updates otherwise — and only sends the columns that actually changed (see Dirty tracking).
new Model(attrs), create(attrs), and update(attrs) are not guarded against untrusted input — they're used by trusted internal code too (dup(), seed scripts, admin tooling) that legitimately needs to set anything. Passing a web request body straight to them is exactly the mass-assignment vulnerability that motivated Rails' strong parameters (and its pre-2012 CVEs): User.create(req.body) sets any declared column, including ones you never meant to expose, e.g. role.
permit() is the fix — filter untrusted input down to a safe payload before it reaches create()/update():
class User extends Model {
@Column() name!: string;
@Column() email!: string;
@Column({ guarded: true }) role!: string; // never comes through permit(), no matter what
}
// An explicit allowlist, like Rails' params.permit(:name, :email) — the primary
// mechanism. Prefer this at every web boundary over relying on `guarded` alone.
await User.create(User.permit(req.body, ['name', 'email']));
// `guarded` columns are excluded even if you name them explicitly:
User.permit({ name: 'Alice', role: 'admin' }, ['name', 'role']); // => { name: 'Alice' }
// Unknown keys (not a declared @Column() at all) are always dropped too.permit() only ever returns declared @Column() keys that are also present in the raw input, always excludes the primary key, and always excludes any @Column({ guarded: true }) field regardless of the allowlist — think of allowedKeys as the primary allowlist mechanism (safe by construction: you opt fields in per form/endpoint) and guarded as defense in depth on top of it (a blocklist alone isn't enough — it only protects fields you remembered to mark). guarded also excludes a column from serializableHash()/toJSON()'s default output (see Serialization) — a password digest shouldn't come from untrusted input or go out in a response.
@Column({ readonly: true }) mirrors Rails' attr_readonly: the column is written on create() like any other, but excluded from every subsequent save()'s UPDATE.
class Invoice extends Model {
@Column() status!: string;
@Column({ readonly: true }) invoiceNumber!: string;
}
const invoice = await Invoice.create({ status: 'draft', invoiceNumber: 'INV-001' });
await invoice.update({ status: 'sent', invoiceNumber: 'INV-002' });
// status is now 'sent' in the DB; invoiceNumber is still 'INV-001' thereThis is not a hard runtime guard — invoice.invoiceNumber can still be assigned in JS and still shows up in changes/isChanged like any other dirty-tracked column (invoice.invoiceNumber reads back 'INV-002' after the call above). It's silently left out of the SQL, the same way virtual columns are excluded from INSERT/UPDATE but stay fully dirty-tracked in memory — if you need writes to actually throw, check isAttributeChanged('invoiceNumber') yourself before saving.
// Assign + save in one call. Same boolean/throwing contract as save()/saveOrFail().
await user.update({ email: 'alice@newdomain.com' }); // false if invalid, doesn't write
await user.updateOrFail({ email: 'alice@newdomain.com' }); // throws RecordInvalid/RecordNotSaved
// Find the first row matching conditions, or create one from conditions + defaults.
// defaults win over conditions on overlapping keys.
const user = await User.firstOrCreate({ email: 'alice@example.com' }, { name: 'Alice' });
// An unpersisted copy of a record's columns, excluding the primary key.
const copy = user.dup();
copy.isPersisted; // false
await copy.save(); // inserts as a new rowtoJSON() returns only declared, non-virtual, non-guarded @Column() values (see Virtual attributes and defaults and Mass-assignment protection for what "virtual"/"guarded" mean — a password digest, for instance, should never show up in a default API response) — not errors, not any ad-hoc property a preload attached (e.g. _posts), not internal bookkeeping. This is what JSON.stringify(user) calls automatically:
JSON.stringify(user); // '{"id":1,"name":"Alice","email":"alice@example.com"}'Without this, JSON.stringify walks every own enumerable property, including errors and anything preloadHasMany/preloadBelongsTo attached — which, if both sides of a relation were preloaded (user._posts[0]._author === user), is a circular structure that throws.
toJSON() is a thin wrapper around serializableHash(), which takes only/except/include for finer control — only/except can only narrow the safe default set further, never resurrect a virtual/guarded column; include is the escape hatch for that, pulling in extra own properties (a virtual/guarded column you deliberately want, or a preloadHasMany/preloadBelongsTo result) and recursively serializing any Model/Model[] found there:
user.serializableHash({ except: ['id'] }); // { name: 'Alice', email: '...' }
user.serializableHash({ only: ['name'] }); // { name: 'Alice' }
await User.preloadHasMany([user], Post, { foreignKey: 'userId', as: '_posts' });
user.serializableHash({ include: ['_posts'] }); // { id, name, email, _posts: [{ id, title, ... }, ...] }Model.where() and Model.all() both return a lazy, chainable QueryChain. Nothing hits the database until you await it or call a terminal method (.first(), .pluck(), .count(), .exists()). all() is equivalent to where({}) — every row, but still chainable with .order()/.limit(), unlike a plain "give me everything" method that just returns an array.
const bobs = await User.where({ name: 'Bob' });
const newestFirst = await User.all().order('createdAt', 'desc').limit(10);
const one = await User.where({ email: 'alice@example.com' }).first();
const names = await User.all().order('name', 'asc').pluck('name'); // string[] — just this column, no model instances
const activeCount = await User.where({ active: true }).count();
const hasAdmin = await User.where({ role: 'admin' }).exists();.all()/.where() load every matching row into memory at once. For a table that might not fit — a one-off migration script, an export job — use findEach/findInBatches instead: both page through the table via primary-key cursor (WHERE id > lastId ORDER BY id LIMIT batchSize, not OFFSET, which gets slower the deeper you page), one query per batch.
for await (const user of User.findEach({ batchSize: 500 })) {
await sendNewsletter(user);
}
for await (const batch of User.findInBatches({ batchSize: 500 })) {
await bulkUpdateSomewhereElse(batch);
}When .where({...})'s object shape can't express the condition you need — an OR, a raw SQL function — .whereRaw(sql, bindings) composes into the same chain instead of making you abandon it:
const admins = await User.where({ active: true }).whereRaw('role = ? OR role = ?', ['admin', 'owner']);See Escape hatch: raw SQL for what to reach for when even that isn't enough.
QueryChain has bulk update/delete methods, matching Rails' update_all/delete_all/destroy_all split:
await Session.where({ userId: user.id }).updateAll({ revoked: true }); // one UPDATE statement
await Session.where({ userId: user.id }).deleteAll(); // one DELETE statement, no callbacks
await Post.where({ authorId: user.id }).destroyAll(); // destroy() on each record, callbacks run.updateAll() issues a single bulk UPDATE with the given attributes (cast the same way where() casts condition values), no instantiation and no beforeSave/afterSave/beforeUpdate/afterUpdate callbacks.
.deleteAll() issues a single bulk DELETE and skips instantiating records entirely — beforeDestroy/afterDestroy callbacks never run, so anything they'd do (@HasManyAttached's auto-purge, a destroy() override) is skipped too. Use it when the target has no such side effects to preserve and you want one query instead of N — e.g. invalidating a user's sessions on password change.
.destroyAll() loads every matching row and calls destroy() on each in turn, so callbacks run exactly as if you'd called destroy() on each individually — this just saves writing the loop. All three return the count affected, not the count matched — for .destroyAll() those can differ if a beforeDestroy callback blocks some of them.
On the create side, Model.insertAll() is the bulk counterpart to create():
await Session.insertAll([
{ userId: 1, token: 'a', expiresAt },
{ userId: 2, token: 'b', expiresAt },
]); // one INSERT statementLike deleteAll(), this skips instantiation entirely — no defaults, no validations, and no beforeSave/beforeCreate/afterCreate callbacks run, so anything a model relies on one of those for (Timestamped's createdAt/updatedAt, SecureToken's token generation, SecurePassword's hashing) must be supplied directly in each row. This makes it a good fit for callback-light models — bulk-seeding, imports — not a drop-in replacement for looping create(). Each row's values still pass through the same column casting as create()/where(), so a caster-backed column (json, encryptedCaster) is written correctly. Returns the number of rows given, not a driver-reported count — unlike update/delete, a bulk insert either fully succeeds or throws.
Model.upsertAll() is insertAll()'s upsert sibling — one INSERT ... ON CONFLICT DO UPDATE statement instead of a read-then-write loop:
await Widget.upsertAll(
[
{ sku: 'ABC', stock: 10 },
{ sku: 'XYZ', stock: 5 },
],
{ conflictTarget: 'sku' } // must name a real unique index/constraint on `sku` — see below
);conflictTarget must name column(s) with a real unique index/constraint — same "pair with a DB constraint, this library won't create one for you" reasoning as @Validates({ uniqueness }). merge picks which columns get overwritten on a conflicting row; omit it to overwrite every column the insert supplied. Same tradeoffs as insertAll() otherwise (no defaults/validations/callbacks, returns rows.length not a driver-reported count).
.lock() is pessimistic locking — SELECT ... FOR UPDATE (default) or .lock('share') for FOR SHARE — a thin wrapper over Knex's own .forUpdate()/.forShare(). Only meaningful inside transaction(): Postgres/MySQL hold the lock until the surrounding transaction commits or rolls back, so use it to serialize concurrent access to a row while you read-then-write it:
await transaction(async () => {
const account = await Account.where({ id }).lock().first(); // other transactions block here until this one commits
await account!.update({ balance: account!.balance - amount });
});Outside a transaction there's nothing to hold the lock across, so it's released the instant the SELECT completes — pointless, though harmless, to call it there. SQLite has no row-level locking at all and silently ignores it (it already locks at the database/table level for writes).
@Validates({...}) stacks — apply it more than once on the same field to accumulate rules.
@Column()
@Validates({ presence: true })
@Validates({ length: { min: 2, max: 50 } })
@Validates({ inclusion: { in: ['admin', 'member'] }, allowBlank: true })
@Validates({ validator: (value) => (value < 0 ? 'must not be negative' : null) })
role!: string;Supported keys: presence, length: { min, max }, format: { with: RegExp }, inclusion: { in: [...] }, validator: (value, instance) => string | null | undefined, allowBlank (skip length/format/inclusion when the value is null/undefined/''), and message to override the default text.
uniqueness is different from every other key above: it needs a database query, so it's the one Model-only validation (AttributeModel has no table to check against) and it doesn't run from the synchronous isValid() — only save()/saveOrFail() check it, right after the in-memory validations pass:
@Column()
@Validates({ presence: true })
@Validates({ uniqueness: true })
email!: string;
@Column()
@Validates({ uniqueness: { scope: 'organizationId' }, message: 'is already taken in this organization' })
handle!: string;Updating a record to its own current value doesn't flag itself (excluded by primary key). This is a UX nicety, not a hard guarantee — a concurrent request can still slip a duplicate in between the check and the write (classic TOCTOU); pair it with a real unique index/constraint in the migration for actual correctness under concurrency.
For cross-field checks, override the validate() hook:
class SignupForm extends Model {
@Column() password!: string;
@Column({ virtual: true }) passwordConfirmation!: string; // no such DB column — see below
protected validate(): void {
if (this.password !== this.passwordConfirmation) {
this.errors.add('passwordConfirmation', "doesn't match password");
}
}
}Checking validity:
user.isValid(); // runs validations, returns boolean, populates user.errors
user.errors.on('email'); // ["is not a valid email address"]
user.errors.full; // ["email is not a valid email address", ...]save() always validates first and returns false (without writing) when invalid, rather than throwing. Use saveOrFail() when you want an exception instead:
try {
await user.saveOrFail();
} catch (err) {
if (err instanceof RecordInvalid) {
/* user.errors is populated */
}
if (err instanceof RecordNotSaved) {
/* a before* callback aborted a *valid* record */
}
}Eight method decorators cover the save/destroy lifecycle: @BeforeSave/@AfterSave, @BeforeCreate/@AfterCreate, @BeforeUpdate/@AfterUpdate, @BeforeDestroy/@AfterDestroy. Multiple methods can register for the same hook and run in declaration order.
class Post extends Model {
@Column() title!: string;
@Column() slug!: string;
@Column() pinned!: number;
@BeforeCreate()
generateSlug() {
this.slug = this.title.toLowerCase().replace(/\s+/g, '-');
}
@BeforeDestroy()
blockDestroyIfPinned() {
if (this.pinned) return false; // returning false halts the operation
}
}Order on save(): beforeSave → beforeCreate/beforeUpdate → INSERT/UPDATE → afterCreate/afterUpdate → afterSave. On destroy(): beforeDestroy → DELETE → afterDestroy. A before* callback that returns (or resolves to) false halts the chain — save()/destroy() return false without touching the database, mirroring Rails' throw :abort.
beforeValidation/afterValidation aren't implemented — wiring them up would make isValid() async, which would break its current synchronous contract for no clear win in a scaffold like this.
Define a plain method on the model that calls hasMany/hasOne/belongsTo. Pass the related class directly — no thunk needed, since the reference is only resolved when the method runs (well after both modules have finished loading), even across circular imports between two model files.
class User extends Model {
posts() {
return this.hasMany(Post, { foreignKey: 'userId' });
}
}
class Post extends Model {
author() {
return this.belongsTo(User, { foreignKey: 'userId' });
}
}hasManyreturns aQueryChain—await user.posts(), or scope it first:user.posts().order('title', 'asc').first().hasOne/belongsToreturn aPromisedirectly —await post.author().
hasOne/belongsTo (and their polymorphic equivalents) are memoized per instance: calling post.author() twice only queries once.
await post.author(); // queries
await post.author(); // cached — no queryPass { reload: true } to force a fresh load for one call, or call record.reload() to clear every cached association on that instance at once (along with its own attributes). hasMany/hasManyThrough/hasAndBelongsToMany/hasManyPolymorphic are not memoized — they return a QueryChain, and staying lazy/chainable (user.posts().where(...)) is more valuable than caching here, since QueryChain.where() mutates the chain in place rather than returning a copy, so a cached chain could get silently corrupted by an unrelated caller's further scoping.
See Many-to-many associations and Polymorphic associations below for the less-common shapes.
Looping over records and calling a relation method fires one query per record. preloadHasMany/preloadBelongsTo batch-fetch in a single WHERE ... IN (...) query and attach the results onto each record under a name you choose:
const users = await User.all();
// naive: 1 query per user
for (const u of users) await u.posts();
// batched: 1 query total
await User.preloadHasMany(users, Post, { foreignKey: 'userId', as: '_posts' });
users[0]._posts; // Post[]
await Post.preloadBelongsTo(posts, User, { foreignKey: 'userId', as: '_author' });
posts[0]._author; // User | undefinedPick an as name that doesn't collide with a same-named relation method — it's assigned as a plain own property, which would shadow a prototype method of the same name. The many-to-many and polymorphic association sections below have their own preload* equivalents.
Every model tracks its column values against a snapshot taken on load/save, ActiveModel::Dirty-style:
const user = await User.find(1);
user.isChanged; // false
user.email = 'new@example.com';
user.isChanged; // true
user.isAttributeChanged('email'); // true
user.changes; // { email: ['old@example.com', 'new@example.com'] }
await user.save();
user.isChanged; // false — reset after a successful save
user.previousChanges; // { email: ['old@example.com', 'new@example.com'] }
user.email = 'discard-me@example.com';
await user.reload(); // re-fetches from the DB, discarding the unsaved changeReassigning a column to its current value doesn't mark it changed (compared with Object.is). save()'s UPDATE path only writes columns present in changes (a partial write) — two in-memory copies of the same row editing different columns can both save() without clobbering each other's edit.
@Column({ type: ... }) converts between the raw DB value and the JS attribute value on load/save. Built-in types: 'string', 'number', 'boolean', 'date', 'json'.
@Column({ type: 'boolean' }) paid!: boolean; // sqlite 0/1 <-> real JS boolean
@Column({ type: 'date' }) placedAt!: Date; // ISO string column <-> real Date instance
@Column({ type: 'json' }) metadata!: Record<string, any>; // JSON text column <-> real objectboolean.save passes the value through unchanged — Knex's sqlite3 dialect already converts true/false to 1/0 for every param, and Postgres/MySQL drivers accept real booleans natively, so converting here too would only break those dialects.
Dirty tracking (changes/isChanged) compares Date values by timestamp, not by reference, so assigning a new Date instance representing the same moment doesn't count as a change.
For anything the built-ins don't cover, pass a custom caster instead of a type name:
@Column({ type: { load: (raw) => new Decimal(raw), save: (value) => value.toString() } })
price!: Decimal;See Column encryption below for a real-world custom caster.
@Column({ virtual: true }) declares an attribute that's tracked, validated, and dirty-tracked exactly like any other column, but is never sent to the database — excluded from INSERT/UPDATE and from the default toJSON()/serializableHash() output. Use it for fields that only make sense in memory: password confirmation, a search form's filter params, anything with no backing column.
class SignupForm extends Model {
@Column() password!: string;
@Column({ virtual: true }) passwordConfirmation!: string;
}@Column({ default: value }) fills in the attribute in the constructor when it's still undefined — it never overrides an explicitly-provided value, and never applies to a record loaded from the database (a loaded row's real value, even NULL, always wins). Use a function for mutable defaults (objects/arrays): a bare literal default would be the same shared object across every instance that doesn't set it explicitly, so mutating one instance's default would corrupt every other one.
@Column({ default: 'pending' }) status!: string; // primitives are safe as literals
@Column({ type: 'json', default: () => ({}) }) metadata!: Record<string, any>; // objects need a functionTwo ways to model many-to-many, matching Rails' own distinction. Both stay lazy/chainable like hasMany — one query with a WHERE targetPk IN (subquery), not two sequential round-trips.
hasManyThrough — the join table is a real Model, so it can carry its own columns, and adding/removing a row is just ordinary create()/destroy():
class PostTag extends Model {
static tableName = 'postTags';
@PrimaryKey() id!: number;
@Column() postId!: number;
@Column() tagId!: number;
}
class Post extends Model {
tags() {
return this.hasManyThrough(Tag, PostTag, { sourceKey: 'postId', targetKey: 'tagId' });
}
}
await PostTag.create({ postId: post.id, tagId: tag.id }); // associate
await post.tags(); // Tag[]
await join.destroy(); // dissociatehasAndBelongsToMany — a bare join table, no Model needed, at the cost of nowhere to put extra columns later. Since there's no create()/destroy() to call on a nonexistent model, pair it with associate/dissociate:
class Post extends Model {
favoriteTags() {
return this.hasAndBelongsToMany(Tag, { joinTable: 'postsFavoriteTags', sourceKey: 'postId', targetKey: 'tagId' });
}
addFavoriteTag(tag: Tag) {
return this.associate(Tag, { joinTable: 'postsFavoriteTags', sourceKey: 'postId', targetKey: 'tagId' }, tag);
}
removeFavoriteTag(tag: Tag) {
return this.dissociate(Tag, { joinTable: 'postsFavoriteTags', sourceKey: 'postId', targetKey: 'tagId' }, tag);
}
}
await post.addFavoriteTag(tag);
await post.favoriteTags(); // Tag[]
await post.removeFavoriteTag(tag);Neither guards against inserting the same pair twice — pair with a unique index on (sourceKey, targetKey) in the migration for that, same as @Validates({ uniqueness }) elsewhere in this library is a UX nicety, not a concurrency guarantee.
preloadHasManyThrough/preloadHasAndBelongsToMany batch like preloadHasMany — see Avoiding N+1 queries:
await Post.preloadHasManyThrough(posts, Tag, PostTag, { sourceKey: 'postId', targetKey: 'tagId', as: '_tags' });
await Post.preloadHasAndBelongsToMany(posts, Tag, {
joinTable: 'postsFavoriteTags',
sourceKey: 'postId',
targetKey: 'tagId',
as: '_favoriteTags',
});A record can belongsTo one of several possible target types (Rails' belongs_to :commentable, polymorphic: true) by pairing an id column with a type column:
const COMMENTABLE_TYPES = { post: Post, photo: Photo }; // stable strings you choose, mapped to the classes they mean
class Comment extends Model {
static tableName = 'comments';
@PrimaryKey() id!: number;
@Column() commentableId!: number;
@Column() commentableType!: string;
commentable() {
return this.belongsToPolymorphic({ idField: 'commentableId', typeField: 'commentableType' }, COMMENTABLE_TYPES);
}
}
class Post extends Model {
comments() {
return this.hasManyPolymorphic(Comment, {
idField: 'commentableId',
typeField: 'commentableType',
typeValue: 'post',
});
}
}Unlike Rails' default of using the class name as the type string (commentable_type = 'Post'), thunderstorm requires an explicit map (COMMENTABLE_TYPES above) — consistent with tableName/foreignKey always being explicit elsewhere in this library, and it means renaming a model class can never silently orphan every row that already references it under the old name.
belongsToPolymorphicresolves to the right target class based on the type column's value, orundefinedif the type string isn't in the map (an unrecognized/legacy type) or the id isnull.hasManyPolymorphic/hasOnePolymorphicare the reverse side — same shape ashasMany/hasOne, plus atypeValuethat must also match, so a comment on aPhotowith the same numeric id as aPostnever leaks in.preloadHasManyPolymorphicbatches likepreloadHasMany(one query).preloadBelongsToPolymorphiccan't stay to one query the waypreloadBelongsTodoes — since different records may reference different target tables, it groups by the type column first and runs one batched query per distinct type actually present, still bounded rather than one query per record:
await Post.preloadHasManyPolymorphic(posts, Comment, {
idField: 'commentableId',
typeField: 'commentableType',
typeValue: 'post',
as: '_comments',
});
await Comment.preloadBelongsToPolymorphic(comments, {
idField: 'commentableId',
typeField: 'commentableType',
types: COMMENTABLE_TYPES,
as: '_commentable',
});encryptedCaster() is exactly that kind of custom caster — there's no separate decorator for encryption, just @Column({ type: encryptedCaster({ keys: [key] }) }). AES-256-GCM via Node's built-in crypto, no external dependency:
import { encryptedCaster } from '@mikecx/thunderstorm';
const key = Buffer.from(process.env.ENCRYPTION_KEY!, 'base64'); // exactly 32 bytes
class Patient extends Model {
static tableName = 'patients';
@PrimaryKey() id!: number;
@Column({ type: encryptedCaster({ keys: [key] }) })
diagnosis!: string; // non-deterministic (default): safest, but not queryable at all
@Column({ type: encryptedCaster({ keys: [key], deterministic: true }) })
@Validates({ uniqueness: true })
ssn!: string; // deterministic: same plaintext -> same ciphertext, so where()/uniqueness still work
}
await Patient.where({ ssn: '111-11-1111' }); // matches — deterministic columns are queryable
await Patient.where({ diagnosis: 'flu' }); // never matches — non-deterministic ciphertext differs every writeDeterministic vs. non-deterministic is the one real choice: deterministic encryption keeps a column queryable (where(), @Validates({ uniqueness })) because the same plaintext always produces the same ciphertext under a given key — at the cost of leaking equality: anyone who can read ciphertexts can tell which rows share a value, and on low-cardinality data (a small enumerable set of possible plaintexts) can guess values by comparing against ciphertexts they've computed themselves. Non-deterministic encryption (the default) uses a random IV per write, so identical plaintexts produce different ciphertext every time — safer, but genuinely unqueryable; there's no way to where() against it.
Key rotation: keys is a list, tried in order on decrypt — put the new key first (used for all new writes) and keep old keys after it so existing rows still decrypt:
encryptedCaster({ keys: [newKey, oldKey] });Rotation isn't automatic re-encryption, though — reading and re-saving the same value won't rewrite it, since dirty tracking only sends columns whose JS-level value actually changed. Migrating existing rows off an old key needs to force the write (e.g. a script that reads, then writes back a value dirty tracking will actually notice as changed, or a raw UPDATE).
@Column() can decorate a getter/setter pair instead of a plain field — normal TypeScript accessors, no separate framework API needed. Decorate only the first of the pair (a TS requirement for accessors in general):
class Order extends Model {
// `declare`d, not a real field — see the callout below.
declare private _code: string;
@Column()
get code(): string {
return this._code;
}
set code(value: string) {
this._code = value.trim().toUpperCase();
}
}
new Order({ code: ' ab-123 ' }).code; // 'AB-123'Gotcha: the backing field must be declared with declare (declare private _code: string;), not as a real field — including with a definite assignment assertion (private _code!: string;). Model's constructor sets attributes via Object.assign(this, attrs) inside super(...), which runs your setter — but TC39 decorators require standards-compliant class-fields semantics, so once any field in the class is decorated, every ordinary field declaration (even one with no initializer) gets an implicit this._code = undefined inserted right after super() returns, silently clobbering whatever the setter just wrote. declare tells TypeScript the property exists without emitting any field-initialization code for it, sidestepping the issue entirely.
The setter also runs when loading a row from the database (there's no separate "internal write" path), so keep it idempotent — normalizing an already-normalized value should be a no-op.
@Delegate(['name', 'email'], { to: 'author' }) on a class with an author() relation method generates async authorName()/authorEmail() methods forwarding through it — equivalent to (await post.author())?.name.
@Delegate(['name'], { to: 'author' })
class Post extends Model {
author() {
return this.belongsTo(User, { foreignKey: 'userId' });
}
}
await post.authorName(); // string | undefinedThe relation is awaited (associations here are lazy/async, unlike Rails' in-memory objects). The generated method isn't visible to the type checker on its own — see TypeScript typing notes for how to type it.
A scope is just a static method returning (or extending) this.where(...) — no decorator, the same pattern as hasMany/belongsTo:
class Post extends Model {
static published<T extends typeof Post>(this: T) {
return this.where({ published: 1 } as any);
}
}
await Post.published(); // Post[]
await Post.published().order('title', 'asc'); // still a QueryChain — chain freelyTo compose scope-shaped functions together (rather than methods on the same class), QueryChain has an .apply() combinator:
const byRecency = (chain: QueryChain<typeof Post>) => chain.order('createdAt', 'desc');
await Post.published()
.apply(byRecency)
.apply((c) => c.limit(10));@DefaultScope registers a query modifier that's automatically applied to every read — find/all/where/findInBatches/findEach, associations, and preloads — unlike an ordinary scope, which you call explicitly:
import { Model, Column, PrimaryKey, DefaultScope } from '@mikecx/thunderstorm';
@DefaultScope((qb) => qb.where('archived', 0))
class Post extends Model {
static tableName = 'posts';
@PrimaryKey()
id!: number;
@Column()
archived!: number;
}
await Post.all(); // only non-archived postsStacking @DefaultScope more than once ANDs the conditions together, and a subclass inherits its parent's default scopes on top of its own — same accumulate-on-inherit behavior as @Validates. Call Model.unscoped() to get a plain QueryChain with every default scope bypassed:
await Post.unscoped(); // every post, archived or notDefault scopes only reach read paths — save()/destroy()/reload() still operate directly by primary key, unscoped, since they're acting on a specific record the caller already holds a reference to, not running a general listing query.
@STI(typeValue) lets several subclasses share one table, discriminated by a type column the base class declares itself:
import { Model, Column, PrimaryKey, STI } from '@mikecx/thunderstorm';
class Vehicle extends Model {
static tableName = 'vehicles';
@PrimaryKey() id!: number;
@Column() type!: string; // required — the STI discriminator column
@Column() make!: string;
}
@STI('car')
class Car extends Vehicle {
@Column() doors!: number;
}
@STI('truck')
class Truck extends Vehicle {
@Column() bedLength!: number;
}
const car = await Car.create({ make: 'Honda', doors: 4 });
car.type; // 'car' — stamped automatically
await Car.all(); // only cars
await Vehicle.all(); // every vehicle, each instantiated as the right subclass (Car/Truck)@STI is built on @DefaultScope: it registers a scope filtering by type (so a subclass's all()/where()/find() only ever see its own rows) and overrides the type column's default for that subclass, the same way any other @Column({ default }) works. Querying the base class directly (Vehicle.all()) returns rows of every type, each instantiated as its actual subclass rather than always Vehicle.
The type value is always an explicit string you choose (@STI('car')), never inferred from the class name — the same rule polymorphic associations' type map already follows, so renaming a class can never silently orphan rows already written under the old name.
Only single-level hierarchies are supported — a subclass of a @STI-decorated class would inherit its default scope's type filter too (default scopes accumulate, they don't replace), which isn't what you want for a deeper hierarchy. Keep @STI one level below the table-owning base class.
Timestamped(Base) is a mixin — not a decorator — that adds createdAt/updatedAt as real, statically-typed Date properties, auto-stamped via beforeCreate/beforeUpdate callbacks:
class Post extends Timestamped(Model) {
static tableName = 'posts';
@PrimaryKey() id!: number;
@Column() title!: string;
}
const post = await Post.create({ title: 'Hello' });
post.createdAt; // Date, set once
post.updatedAt; // Date, bumped on every saveIt's a mixin rather than @Timestamps() specifically so post.createdAt type-checks with no extra work — see TypeScript typing notes for why that matters and how @Delegate/@Enum differ.
@Enum('status', { draft: 0, published: 1, archived: 2 }) maps a raw column to named labels without touching how the raw column itself is read/written — keep a normal @Column() on it. Generates:
@Enum('status', { draft: 0, published: 1, archived: 2 })
class Post extends Model {
@Column() status!: number; // still a plain int column
}
post.statusLabel; // 'draft' — a getter over the raw value
post.isDraft(); // true — one is<Label>() predicate per label
Post.withStatus('draft'); // QueryChain<Post> — a static scope, throws on an unknown labelSecurePassword(Base) is a mixin (same pattern as Timestamped) mirroring Rails' has_secure_password: hardcoded columns password (virtual), passwordConfirmation (virtual), passwordDigest (real, guarded), and an authenticate() method. Hashed with Node's built-in scrypt — no bcrypt dependency.
class User extends SecurePassword(Model) {
static tableName = 'users';
@PrimaryKey() id!: number;
@Column() email!: string;
}
const user = await User.create({ email: 'alice@example.com', password: 'hunter2' });
user.passwordDigest; // a salted scrypt hash — never the plaintext
await user.authenticate('hunter2'); // true
await user.authenticate('wrong'); // false
await user.update({ email: 'alice@newdomain.com' }); // doesn't touch/re-hash the password
await user.update({ password: 'newpassword' }); // re-hashes; the old password stops workingpasswordis required on create; leaving it out on a laterupdate()leaves the existing digest alone — dirty tracking (isAttributeChanged('password')) is what makes "did this save actually touch the password" answerable, since a virtual attribute likepasswordnever auto-clears after use.- If
passwordConfirmationis set, it must matchpassword, or the save fails with apasswordConfirmationerror. passwordDigestisguarded: it can never come frompermit()-filtered input, and it's excluded fromserializableHash()/toJSON()'s default output.
SecureToken(Base) generates a random URL-safe token column on create if one wasn't provided, plus regenerateToken() — mirroring has_secure_token. Useful for API keys, invite links, "remember me" tokens.
class ApiKey extends SecureToken(Model) {
static tableName = 'api_keys';
@PrimaryKey() id!: number;
@Column() label!: string;
}
const key = await ApiKey.create({ label: 'CI' });
key.token; // a random 24-byte, base64url-encoded string
await key.regenerateToken(); // replaces and persists a new tokentoken is guarded, same reasoning as passwordDigest — it's server-generated, never something that should arrive via mass-assigned input or leak into a default API response.
Lockable(Base) adds a lockVersion column (starts at 0) and mirrors ActiveRecord::Locking::Optimistic: save()/update()/destroy() include WHERE lockVersion = ... and throw StaleObjectError if zero rows were affected — someone else changed or deleted the record first.
class Post extends Lockable(Model) {
static tableName = 'posts';
@PrimaryKey() id!: number;
@Column() title!: string;
}
const post = await Post.create({ title: 'Draft' });
const staleCopy = await Post.find(post.id);
await post.update({ title: 'Published' }); // lockVersion 0 -> 1
await staleCopy!.update({ title: 'Also published' }); // throws StaleObjectError — staleCopy is still at lockVersion 0Unlike a normal validation failure, StaleObjectError is thrown rather than reported via errors/a false return — even from plain save()/update(), not just the *OrFail() variants — since a lost race isn't safe to silently ignore. Catch it where you can meaningfully recover (reload and retry, surface a conflict to the user).
Model.insertAll()/QueryChain.updateAll()/deleteAll()/destroyAll() don't go through save()/destroy(), so they bypass the lock check entirely — same trade-off as skipping callbacks/validations.
SoftDelete(Base) adds a deletedAt column and a default scope excluding non-null rows, mirroring gems like paranoia/discard: destroy() on a soft-deletable model does an UPDATE setting deletedAt instead of a DELETE, still running beforeDestroy/afterDestroy callbacks around it — the row stays in the table, just invisible to normal reads.
class Post extends SoftDelete(Model) {
static tableName = 'posts';
@PrimaryKey() id!: number;
@Column() title!: string;
}
const post = await Post.create({ title: 'Draft' });
await post.destroy();
await Post.find(post.id); // undefined — excluded by the default scope
post.isPersisted; // true — the row still exists
post.isDeleted; // true
const trashed = (await Post.unscoped()).find((p) => p.id === post.id)!;
await trashed.restore(); // clears deletedAt
await Post.find(post.id); // backrestore()/isDeleted are always present on every Model (like reload()), a no-op/false/throwing guard on a model without a deletedAt column rather than something only added by the mixin — the same convention Lockable's checks already follow. restore() doesn't run callbacks or go through the Lockable check, the same documented simplification insertAll()/deleteAll() already make elsewhere.
Since it's built on @DefaultScope, SoftDelete composes with everything that mechanism reaches — a soft-deleted record disappears from where()-based associations and preloads too, not just find/all.
Attach files to a record without thunderstorm ever touching the actual bytes: it persists blob metadata (key, filename, content type, byte size) and orchestrates two functions you provide — put/delete — so key generation and storage cleanup are guaranteed, not opt-in caller discipline.
export interface BlobStorage {
put(key: string, data: Buffer, contentType: string): Promise<void>;
delete(key: string): Promise<void>;
}BlobStorage is deliberately not a full "storage service" — no get, no url, no variants. Serving a file back out (signed URLs, a CDN path, a static file route) is a deployment-specific, controller-shaped concern that stays entirely yours; thunderstorm only guarantees the bytes get written when you attach and deleted when you purge/destroy.
import { Model, HasOneAttached, PrimaryKey, Column } from '@mikecx/thunderstorm';
const avatarStorage: BlobStorage = {
put: (key, data, contentType) =>
s3
.send(new PutObjectCommand({ Bucket: 'my-bucket', Key: key, Body: data, ContentType: contentType }))
.then(() => {}),
delete: (key) => s3.send(new DeleteObjectCommand({ Bucket: 'my-bucket', Key: key })).then(() => {}),
};
@HasOneAttached('avatar', avatarStorage)
class User extends Model {
static tableName = 'users';
@PrimaryKey() id!: number;
@Column() email!: string;
}
interface User {
avatarKey: string | null;
avatarFilename: string | null;
avatarContentType: string | null;
avatarByteSize: number | null;
readonly avatarAttached: boolean;
attachAvatar(data: Buffer, meta: { filename: string; contentType: string }): Promise<void>;
purgeAvatar(): Promise<void>;
}
await user.attachAvatar(req.file.buffer, { filename: req.file.originalname, contentType: req.file.mimetype });
user.avatarAttached; // true
await user.destroy(); // avatar's blob is deleted automatically — no manual purge() neededstorage is required, not optional — a model can't be decorated without a way to clean up after itself. Add the migration with attachmentColumns(), which keeps the column names in lockstep with what @HasOneAttached reads at runtime instead of hand-typed boilerplate that could drift:
import type { Knex } from 'knex';
import { attachmentColumns } from '@mikecx/thunderstorm';
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('users', (t) => attachmentColumns(t, 'avatar'));
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('users', (t) => {
t.dropColumn('avatarKey');
t.dropColumn('avatarFilename');
t.dropColumn('avatarContentType');
t.dropColumn('avatarByteSize');
});
}Like @Delegate/@Enum, the generated members (avatarKey, attachAvatar, ...) come from the runtime 'avatar' string, so they're not visible to the type checker on their own — declare them via interface merging as shown above.
Unlike Rails' polymorphic active_storage_attachments, this is a real, ordinary @Column()-backed table with a real foreign key — thunderstorm has no polymorphic associations, so each attachment point gets its own table, defined the same way any other model is:
class PostImage extends Model {
static tableName = 'postImages';
@PrimaryKey() id!: number;
@Column() postId!: number;
@Column() key!: string;
@Column() filename!: string;
@Column() contentType!: string;
@Column() byteSize!: number;
}
@HasManyAttached('images', PostImage, { foreignKey: 'postId' }, imageStorage)
class Post extends Model {
static tableName = 'posts';
@PrimaryKey() id!: number;
@Column() title!: string;
}
interface Post {
images(): Promise<PostImage[]>;
attachImages(data: Buffer, meta: { filename: string; contentType: string }): Promise<PostImage>;
purgeImages(): Promise<void>;
}
const image = await post.attachImages(buffer, { filename: 'cover.png', contentType: 'image/png' });
for (const image of await post.images()) {
/* ... */
}
await post.destroy(); // every attached image's blob is deleted automaticallyimages() is just this.hasMany(PostImage, { foreignKey: 'postId' }) under the hood — only the storage orchestration is new, not the association. Member names are used exactly as given (no pluralization/singularization guessing) — HasManyAttached('images', ...) gives you attachImages/purgeImages, matching @Enum's existing convention of deriving names directly from the literal string rather than guessing grammar.
The table itself is created with createAttachmentTable(), same lockstep-with-the-decorator reasoning as attachmentColumns() above:
import { createAttachmentTable } from '@mikecx/thunderstorm';
export async function up(knex: Knex): Promise<void> {
await createAttachmentTable(knex, 'postImages', { foreignKey: 'postId' });
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable('postImages');
}- No image variants/thumbnails, no direct-upload tokens, no signed-URL helpers — bring your own (
sharp, your storage SDK's presigner). - Gotcha: if
storage.putsucceeds but the following database write then fails (a validation error, a dropped connection),attachAvatar/attachImagesbest-effort callsstorage.deleteon the now-orphaned key — but a delete failure there won't mask the original save error, so a narrow leak window remains under storage flakiness at exactly the wrong moment. - Destroy-time cleanup is synchronous and best-effort, not transactional: there's no job queue backing it, so a
storage.deletefailure duringdestroy()makes the whole call reject even though the database row is already gone — you'll see the error and know to retry cleanup, it won't silently leak. - Buffers only, no streaming — the whole file needs to be in memory to compute
byteSize, unlike Rails' IO-stream-based blobs. Fine for avatars/typical uploads, not huge files.
By default, destroying a record does nothing to whatever references it — post.destroy() leaves any Comment rows with a now-dangling postId. @Dependent(associationMethod, action) wires that up, Rails' dependent: option:
class Post extends Model {
static tableName = 'posts';
comments() {
return this.hasMany(Comment, { foreignKey: 'postId' });
}
}
@Dependent('comments', 'destroy')
class PostWithComments extends Post {}It's a class decorator naming an already-defined association method, not a parameter on hasMany/hasOne themselves — those are plain per-call methods in this library, not a persistent association registry a decorator param could hook into (see Associations). The association is declared exactly as it always is; @Dependent just registers beforeDestroy/afterDestroy callbacks that call it.
Four actions, applying to both hasMany-shaped associations (hasMany/hasManyThrough/hasAndBelongsToMany, detected at runtime by the association method's return value having .destroyAll) and single-record ones (hasOne/belongsTo):
@Dependent('comments', 'destroy') // destroy() on each — its own callbacks run too
@Dependent('sessions', 'delete') // bulk DELETE, no callbacks
@Dependent('comments', { update: { postId: null } }) // bulk UPDATE — nullify the foreign key
@Dependent('orders', 'restrict') // block the destroy if any order still exists
class Post extends Model {
/* ... */
}'destroy'— fulldestroy()lifecycle per related record, including e.g.@HasManyAttached's own purge if the target has one.'delete'— bulkDELETE(deleteAll()for a chain; a single scopedDELETEfor one record) — no callbacks either way, which is the point, not just fewer queries.{ update: {...} }— bulkUPDATE(updateAll()for a chain,update()for one record) — the common case is nullifying a foreign key rather than deleting the related rows at all.'restrict'— abeforeDestroycheck: if the association still has anything, the destroy is blocked (returnsfalse) and nothing is touched.
Stack multiple @Dependents on the same class for different associations. There's no DB-level foreign-key cascade here — if you also add a real ON DELETE CASCADE/SET NULL constraint in a migration, Postgres will additionally enforce it at the schema level, but won't run any thunderstorm callbacks when it fires.
@CounterCache(associationMethod, column) auto-maintains a count column on the other side of a belongsTo/hasOne association — mirrors belongs_to ..., counter_cache: true. Declared on the child (the side with the foreign key):
class Post extends Model {
static tableName = 'posts';
@PrimaryKey() id!: number;
@Column({ default: 0 }) commentsCount!: number; // you declare this column yourself
}
@CounterCache('post', 'commentsCount')
class Comment extends Model {
static tableName = 'comments';
@PrimaryKey() id!: number;
@Column() postId!: number;
post() {
return this.belongsTo(Post, { foreignKey: 'postId' });
}
}
const post = await Post.create({});
await Comment.create({ postId: post.id }); // post.commentsCount, reloaded, is now 1Only create and destroy are handled — incrementing/decrementing via .increment()/.decrement() (one atomic UPDATE ... SET commentsCount = commentsCount + 1, not a load-then-save round trip, so concurrent creates/destroys can't clobber each other's count). Reassigning postId on an existing Comment to a different post does not move the count — that would need the foreign key column name threaded through explicitly to diff the old and new parent. Update both counts by hand if you need that.
instance.touch(...names) bumps updatedAt (plus any other column names you pass) to now via a direct UPDATE — Rails' touch. It skips validations and every callback except the write itself. Only meaningful on a Timestamped model, since that's what owns updatedAt:
class Post extends Timestamped(Model) {
static tableName = 'posts';
@PrimaryKey() id!: number;
@Column() title!: string;
}
const post = await Post.create({ title: 'Hello' });
await post.touch(); // UPDATE posts SET updatedAt = ? WHERE id = ?
await post.touch('publishedAt'); // also bumps publishedAt to now@Touch(associationMethod) cascades this up to a belongsTo/hasOne parent whenever the child is created, saved, or destroyed — mirrors belongs_to ..., touch: true. Declared on the child, naming the association method that resolves the parent, the same shape @CounterCache/@Dependent use:
@Touch('post')
class Comment extends Timestamped(Model) {
static tableName = 'comments';
@PrimaryKey() id!: number;
@Column() postId!: number;
post() {
return this.belongsTo(Post, { foreignKey: 'postId' });
}
}
const comment = await Comment.create({ postId: post.id, body: 'First' }); // post.updatedAt is bumped toonoTouching(fn) (also available as the static alias Model.noTouching(fn)) suppresses every touch() call — direct or @Touch-cascaded — for the duration of fn, Rails' no_touching. It's backed by AsyncLocalStorage, the same mechanism transaction() uses, rather than a global flag — Node has no per-request thread isolation to make a global safe under concurrent unrelated requests the way Rails' thread-local is:
import { noTouching } from './src/Model';
await noTouching(async () => {
await comment.update({ body: 'Fixing a typo, not really a meaningful edit' });
// post.updatedAt is left alone
});transaction(fn) runs fn inside a real database transaction. Every Model call made anywhere inside it — including in nested async calls — implicitly participates, with no trx parameter to thread through anything: it's backed by Node's AsyncLocalStorage, so getKnex() resolves to the active transaction automatically for the lifetime of that async context.
import { transaction } from './src/Model';
await transaction(async () => {
alice.balance -= 30;
await alice.save();
bob.balance += 30;
await bob.save();
// throwing anywhere in here rolls back both writes
});Resolves/commits if fn resolves, rejects/rolls back if fn throws. Model.transaction(fn) is an equivalent static alias. Calling transaction() again while already inside one just reuses the same transaction (no savepoints/nested transactions).
logQueries(knex, formatter?) logs every query run through that connection with real per-query timing — the closest thing here to Rails' dev-log Model Load (2.1ms) SELECT ... lines:
import { logQueries } from '@mikecx/thunderstorm';
if (process.env.NODE_ENV !== 'production') {
logQueries(knex);
}(0.4ms) select * from "widgets" where "id" = $1 [1]
The default formatter is a plain one-liner; pass your own (info: { sql, bindings, ms }) => void for colorized output, routing to a real logger, filtering slow queries, etc. Both formatter and whether to call logQueries() at all (dev-only, say) are left to the caller — this only centralizes the fiddly part: correlating Knex's query (before) and query-response/query-error (after) events to compute actual duration, since neither carries timing on its own.
Schema lives in migrations/, run via the standard Knex CLI (knexfile.ts at the project root, development and test sqlite environments already configured):
npm run migrate:make -- create_widgets # scaffold a new migration
npm run migrate:latest # apply pending migrations
npm run migrate:rollback # roll back the last batch
npm run migrate:status # list applied / pending// migrations/20260724120000_create_users.ts
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('users', (t) => {
t.increments('id');
t.string('name').notNullable();
t.string('email').notNullable();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists('users');
}npm run demo doesn't touch the file database at all — it points a fresh in-memory connection at the same migrations/ directory and calls knex.migrate.latest() programmatically, so the demo always runs against the real schema without leaving files behind.
Every layer here is designed so you're never actually stuck: .whereRaw() (above) handles the common case — an arbitrary condition mid-chain — but when a Model or QueryChain abstraction genuinely can't express what you need at all, drop to Knex directly. getKnex() returns the active connection (or the active transaction, if you're inside one — see Transactions); Model.query() returns the same builder already scoped to that model's table:
import { getKnex } from './src/Model';
// Anything Knex can do: joins, subqueries, CTEs, window functions, unions, aggregates...
const rows = await getKnex()('users').join('posts', 'posts.userId', 'users.id').groupBy('users.id');
// Scoped to a specific model's table:
const rows2 = await User.query().whereRaw('lower(email) = ?', [email.toLowerCase()]);
// Fully raw SQL — hydrate the rows back into real Model instances with fromRow():
const raw = await getKnex().raw('SELECT * FROM users WHERE id = ANY(?)', [[1, 2, 3]]);
const users = (raw.rows ?? raw).map((row: any) => User.fromRow(row));That last one is the case actually worth calling out: knex.raw()'s result shape is driver-dependent — sqlite3 resolves directly to an array of rows, Postgres resolves to a full result object with the rows under .rows (plus .rowCount etc.), MySQL differs again. raw.rows ?? raw above is a cheap way to handle both the array and { rows } shapes, but check what your actual driver returns rather than trusting that blindly. Query builder methods (.select(), .where(), .whereRaw(), Model.query(), QueryChain) don't have this problem — Knex normalizes those across dialects; it's specifically .raw()'s return value that isn't normalized.
This is deliberately not hidden or discouraged — Knex is a mature, fully public, actively maintained SQL builder (unlike Arel, which the Rails core team has said isn't a stable public API in modern Rails). There's no ceiling here: if Model/QueryChain can't do it, Knex almost certainly can, and it's one function call away.
A few deliberate choices shape how usable the types are for consumers:
- No blanket index signature.
Modeldoes not declare[key: string]: any. Internals that need to read/write a column by a runtime string key go through two small module-private helpers (getAttr/setAttrinModel.ts) instead. The payoff:someUser.naem(a typo) is a compile error, not a silentany. where()conditions are typed asPartial<AttributesOf<InstanceType<T>>>, notRecord<string, any>—AttributesOf<T>strips function-valued members (methods, relations) from a model's own declared fields, soUser.where({ name: 'x' })gets real autocomplete and typo-checking. This is an approximation, not a guarantee: decorator metadata isn't visible to the type system, so it can't distinguish an actual@Column()field from an ordinary declared property or getter with the same shape. Full column-accurate typing (what an ORM like Prisma gets) would need a schema→types generation step instead of decorators — out of scope here.QueryChainis exported specifically so you can type a standalone scope-composition function as(chain: QueryChain<typeof Post>) => ...(see Scopes) rather than reaching forReturnType<typeof Post.where>, which resolves to the genericthis: Tconstraint (typeof Model) rather thantypeof Postwhen read outside a call expression.Timestampedis a mixin, not a decorator, specifically so its added properties are real, statically-typed members of the returned class with no extra step.@Delegate/@Enumremain class decorators because their generated method names depend on runtime string values (attribute lists, label keys) — giving those full static types would need const type parameters and template-literal mapped types, a much bigger lift for less-central features. Their generated members aren't visible to the type checker unless you declare them yourself via interface merging:interface Post { authorName(): Promise<string | undefined>; // matches @Delegate(['name'], { to: 'author' }) } interface Ticket { statusLabel: string; // matches @Enum('status', {...}) isDraft(): boolean; }
- Polymorphic
this: Ttyping throughout the static API (find,all,where,create,fromRow,preloadHasMany, ...) meansUser.find(1)already resolves toPromise<User | undefined>, notPromise<Model | undefined>— this was correct from early on and didn't need changing.
Tests use Vitest and run against real sqlite3 :memory: connections (no mocking) — each test file creates its own tables in beforeEach.
npm test # run once
npm run test:watch| File | Covers |
|---|---|
| src/Model.test.ts | CRUD, querying |
| src/validations.test.ts | @Validates, errors, save/saveOrFail |
| src/callbacks.test.ts | lifecycle hooks, halting |
| src/associations.test.ts | hasMany/hasOne/belongsTo, hasManyThrough/hasAndBelongsToMany, belongsToPolymorphic/hasManyPolymorphic/hasOnePolymorphic, preload query counts |
| src/dirty.test.ts | changes/isChanged/previousChanges, partial writes, reload() |
| src/casting.test.ts | built-in casters round-tripping through the DB, custom accessors, where() casting condition values |
| src/encryption.test.ts | encryptedCaster() — deterministic vs. non-deterministic, queryability, uniqueness, key rotation, wrong-key errors |
| src/macros.test.ts | Timestamped, @Delegate, @Enum, and the metadata-inheritance fix they depend on |
| src/scopes.test.ts | static-method scopes, QueryChain.apply() |
| src/transactions.test.ts | commit, rollback, nested reuse |
| src/convenience.test.ts | update/updateOrFail/firstOrCreate/dup/toJSON |
| src/queries.test.ts | pluck/count/exists, findEach/findInBatches cursor pagination, whereRaw composability |
| src/attributes.test.ts | virtual attributes, defaults, serializableHash |
| src/attributeModel.test.ts | AttributeModel used standalone — no tableName, no DB connection, none of Model's persistence surface |
| src/permit.test.ts | permit() — allowlist/guarded/primary-key filtering, the actual mass-assignment vulnerability it closes |
| src/uniqueness.test.ts | @Validates({ uniqueness }) — self-exclusion on update, scoping, RecordInvalid vs RecordNotSaved |
| src/security.test.ts | SecurePassword/SecureToken mixins, and the security.ts hashing/token primitives directly |
| src/attachments.test.ts | @HasOneAttached/@HasManyAttached — key generation, reattach/purge/destroy cleanup, orphaned-blob cleanup on save failure |
They're the most precise documentation of edge cases — e.g. associations.test.ts asserts the exact query count preloadHasMany issues via knex.on('query', ...), and transactions.test.ts asserts a partially-completed multi-step transfer fully rolls back on failure.
git clone git@github.com:mikecx/thunderstorm.git
cd thunderstorm
npm install
npm run demo # runs migrations against an in-memory DB and exercises every feature abovenpx tsc --noEmit # type check
npm run lint # ESLint (npm run lint:fix to auto-fix)
npm run format:check # Prettier (npm run format to auto-fix)
npm run build # emit dist/ — excludes *.test.ts
npm testAll of the above run in CI (.github/workflows/ci.yml) on push/PR against Node 22 and 24 (the two currently-supported LTS lines — see engines.node in package.json). eslint.config.mjs turns off @typescript-eslint/no-explicit-any (this codebase uses any deliberately as a controlled escape hatch — see TypeScript typing notes) and @typescript-eslint/no-unsafe-declaration-merging (needed for the @Delegate/@Enum interface-merging typing pattern documented above).
See AGENTS.md for the internals-focused guide — architecture, load-bearing design decisions worth knowing before changing Model.ts/decorators.ts, and testing conventions. This README is the user-facing feature reference; AGENTS.md is about how the codebase is put together.
