Skip to content

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:

AbstractEntity (neutral) vs. BaseEntity (connector) ​

IGNIS separates the engine-neutral entity root from the connector-specific implementation.

  • Engine-neutral root. AbstractEntity has no Drizzle, no pgTable, and no drizzle-zod.
  • Minimal surface. It carries only a name, an abstract getSchema(), a getIdType(): TIdSchemaType method (default 'string'), and toObject()/toJSON().
  • Everything else is connector-owned. The Drizzle-backed entity, drizzle-zod schema 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

typescript
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

typescript
@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 ​

OptionTypeDescription
type'entity' | 'view'Entity type - 'entity' for tables, 'view' for database views
tableNamestringOptional custom table name. Resolution order: tableName > static TABLE_NAME > class name
skipMigratebooleanSkip this model during schema migrations
settings.hiddenPropertiesstring[]Property names excluded from all repository query results (at SQL level)
settings.defaultFilterTFilterFilter automatically applied to all repository queries (see Default Filter)
settings.scopeFilterIScopeFilterSettingsRow 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.defaultLimitnumberDefault 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.maxLimitnumberLargest 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.authorizeIModelAuthorizeSettingsAuthorization settings - declares the model's authorization principal (see Authorization)
settings.authorize.principalstringThe 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:

  1. If settings.defaultLimit or settings.maxLimit is provided, each is validated to be a positive integer - otherwise the decorator throws at decoration (boot) time.
  2. If settings.authorize.principal is provided and AUTHORIZATION_SUBJECT is not already an own property of the class, it auto-populates AUTHORIZATION_SUBJECT with the principal value.
  3. The model is registered in the MetadataRegistry model registry, keyed by table name (resolved as metadata.tableName > static TABLE_NAME > class name).
  4. The static relations property 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 ​

FeatureDescription
Schema encapsulationHolds the Drizzle pgTable schema for consistent repository access
MetadataWorks with the @model decorator to mark database entities
Schema generationUses drizzle-zod to generate Zod schemas (select, create, update)
Static propertiesSupports static schema, relations, TABLE_NAME, and AUTHORIZATION_SUBJECT
ConvenienceInherits toObject() and toJSON() from AbstractEntity

Static properties ​

PropertyTypeDescription
schemaTableThe 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
relationsTValueOrResolver<Array<TRelationConfig>>Relation definitions (can be a function for lazy loading to avoid circular deps)
TABLE_NAMEstring | undefinedOptional table name (defaults to class name if not set)
AUTHORIZATION_SUBJECTstring | undefinedAuthorization principal name. Auto-populated from @model settings.authorize.principal

Instance members ​

MemberDescription
schemaInstance 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:

typescript
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.

typescript
getSchema(opts: { type: TSchemaType }): ZodSchema

The type parameter accepts lowercase string values defined in the SchemaTypes class:

TypeValueZod schema generatedDescription
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
typescript
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 ​

