Models - Full Reference
Exhaustive reference for the @model decorator, the entity class hierarchy, and every schema enricher. For a readable introduction and the common tasks, start with the Models overview.
Files:
packages/kernel/src/base/models/base.ts- neutralAbstractEntitypackages/connectors/src/relational/core/models/base.ts- relational entityBaseRelationalEntitypackages/connectors/src/relational/core/models/factory.ts-ModelFactory.defineEntity,TDefinedEntityClasspackages/connectors/src/relational/core/models/relations.ts-one,many,toRelationConfigspackages/connectors/src/relational/core/repositories/dialect/relations/one.ts- how aonerelation finds its columnspackages/connectors/src/relational/postgres/models/index.ts- theBaseEntity/BasePostgresEntityaliasespackages/kernel/src/base/metadata/persistents.ts-@modeldecoratorpackages/connectors/src/relational/postgres/models/enrichers- schema enrichers
AbstractEntity (neutral) vs. BaseEntity (connector)
IGNIS separates the engine-neutral entity root from the connector-specific implementation.
- Engine-neutral root.
AbstractEntityhas no Drizzle, nopgTable, and nodrizzle-zod. - Minimal surface. It carries only a
name, an abstractgetSchema(), agetIdType(): TIdSchemaTypemethod (default'string'), andtoObject()/toJSON(). - Everything else is connector-owned. The Drizzle-backed entity,
drizzle-zodschema generation, and all schema enrichers belong to the PostgreSQL connector, not the neutral base. See Connectors for the full base-vs-connector architecture.
Source -> packages/kernel/src/base/models/base.ts
export abstract class AbstractEntity<Schema = unknown> extends BaseHelper {
name: string;
constructor(opts: { name: string }) {
super({ scope: opts.name });
this.name = opts.name;
}
abstract getSchema<T = Schema>(opts: { type: TSchemaType }): T;
getIdType(): TIdSchemaType {
return 'string';
}
toObject() {
return { ...this };
}
toJSON() {
return this.toObject();
}
}Naming
The canonical class is BaseRelationalEntity, and it is engine-neutral. BaseEntity and BasePostgresEntity are compatibility aliases re-exporting the same class from connectors/postgres/models/index.ts - all three resolve to identical runtime behavior. This is the one real alias in the persistence layer; the datasource and repository names beside it are distinct subclasses. Code samples use BaseEntity, the most common import today.
The @model Decorator
Marks a class as a database entity and configures its behavior.
Source -> packages/kernel/src/base/metadata/persistents.ts
@model({
type: 'entity' | 'view',
tableName?: string,
skipMigrate?: boolean,
settings?: {
hiddenProperties?: string[],
defaultFilter?: TFilter,
scopeFilter?: IScopeFilterSettings,
defaultLimit?: number,
maxLimit?: number,
authorize?: {
principal: string,
[extra: string | symbol]: any,
},
},
})Options
| Option | Type | Description |
|---|---|---|
type | 'entity' | 'view' | Entity type - 'entity' for tables, 'view' for database views |
tableName | string | Optional custom table name. Resolution order: tableName > static TABLE_NAME > class name |
skipMigrate | boolean | Skip this model during schema migrations |
settings.hiddenProperties | string[] | Property names excluded from all repository query results (at SQL level) |
settings.defaultFilter | TFilter | Filter automatically applied to all repository queries (see Default Filter) |
settings.scopeFilter | IScopeFilterSettings | Row scope resolved per query, ANDed into every read and write. resolve() returns the scope where, ScopeFilters.UNRESTRICTED for no scope this call, or null to fall to onMissing (default deny, matching zero rows). Relational repositories only |
settings.defaultLimit | number | Default row limit applied when a query omits limit. Must be a positive integer (validated at decoration time). Falls back to the global DEFAULT_LIMIT (10). See Pagination |
settings.maxLimit | number | Largest limit a caller may ask for. Must be a positive integer (validated at decoration time). Falls back to DEFAULT_MAX_LIMIT (1000). An explicit limit above it throws before the query runs. See Limit ceiling |
settings.authorize | IModelAuthorizeSettings | Authorization settings - declares the model's authorization principal (see Authorization) |
settings.authorize.principal | string | The authorization subject name for this model. Auto-populates the static AUTHORIZATION_SUBJECT property |
`scopeFilter` is not a default filter
shouldSkipDefaultFilter clears settings.defaultFilter. It does not clear settings.scopeFilter - the row scope stays applied on every query. That is the point: a scope is a security boundary, and an options flag must not be able to lift it.
Behavior
When the @model decorator is applied:
- If
settings.defaultLimitorsettings.maxLimitis provided, each is validated to be a positive integer - otherwise the decorator throws at decoration (boot) time. - If
settings.authorize.principalis provided andAUTHORIZATION_SUBJECTis not already an own property of the class, it auto-populatesAUTHORIZATION_SUBJECTwith the principal value. - The model is registered in the
MetadataRegistrymodel registry, keyed by table name (resolved asmetadata.tableName> staticTABLE_NAME> class name). - The static
relationsproperty is stored as a resolver (not immediately resolved) to avoid circular dependency issues between models.
BaseEntity (BaseRelationalEntity)
Relational entity class, wrapping a Drizzle ORM schema. Extends the neutral AbstractEntity.
Source -> packages/connectors/src/relational/core/models/base.ts - packages/connectors/src/relational/postgres/models/base.ts only re-exports it
Purpose
| Feature | Description |
|---|---|
| Schema encapsulation | Holds the Drizzle pgTable schema for consistent repository access |
| Metadata | Works with the @model decorator to mark database entities |
| Schema generation | Uses drizzle-zod to generate Zod schemas (select, create, update) |
| Static properties | Supports static schema, relations, TABLE_NAME, and AUTHORIZATION_SUBJECT |
| Convenience | Inherits toObject() and toJSON() from AbstractEntity |
Static properties
| Property | Type | Description |
|---|---|---|
schema | Table | The Drizzle table. Typed Table on the base so a factory-built class can carry its precise table; a subclass that assigns its own keeps the precise type. The instance schema is the typed contract |
relations | TValueOrResolver<Array<TRelationConfig>> | Relation definitions (can be a function for lazy loading to avoid circular deps) |
TABLE_NAME | string | undefined | Optional table name (defaults to class name if not set) |
AUTHORIZATION_SUBJECT | string | undefined | Authorization principal name. Auto-populated from @model settings.authorize.principal |
Instance members
| Member | Description |
|---|---|
schema | Instance copy of the Drizzle schema (opts.schema > static schema) |
getSchema({ type }) | Get a Zod schema for validation ('select', 'create', 'update') |
getIdType() | Maps the id column's Drizzle dataType to 'number' (serial/integer) or 'string' (everything else) |
toObject() | Convert to a plain object (shallow spread of this) |
toJSON() | Delegates to toObject() (used by JSON.stringify) |
IEntity interface
Models implementing static properties conform to IEntity:
interface IEntity<Schema extends TTableSchemaWithId = TTableSchemaWithId> {
TABLE_NAME?: string;
schema: Schema;
relations?: TValueOrResolver<Array<TRelationConfig>>;
}getSchema method
Generates a Zod validation schema from the Drizzle table schema using drizzle-zod.
getSchema(opts: { type: TSchemaType }): ZodSchemaThe type parameter accepts lowercase string values defined in the SchemaTypes class:
| Type | Value | Zod schema generated | Description |
|---|---|---|---|
SchemaTypes.SELECT | 'select' | createSelectSchema(schema) | Schema for query results |
SchemaTypes.CREATE | 'create' | createInsertSchema(schema) | Schema for insert operations |
SchemaTypes.UPDATE | 'update' | createUpdateSchema(schema) | Schema for update operations |
const user = new User();
const createSchema = user.getSchema({ type: 'create' });
const selectSchema = user.getSchema({ type: 'select' });
const updateSchema = user.getSchema({ type: 'update' });An invalid type throws. The Zod generator is a lazy singleton, created once through drizzle-zod's createSchemaFactory() and shared by every entity. It is private to the module - there is no schemaFactory member to call.
Class definition
/** Lazy singleton, shared by every entity. */
let schemaFactory: ReturnType<typeof createSchemaFactory> | undefined;
const getSchemaFactory = (): ReturnType<typeof createSchemaFactory> =>
(schemaFactory ??= createSchemaFactory());
export class BaseRelationalEntity<Schema extends TTableSchemaWithId = TTableSchemaWithId>
extends AbstractEntity
implements IEntity<Schema>
{
schema: Schema;
// Phantom type carriers (no runtime value)
declare readonly $inferData?: TTableObject<Schema>;
declare readonly $inferPersist?: TTableInsert<Schema>;
static TABLE_NAME?: string;
static AUTHORIZATION_SUBJECT?: string;
static schema: Table;
static relations?: TValueOrResolver<Array<TRelationConfig>>;
constructor(opts?: { name?: string; schema?: Schema }) {
const ctor = new.target as typeof BaseRelationalEntity;
const name = opts?.name ?? ctor.TABLE_NAME ?? ctor.name;
super({ name });
this.schema = opts?.schema ?? (ctor.schema as Schema);
}
override getIdType(): TIdSchemaType {
return getIdType({ entity: this.schema }) === 'number' ? 'number' : 'string';
}
getSchema<T = unknown>(opts: { type: TSchemaType }): T {
const factory = getSchemaFactory();
switch (opts.type) {
case SchemaTypes.CREATE:
return factory.createInsertSchema(this.schema) as T;
case SchemaTypes.UPDATE:
return factory.createUpdateSchema(this.schema) as T;
case SchemaTypes.SELECT:
return factory.createSelectSchema(this.schema) as T;
default:
throw getError({ message: `[getSchema] Invalid schema type | type: ${opts.type}` });
}
}
}Definition patterns
A model can be written three ways. New models use the first; the other two keep working.
ModelFactory.defineEntity (recommended)
Declare the table, then build the class from it. The full contract is in ModelFactory.defineEntity.
import { model } from '@venizia/ignis';
import { generateIdColumnDefs, ModelFactory } from '@venizia/ignis/postgres';
import { pgTable, text } from 'drizzle-orm/pg-core';
export const userTable = pgTable('User', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
name: text('name').notNull(),
});
@model({ type: 'entity' })
export class User extends ModelFactory.defineEntity({ table: userTable }) {}Static properties
Define schema and relations as static properties. Repositories auto-resolve them.
import { pgTable, text } from 'drizzle-orm/pg-core';
import { BaseEntity, model, generateIdColumnDefs, RelationTypes } from '@venizia/ignis';
import type { TRelationConfig } from '@venizia/ignis/postgres';
import { Comment } from './comment.model';
export const userTable = pgTable('User', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
name: text('name').notNull(),
email: text('email').notNull(),
});
@model({ type: 'entity' })
export class User extends BaseEntity<typeof User.schema> {
static override schema = userTable;
static override TABLE_NAME = 'User';
// A resolver returning a plain array of relation configs (lazy - avoids circular imports).
static override relations = (): TRelationConfig[] => [
{
name: 'comments',
type: RelationTypes.MANY,
schema: Comment.schema,
metadata: { relationName: 'author' },
},
];
}- Auto-resolved. Schema and relations are auto-resolved by repositories.
- Less ceremony. No need to pass
relationsin a repository constructor. - Declarative. Cleaner than wiring dependencies through a constructor.
Constructor-based (legacy)
Pass schema in the constructor.
@model({ type: 'entity' })
export class User extends BaseEntity<typeof userTable> {
constructor() {
super({ name: 'User', schema: userTable });
}
}ModelFactory.defineEntity
Builds an entity class from a drizzle table, so the table name, the id and the relations are each stated once.
Source -> packages/connectors/src/relational/core/models/factory.ts
import { many, ModelFactory, one } from '@venizia/ignis/postgres';
import type { TDefinedEntityClass, TEntityObject } from '@venizia/ignis/postgres';class ModelFactory {
static defineEntity<
Schema extends TTableSchemaWithId,
Relations extends TRelationDefinitions = {},
>(opts: { table: Schema; relations?: () => Relations }): TDefinedEntityClass<Schema, Relations>;
}It ships from @venizia/ignis/postgres and from @venizia/ignis-connectors/relational, /postgres and /sqlite.
Options
| Option | Type | Default | Meaning |
|---|---|---|---|
table | TTableSchemaWithId | - | A plain drizzle table: pgTable, pgSchema(...).table or sqliteTable, with an id column - usually ...generateIdColumnDefs({ id: { dataType: 'string' } }) for a UUID v7 id |
relations | () => TRelationDefinitions | none | Relations keyed by name, built with one() and many(). Read on first use and once, so it may name a table declared later in the file |
The returned class
TDefinedEntityClass<Schema, Relations> is typeof BaseRelationalEntity<Schema> plus these statics, typed precisely:
| Static | Type | Value |
|---|---|---|
TABLE_NAME | string | getTableName(table) |
schema | Schema | The table you passed |
relationDefinitions | Relations | undefined | The keyed relations - undefined when there are none |
relations | TValueOrResolver<Array<TRelationConfig>> | The array form the query dialect reads, built once |
Every other member is inherited: the instance schema, getSchema({ type }), getIdType(), and AUTHORIZATION_SUBJECT. Extend the class and decorate it with @model as usual.
Relation helpers
| Helper | Signature | Builds |
|---|---|---|
one | one(table, metadata?) | A to-one relation. metadata is drizzle's one() options, every field optional |
many | many(table, metadata?) | A to-many relation. metadata.relationName names the one on the other table it pairs with |
toRelationConfigs | toRelationConfigs({ relations }) | Flattens the keyed form into Array<TRelationConfig>, each named by its key |
A relation points at a table, never at an entity class - passing a class does not compile. That is why two entities that relate both ways, a self relation (parent and children), and relations across files all keep their row types.
A one relation's name is also its drizzle relationName, unless its metadata sets one. So many(featureTable, { relationName: 'policy' }) pairs with the policy: one(policyTable) declared on the feature entity.
How one finds its columns
The same rules apply to a hand-written relations array.
| The source table has | one(target) resolves to |
|---|---|
fields and references both written | Those, as written |
Only one of fields and references | An error |
Exactly one foreign key to the target (.references(() => target.id)) | That key's columns |
Two or more foreign keys to the target, one not claimed by a one written out on the same entity | That key's columns |
| Two or more foreign keys to the target, otherwise | An error naming the columns left - write fields and references |
| No key, but the target has one back | The inverse side of a one-to-one - see Pairing the inverse side |
| No key, but the target has two or more back | An error naming the columns - write fields and references on this side, reversed |
| No key either way | An error - add .references() to the column, or write fields and references |
A key is claimed when a one on the same entity writes exactly its columns as fields. With author_id and editor_id both keying users, write author out and editor: one(usersTable) reads editor_id. Add a third key and editor is refused again.
A one to its own table follows the same rules, plus one: only one such one per entity may leave out fields. One self key cannot back both previous and next, so the second writes its columns, reversed.
Each error is thrown when the datasource builds its schema, with the relation's name and the tables in the message.
Pairing the inverse side
The inverse side carries no columns. drizzle reads them off the one() on the table that owns the key, so that entity must declare one:
export const userTable = pgTable('User', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
});
export const profileTable = pgTable('Profile', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
userId: text('user_id').notNull().references((): AnyPgColumn => userTable.id),
});
@model({ type: 'entity' })
export class User extends ModelFactory.defineEntity({
table: userTable,
relations: () => ({ profile: one(profileTable) }), // the inverse side
}) {}
@model({ type: 'entity' })
export class Profile extends ModelFactory.defineEntity({
table: profileTable,
relations: () => ({ user: one(userTable) }), // owns the key - `profile` pairs with it
}) {}Leave out user and schema discovery fails. The datasource pairs every relation once, when it builds its schema, the way drizzle does on a query. That is at boot when configure() reads getSchema(), otherwise on the datasource's first query. An inverse one with nothing back fails there, with the entity and the relation named. Any other relation it cannot pair - a many whose relationName matches no one - logs a warning there, and still fails the queries that include it. A relation to a table that no model on this datasource uses logs nothing at warn level: a datasource that carries a subset of models is a normal shape, so they collapse into one debug line per datasource.
The inverse side takes no relationName. When the key's table has more than one key or relation back, or you cannot declare the one() back, write the columns on this side, reversed:
// On User: walks profileTable.userId back
profile: one(profileTable, { fields: [userTable.id], references: [profileTable.userId] }),
// On a linked-list node, beside previous: one(nodeTable): walks the self key back
next: one(nodeTable, { fields: [nodeTable.id], references: [nodeTable.previousId] }),TEntityObject
TEntityObject<typeof Entity> is a row of the entity plus its relations. A many relation is an optional array of the related table's rows, and a one relation an optional row. An entity without relations gives a plain row.
export type TPolicy = TEntityObject<typeof Policy>;
// { id: string; title: string; features?: Array<TTableObject<typeof featureTable>> }The row is read off the instance's $inferData, not off the static schema, so an unknown key is still refused.
Hidden properties
Hidden properties are excluded at the SQL level - they are never fetched from the database when querying through repositories.
- Security. Sensitive data like passwords is never accidentally exposed.
- Performance. Less data is transferred.
- Consistency. Exclusion applies across all repository operations.
import { pgTable, text } from 'drizzle-orm/pg-core';
import { BaseEntity, model, generateIdColumnDefs } from '@venizia/ignis';
@model({
type: 'entity',
settings: { hiddenProperties: ['password', 'secret'] },
})
export class User extends BaseEntity<typeof User.schema> {
static override schema = pgTable('User', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
email: text('email').notNull(),
password: text('password'),
secret: text('secret'),
});
}Behavior matrix
| Operation | Hidden properties |
|---|---|
find(), findOne(), findById() | Excluded from SELECT |
create(), createAll() | Excluded from RETURNING |
updateById(), updateAll() | Excluded from RETURNING |
deleteById(), deleteAll() | Excluded from RETURNING |
count(), existsWith() | Can filter by hidden fields |
| Direct connector query | Included (bypasses repository) |
Notes:
- Hidden properties can still be used in
whereclauses for filtering. - Data is still stored in the database - only excluded from query results.
- Use a direct connector query when you need to access hidden data:
// Repository query - password/secret NOT included
const user = await userRepository.findById({ id: '123' });
// user = { id: '123', email: 'john@example.com' }
// Direct connector query - ALL fields included
const connector = userRepository.connector;
const [fullUser] = await connector
.select()
.from(User.schema)
.where(eq(User.schema.id, '123'));
// fullUser = { id: '123', email: '...', password: 'hashed...', secret: '...' }Default filter
Default filters are automatically applied to all repository queries for a model. Useful for soft delete, multi-tenancy, active-record filtering, and query limits.
import { model, BaseEntity } from '@venizia/ignis';
import { postTable } from '@/schemas';
@model({
type: 'entity',
settings: {
defaultFilter: {
where: { isDeleted: false },
limit: 100,
},
},
})
export class Post extends BaseEntity<typeof Post.schema> {
static override schema = postTable;
}Behavior matrix
| Operation | Default filter |
|---|---|
find(), findOne(), findById() | Applied to WHERE clause |
count(), existsWith() | Applied to WHERE clause |
updateById(), updateAll() | Applied to WHERE clause |
deleteById(), deleteAll() | Applied to WHERE clause |
create(), createAll() | Not applied |
Bypassing
Use shouldSkipDefaultFilter: true to bypass it:
// Normal query - includes default filter
await postRepository.find({ filter: {} });
// WHERE isDeleted = false LIMIT 100
// Admin query - bypass default filter
await postRepository.find({
filter: {},
options: { shouldSkipDefaultFilter: true },
});
// No default WHERE clause (includes deleted), and no default LIMIT 100 eithershouldSkipDefaultFilter drops the whole defaultFilter, its limit included, so the query falls back to settings.defaultLimit ?? 10. It does not drop settings.scopeFilter - a row scope stays applied on every query.
TIP
See Default Filter for full documentation including merge strategies and common patterns.
Schema enrichers
- What they are. Helper functions in the PostgreSQL connector that return an object of Drizzle ORM column definitions.
- How to use them. Spread a
generate*result into apgTabledefinition to add common, standardized fields. - Convenience wrappers. Each
enrich*wrapper accepts an existing column definitions object as its first argument and merges the generated columns into it.
| Enricher function | Convenience wrapper | Purpose |
|---|---|---|
generateIdColumnDefs | enrichId | Adds a primary key id column (string, integer, or big integer) |
generateTzColumnDefs | enrichTz | Adds createdAt, modifiedAt, and optional deletedAt timestamp columns |
generateUserAuditColumnDefs | enrichUserAudit | Adds createdBy and modifiedBy columns tracking user audit info |
generatePrincipalColumnDefs | enrichPrincipal | Adds polymorphic principal columns ({discriminator}Id and {discriminator}Type) |
generateDataTypeColumnDefs | enrichDataTypes | Adds generic value columns (dataType, nValue, tValue, bValue, jValue, boValue) |
Combined example
import { pgTable, text } from 'drizzle-orm/pg-core';
import {
generateIdColumnDefs,
generateTzColumnDefs,
generateUserAuditColumnDefs,
} from '@venizia/ignis';
export const myTable = pgTable('MyTable', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
...generateTzColumnDefs(),
...generateUserAuditColumnDefs({
created: { dataType: 'string', columnName: 'created_by' },
modified: { dataType: 'string', columnName: 'modified_by' },
}),
name: text('name').notNull(),
});generateIdColumnDefs
Adds a primary key id column with full TypeScript type inference.
Source -> packages/connectors/src/relational/postgres/models/enrichers/id.enricher.ts
generateIdColumnDefs<Opts extends TIdEnricherOptions | undefined>(opts?: Opts): TIdColumnDef<Opts>Options (TIdEnricherOptions):
type TIdEnricherOptions = {
id?: { columnName?: string } & (
| { dataType: 'string'; generator?: () => string }
| { dataType: 'number'; sequenceOptions?: PgSequenceOptions }
| {
dataType: 'big-number';
numberMode: 'number' | 'bigint';
sequenceOptions?: PgSequenceOptions;
}
);
};Default: id.dataType defaults to 'number' (auto-incrementing integer).
Emitted columns:
| Data type | Column type | Constraints | Description |
|---|---|---|---|
'string' | text | Primary key, default crypto.randomUUID() | Text column with customizable ID generator |
'number' | integer | Primary key, GENERATED ALWAYS AS IDENTITY | Auto-incrementing integer |
'big-number' | bigint | Primary key, GENERATED ALWAYS AS IDENTITY | Auto-incrementing big integer (mode 'number' or 'bigint') |
Notes:
- For
dataType: 'string', atextcolumn is used for maximum database compatibility. Provide a customgenerator(nanoid, cuid, etc.); the default iscrypto.randomUUID(). - For
dataType: 'big-number',numberModeis required -'number'(values up to 2^53-1, safe for JavaScript numbers) or'bigint'(larger values, requiresBigInt). sequenceOptions(available fornumberandbig-number) customizes identity generation, e.g.{ startWith: 1000, increment: 1 }.
Convenience wrapper:
enrichId(baseColumns: TColumnDefinitions, opts?: TIdEnricherOptions): TColumnDefinitionsgenerateTzColumnDefs
Adds timestamp columns for creation, modification, and soft deletion.
Source -> packages/connectors/src/relational/postgres/models/enrichers/tz.enricher.ts
generateTzColumnDefs<Opts extends TTzEnricherOptions | undefined>(opts?: Opts): TTzEnricherResult<Opts>Options (TTzEnricherOptions):
type TTzEnricherOptions = {
created?: { columnName: string; withTimezone: boolean };
modified?: { enable: false } | { enable?: true; columnName: string; withTimezone: boolean };
deleted?: { enable: false } | { enable?: true; columnName: string; withTimezone: boolean };
};The modified and deleted options use a discriminated union:
enable: false. No other properties are needed.enable: true(or omitted).columnNameandwithTimezoneare required.
Defaults:
created:{ columnName: 'created_at', withTimezone: true }modified:{ enable: true, columnName: 'modified_at', withTimezone: true }deleted:{ enable: false }(disabled by default)
Emitted columns:
| Column | Type | Constraints | Default | Description |
|---|---|---|---|---|
createdAt | timestamp | NOT NULL | NOW() | When the record was created (always included) |
modifiedAt | timestamp | NOT NULL | NOW(), auto-updates via $onUpdate(() => new Date().toISOString()) | When the record was last modified (enabled by default) |
deletedAt | timestamp | nullable | none | When the record was soft-deleted (disabled by default) |
Type inference
createdAt. Always present.modifiedAt. Present unlessmodified: { enable: false }.deletedAt. Absent unlessdeleted: { enable: true, ... }.
A generated CRUD route never takes createdAt or modifiedAt from the request body, so over HTTP the clock always sets them. deletedAt stays in the body. See ControllerFactory.
Convenience wrapper:
enrichTz(baseSchema: TColumnDefinitions, opts?: TTzEnricherOptions): TColumnDefinitionsSoft delete pattern
The deletedAt column enables soft delete - records are marked deleted rather than physically removed.
import { eq, isNull, isNotNull } from 'drizzle-orm';
// Soft delete: set deletedAt timestamp
await db.update(myTable).set({ deletedAt: new Date() }).where(eq(myTable.id, id));
// Query only active (non-deleted) records
const active = await db.select().from(myTable).where(isNull(myTable.deletedAt));
// Query deleted records
const deleted = await db.select().from(myTable).where(isNotNull(myTable.deletedAt));
// Restore a soft-deleted record
await db.update(myTable).set({ deletedAt: null }).where(eq(myTable.id, id));generateUserAuditColumnDefs
Adds createdBy and modifiedBy columns tracking which user created or modified a record.
Source -> packages/connectors/src/relational/postgres/models/enrichers/user-audit.enricher.ts
generateUserAuditColumnDefs(opts?: TUserAuditEnricherOptions): {
createdBy: PgIntegerBuilderInitial | PgTextBuilderInitial;
modifiedBy: PgIntegerBuilderInitial | PgTextBuilderInitial;
}Options:
type TUserAuditColumnOpts = {
dataType: 'string' | 'number'; // Required - type of user ID
columnName: string; // Column name in database
allowAnonymous?: boolean; // Allow null user ID (default: true)
};
type TUserAuditEnricherOptions = {
created?: TUserAuditColumnOpts;
modified?: TUserAuditColumnOpts;
};Defaults:
created:{ dataType: 'number', columnName: 'created_by', allowAnonymous: true }modified:{ dataType: 'number', columnName: 'modified_by', allowAnonymous: true }
How it works
- Retrieval. The enricher reads the request context from
RequestContextRegistryat insert/update time, then takes the user ID off theAuthentication.AUDIT_USER_IDkey. An application installs the resolver over that registry when it registers its default middlewares, backed by Hono'scontextStorage. A host that installs none - a browser Worker - has no request context at all, which is theallowAnonymouscase below. createdBy. Set via$default()- creation only.modifiedBy. Set via both$default()and$onUpdate()- creation and every modification.- A value in the data wins.
$default()and$onUpdate()fill only a key the data leaves out, so server code can still set these columns. A generated CRUD route never takes them from the request body, so over HTTP the request context always decides them. SeeControllerFactory.
Emitted columns:
| Column | Data type | Default column name | Description |
|---|---|---|---|
createdBy | integer or text | created_by | User ID who created the record |
modifiedBy | integer or text | modified_by | User ID who last modified the record |
allowAnonymous behavior:
allowAnonymous | No context | No user ID | Has user ID |
|---|---|---|---|
true (default) | Returns null | Returns null | Returns user ID |
false | Throws error | Throws error | Returns user ID |
allowAnonymous: false. Use for sensitive audit trails, tables that forbid anonymous operations, and compliance requirements mandating user attribution.allowAnonymous: true(default). Use for background jobs, migrations, seed scripts, and system-generated records.
The enricher validates dataType and throws for invalid values (only 'number' and 'string' are valid).
WARNING
Fire-and-forget promises may run outside the async context, losing access to AUDIT_USER_ID. Ensure audit-critical operations complete within the request lifecycle.
Convenience wrapper:
enrichUserAudit<ColumnDefinitions extends TColumnDefinitions>(
baseSchema: ColumnDefinitions,
opts?: TUserAuditEnricherOptions,
): TUserAuditEnricherResult<ColumnDefinitions>generatePrincipalColumnDefs
Adds polymorphic principal columns for associating a record with different entity types (a comment can belong to a Post, User, or Product).
Source -> packages/connectors/src/relational/postgres/models/enrichers/principal.enricher.ts
generatePrincipalColumnDefs<
Discriminator extends string = 'principal',
IdType extends 'number' | 'string' = 'number',
Nullable extends boolean = false,
NullableDiscriminator extends boolean = true,
>(
opts: TPrincipalEnricherOptions<Discriminator, IdType, Nullable, NullableDiscriminator>,
): TPrincipalColumnDef<Discriminator, IdType, Nullable, NullableDiscriminator>Options (TPrincipalEnricherOptions):
| Option | Type | Default | Description |
|---|---|---|---|
discriminator | string | 'principal' | Prefix for generated column names |
defaultPolymorphic | string | '' | Default value for the type discriminator column |
polymorphicIdType | 'number' | 'string' | (required) | Data type of the ID column |
isNullableId | boolean | false | When true, the ID column is nullable (omits NOT NULL) |
isNullableDiscriminator | boolean | true | When false, the type discriminator column is NOT NULL; the default leaves it nullable |
Emitted columns, given discriminator = 'principal':
| Column | DB column name | Type | Constraints | Description |
|---|---|---|---|---|
principalId | principal_id | integer or text | NOT NULL (unless isNullableId) | ID of the associated entity |
principalType | principal_type | text | DEFAULT '' (or defaultPolymorphic); NOT NULL when isNullableDiscriminator is false | Type discriminator (e.g. 'User', 'Post') |
With a custom discriminator such as 'owner', columns become ownerId / owner_id and ownerType / owner_type.
import { pgTable, text } from 'drizzle-orm/pg-core';
import { generateIdColumnDefs, generatePrincipalColumnDefs } from '@venizia/ignis';
export const commentTable = pgTable('Comment', {
...generateIdColumnDefs(),
...generatePrincipalColumnDefs({ polymorphicIdType: 'number' }),
content: text('content').notNull(),
});
// principalId: integer('principal_id').notNull()
// principalType: text('principal_type').default('')Convenience wrapper:
enrichPrincipal<ColumnDefinitions extends TColumnDefinitions>(
baseSchema: ColumnDefinitions,
opts: TPrincipalEnricherOptions,
): ColumnDefinitions & TPrincipalColumnDefgenerateDataTypeColumnDefs
Adds polymorphic data storage columns for entities that store values of different types in a single table (key-value stores, settings tables).
Source -> packages/connectors/src/relational/postgres/models/enrichers/data-type.enricher.ts
generateDataTypeColumnDefs(opts?: TDataTypeEnricherOptions): {
dataType: PgTextBuilderInitial;
nValue: PgDoublePrecisionBuilderInitial;
tValue: PgTextBuilderInitial;
bValue: PgCustomColumnBuilder<Buffer>;
jValue: PgJsonbBuilderInitial<Record<string, any>>;
boValue: PgBooleanBuilderInitial;
}Options (TDataTypeEnricherOptions):
type TDataTypeEnricherOptions = {
defaultValue: Partial<{
dataType: string;
nValue: number;
tValue: string;
bValue: Buffer;
jValue: object;
boValue: boolean;
}>;
};Emitted columns:
| Column | SQL type | DB column name | TypeScript type | Purpose |
|---|---|---|---|---|
dataType | text | data_type | string | Type discriminator ('number', 'text', 'json') |
nValue | double precision | n_value | number | Numeric values |
tValue | text | t_value | string | Text values |
bValue | bytea | b_value | Buffer | Binary values |
jValue | jsonb | j_value | Record<string, any> | JSON values |
boValue | boolean | bo_value | boolean | Boolean values |
All columns are nullable by default (no NOT NULL), since typically only one value column is populated per row depending on the dataType discriminator. Pass defaultValue to attach SQL defaults to individual columns.
import { pgTable, text } from 'drizzle-orm/pg-core';
import { BaseEntity, model, generateIdColumnDefs, generateDataTypeColumnDefs } from '@venizia/ignis';
@model({ type: 'entity' })
export class AppConfig extends BaseEntity<typeof AppConfig.schema> {
static override schema = pgTable('AppConfig', {
...generateIdColumnDefs({ id: { dataType: 'string' } }),
...generateDataTypeColumnDefs(),
key: text('key').notNull().unique(),
});
}
// { key: 'max_retries', dataType: 'number', nValue: 3 }
// { key: 'feature_flags', dataType: 'json', jValue: { darkMode: true } }Convenience wrapper:
enrichDataTypes(baseSchema: TColumnDefinitions, opts?: TDataTypeEnricherOptions): TColumnDefinitionsKey types
Source -> packages/connectors/src/relational/core/models/common/types.ts
TTableSchemaWithId
Any Drizzle table with an id column. PgTable and SQLiteTable both extend Table, so both fit:
type TTableSchemaWithId<TC extends TableConfig = TableConfig> = Table<TC> & {
id: TIdColumn;
};TTableObject / TTableInsert
Infer the select (output) and insert (input) types from a table schema:
type TTableObject<T extends TTableSchemaWithId> = T['$inferSelect'];
type TTableInsert<T extends TTableSchemaWithId> = T['$inferInsert'];TRelationConfig
Configuration for entity relationships.
Source -> packages/connectors/src/relational/core/repositories/common/types.ts
type TRelationConfig = {
name: string;
} & (
| { type: 'one'; schema: TTableSchemaWithId; metadata?: /* Partial<Drizzle one() params> */ }
| { type: 'many'; schema: TTableSchemaWithId; metadata: /* Drizzle many() params */ }
);A one relation may leave out fields and references: they are read off the table's foreign key, by the rules in How one finds its columns.
Relation types are defined in the RelationTypes class: RelationTypes.ONE ('one', one-to-one or many-to-one) and RelationTypes.MANY ('many', one-to-many). See Relations.
TValueOrResolver
From @venizia/ignis-helpers, enables lazy resolution to avoid circular dependencies:
type TValueOrResolver<T> = T | TResolver<T>; // T or () => TUsed for relations on BaseEntity - store a function that returns the relations array, resolved lazily when DataSource.buildSchema() is called.
Schema utilities
Source -> packages/kernel/src/base/models/common/schemas.ts, packages/kernel/src/base/models/common/utilities.ts
idParamsSchema
Generates a Zod schema for path parameters containing an id field, suitable for OpenAPI route definitions.
idParamsSchema(opts?: { idType: TIdSchemaType }): z.ZodObject<{ id: z.ZodNumber | z.ZodString }>idType | Default | Zod type | Examples |
|---|---|---|---|
'number' | Yes | z.number() | [1, 2, 3] |
'string' | z.string() | ['4651e634-...', 'some_unique_id'] |
Throws for invalid idType values.
jsonContent
Creates an OpenAPI JSON content specification:
jsonContent<T extends z.ZodType>(opts: {
schema: T;
description: string;
required?: boolean;
}): { description, content: { 'application/json': { schema } }, required? }jsonResponse
Creates a complete OpenAPI response specification with success and error responses:
jsonResponse<ContentSchema, HeaderSchema>(opts: {
schema: ContentSchema;
description?: string; // Default: 'Success Response'
required?: boolean;
headers?: HeaderSchema;
}): {
200: { description, content, headers? },
...errorResponses()
}errorResponses
The JSON error body of a route, under the two range keys OpenAPI accepts. jsonResponse(), htmlResponse() and the static-asset routes spread it, and so can a route of your own:
errorResponses(): {
'4XX': { description: 'Error Response', content: { 'application/json': { schema: ErrorSchema } } },
'5XX': { description: 'Error Response', content: { 'application/json': { schema: ErrorSchema } } }
}ErrorSchema is the named component ErrorResponse, so both keys reference one schema in the generated document.
snakeToCamel
Converts a Zod schema from snake_case to camelCase, transforming both the schema shape and runtime data. Useful when a database uses snake_case column names but you work in camelCase.
snakeToCamel<T extends z.ZodRawShape>(shape: T): z.ZodEffects<...>The resulting schema, in order:
- Accepts snake_case input, validated against the original schema.
- Transforms keys to camelCase at runtime.
- Validates the transformed data against a camelCase schema.
import { z } from 'zod';
import { snakeToCamel } from '@venizia/ignis';
const userCamelSchema = snakeToCamel({
user_id: z.number(),
first_name: z.string(),
created_at: z.date(),
});
const result = userCamelSchema.parse({ user_id: 123, first_name: 'John', created_at: new Date() });
// { userId: 123, firstName: 'John', createdAt: Date }Built on keysToCamel() / toCamel() from @venizia/ignis-helpers; recursively handles nested objects and preserves arrays.
getIdType
There are two distinct getIdTypes in the framework - do not confuse them.
| Neutral instance method | PostgreSQL utility function | |
|---|---|---|
| Location | AbstractEntity.getIdType() (base/models/base.ts) | getIdType() (connectors/core/models/common/types.ts) |
| Signature | getIdType(): TIdSchemaType | getIdType<T extends TTableSchemaWithId>(opts: { entity: T }): string |
| Purpose | Neutral capability every engine's entity implements - returns 'string' | 'number' at the entity level. Used by idParamsSchema to build the right Zod schema for path parameters. | PostgreSQL-specific - inspects a Drizzle table schema's id column and returns its dataType, or 'unknown' if not determinable |
// Neutral - instance method (default 'string'; BaseEntity overrides based on the column)
const entity = new User();
entity.getIdType(); // 'string' | 'number'
// PostgreSQL connector - standalone utility inspecting a raw Drizzle schema
import { getIdType } from '@venizia/ignis/postgres';
getIdType({ entity: User.schema }); // 'string' | 'number' | 'unknown'See also
- Models overview - introduction and common tasks
- Tutorial - creating models step by step
- Repositories - the data access layer
- Relations - model relationships
- Filter System - querying models
- Connectors - the base-vs-connector architecture