typescript
/** 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.

Declare the table, then build the class from it. The full contract is in ModelFactory.defineEntity.

typescript
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.

typescript
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 relations in a repository constructor.
  • Declarative. Cleaner than wiring dependencies through a constructor.

Constructor-based (legacy) ​

Pass schema in the constructor.

typescript
@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

typescript
import { many, ModelFactory, one } from '@venizia/ignis/postgres';
import type { TDefinedEntityClass, TEntityObject } from '@venizia/ignis/postgres';
typescript
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 ​

OptionTypeDefaultMeaning
tableTTableSchemaWithId-A plain drizzle table: pgTable, pgSchema(...).table or sqliteTable, with an id column - usually ...generateIdColumnDefs({ id: { dataType: 'string' } }) for a UUID v7 id
relations() => TRelationDefinitionsnoneRelations 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:

StaticTypeValue
TABLE_NAMEstringgetTableName(table)
schemaSchemaThe table you passed
relationDefinitionsRelations | undefinedThe keyed relations - undefined when there are none
relationsTValueOrResolver<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 ​

HelperSignatureBuilds
oneone(table, metadata?)A to-one relation. metadata is drizzle's one() options, every field optional
manymany(table, metadata?)A to-many relation. metadata.relationName names the one on the other table it pairs with
toRelationConfigstoRelationConfigs({ 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 hasone(target) resolves to
fields and references both writtenThose, as written
Only one of fields and referencesAn 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 entityThat key's columns
Two or more foreign keys to the target, otherwiseAn error naming the columns left - write fields and references
No key, but the target has one backThe inverse side of a one-to-one - see Pairing the inverse side
No key, but the target has two or more backAn error naming the columns - write fields and references on this side, reversed
No key either wayAn 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:

typescript
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:

typescript
// 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.

typescript
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.
typescript
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 ​

OperationHidden 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 queryIncluded (bypasses repository)

Notes:

  • Hidden properties can still be used in where clauses 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:
typescript
// 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.

typescript
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 ​

OperationDefault 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:

typescript
// 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 either

shouldSkipDefaultFilter 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 a pgTable definition 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 functionConvenience wrapperPurpose
generateIdColumnDefsenrichIdAdds a primary key id column (string, integer, or big integer)
generateTzColumnDefsenrichTzAdds createdAt, modifiedAt, and optional deletedAt timestamp columns
generateUserAuditColumnDefsenrichUserAuditAdds createdBy and modifiedBy columns tracking user audit info
generatePrincipalColumnDefsenrichPrincipalAdds polymorphic principal columns ({discriminator}Id and {discriminator}Type)
generateDataTypeColumnDefsenrichDataTypesAdds generic value columns (dataType, nValue, tValue, bValue, jValue, boValue)

Combined example ​

typescript
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

typescript
generateIdColumnDefs<Opts extends TIdEnricherOptions | undefined>(opts?: Opts): TIdColumnDef<Opts>

Options (TIdEnricherOptions):

typescript
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 typeColumn typeConstraintsDescription
'string'textPrimary key, default crypto.randomUUID()Text column with customizable ID generator
'number'integerPrimary key, GENERATED ALWAYS AS IDENTITYAuto-incrementing integer
'big-number'bigintPrimary key, GENERATED ALWAYS AS IDENTITYAuto-incrementing big integer (mode 'number' or 'bigint')

Notes:

  • For dataType: 'string', a text column is used for maximum database compatibility. Provide a custom generator (nanoid, cuid, etc.); the default is crypto.randomUUID().
  • For dataType: 'big-number', numberMode is required - 'number' (values up to 2^53-1, safe for JavaScript numbers) or 'bigint' (larger values, requires BigInt).
  • sequenceOptions (available for number and big-number) customizes identity generation, e.g. { startWith: 1000, increment: 1 }.

Convenience wrapper:

typescript
enrichId(baseColumns: TColumnDefinitions, opts?: TIdEnricherOptions): TColumnDefinitions

generateTzColumnDefs ​

Adds timestamp columns for creation, modification, and soft deletion.

Source -> packages/connectors/src/relational/postgres/models/enrichers/tz.enricher.ts

typescript
generateTzColumnDefs<Opts extends TTzEnricherOptions | undefined>(opts?: Opts): TTzEnricherResult<Opts>

Options (TTzEnricherOptions):

typescript
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). columnName and withTimezone are required.

Defaults:

  • created: { columnName: 'created_at', withTimezone: true }
  • modified: { enable: true, columnName: 'modified_at', withTimezone: true }
  • deleted: { enable: false } (disabled by default)

Emitted columns:

ColumnTypeConstraintsDefaultDescription
createdAttimestampNOT NULLNOW()When the record was created (always included)
modifiedAttimestampNOT NULLNOW(), auto-updates via $onUpdate(() => new Date().toISOString())When the record was last modified (enabled by default)
deletedAttimestampnullablenoneWhen the record was soft-deleted (disabled by default)

Type inference

  • createdAt. Always present.
  • modifiedAt. Present unless modified: { enable: false }.
  • deletedAt. Absent unless deleted: { 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:

typescript
enrichTz(baseSchema: TColumnDefinitions, opts?: TTzEnricherOptions): TColumnDefinitions
Soft delete pattern

The deletedAt column enables soft delete - records are marked deleted rather than physically removed.

typescript
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

typescript
generateUserAuditColumnDefs(opts?: TUserAuditEnricherOptions): {
  createdBy: PgIntegerBuilderInitial | PgTextBuilderInitial;
  modifiedBy: PgIntegerBuilderInitial | PgTextBuilderInitial;
}

Options:

typescript
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 RequestContextRegistry at insert/update time, then takes the user ID off the Authentication.AUDIT_USER_ID key. An application installs the resolver over that registry when it registers its default middlewares, backed by Hono's contextStorage. A host that installs none - a browser Worker - has no request context at all, which is the allowAnonymous case 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. See ControllerFactory.

Emitted columns:

ColumnData typeDefault column nameDescription
createdByinteger or textcreated_byUser ID who created the record
modifiedByinteger or textmodified_byUser ID who last modified the record

allowAnonymous behavior:

allowAnonymousNo contextNo user IDHas user ID
true (default)Returns nullReturns nullReturns user ID
falseThrows errorThrows errorReturns 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:

typescript
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

typescript
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):

OptionTypeDefaultDescription
discriminatorstring'principal'Prefix for generated column names
defaultPolymorphicstring''Default value for the type discriminator column
polymorphicIdType'number' | 'string'(required)Data type of the ID column
isNullableIdbooleanfalseWhen true, the ID column is nullable (omits NOT NULL)
isNullableDiscriminatorbooleantrueWhen false, the type discriminator column is NOT NULL; the default leaves it nullable

Emitted columns, given discriminator = 'principal':

ColumnDB column nameTypeConstraintsDescription
principalIdprincipal_idinteger or textNOT NULL (unless isNullableId)ID of the associated entity
principalTypeprincipal_typetextDEFAULT '' (or defaultPolymorphic); NOT NULL when isNullableDiscriminator is falseType discriminator (e.g. 'User', 'Post')

With a custom discriminator such as 'owner', columns become ownerId / owner_id and ownerType / owner_type.

typescript
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:

typescript
enrichPrincipal<ColumnDefinitions extends TColumnDefinitions>(
  baseSchema: ColumnDefinitions,
  opts: TPrincipalEnricherOptions,
): ColumnDefinitions & TPrincipalColumnDef

generateDataTypeColumnDefs ​

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

typescript
generateDataTypeColumnDefs(opts?: TDataTypeEnricherOptions): {
  dataType: PgTextBuilderInitial;
  nValue: PgDoublePrecisionBuilderInitial;
  tValue: PgTextBuilderInitial;
  bValue: PgCustomColumnBuilder<Buffer>;
  jValue: PgJsonbBuilderInitial<Record<string, any>>;
  boValue: PgBooleanBuilderInitial;
}

Options (TDataTypeEnricherOptions):

typescript
type TDataTypeEnricherOptions = {
  defaultValue: Partial<{
    dataType: string;
    nValue: number;
    tValue: string;
    bValue: Buffer;
    jValue: object;
    boValue: boolean;
  }>;
};

Emitted columns:

ColumnSQL typeDB column nameTypeScript typePurpose
dataTypetextdata_typestringType discriminator ('number', 'text', 'json')
nValuedouble precisionn_valuenumberNumeric values
tValuetextt_valuestringText values
bValuebyteab_valueBufferBinary values
jValuejsonbj_valueRecord<string, any>JSON values
boValuebooleanbo_valuebooleanBoolean 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.

typescript
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:

typescript
enrichDataTypes(baseSchema: TColumnDefinitions, opts?: TDataTypeEnricherOptions): TColumnDefinitions

Key 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:

typescript
type TTableSchemaWithId<TC extends TableConfig = TableConfig> = Table<TC> & {
  id: TIdColumn;
};

TTableObject / TTableInsert ​

Infer the select (output) and insert (input) types from a table schema:

typescript
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

typescript
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:

typescript
type TValueOrResolver<T> = T | TResolver<T>;  // T or () => T

Used 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.

typescript
idParamsSchema(opts?: { idType: TIdSchemaType }): z.ZodObject<{ id: z.ZodNumber | z.ZodString }>
idTypeDefaultZod typeExamples
'number'Yesz.number()[1, 2, 3]
'string'z.string()['4651e634-...', 'some_unique_id']

Throws for invalid idType values.

jsonContent ​

Creates an OpenAPI JSON content specification:

typescript
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:

typescript
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:

typescript
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.

typescript
snakeToCamel<T extends z.ZodRawShape>(shape: T): z.ZodEffects<...>

The resulting schema, in order:

  1. Accepts snake_case input, validated against the original schema.
  2. Transforms keys to camelCase at runtime.
  3. Validates the transformed data against a camelCase schema.
typescript
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 methodPostgreSQL utility function
LocationAbstractEntity.getIdType() (base/models/base.ts)getIdType() (connectors/core/models/common/types.ts)
SignaturegetIdType(): TIdSchemaTypegetIdType<T extends TTableSchemaWithId>(opts: { entity: T }): string
PurposeNeutral 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
typescript
// 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 ​