Schema Presets (Cross-engine)

nuxt-auto-api ships per-dialect column preset helpers so every table gets timestamps, softDelete, tenant, id, audit, and json columns with one import — and because auto-api's features are convention-driven (detect deletedAt by name; updatedAt self-manages via drizzle $onUpdate), importing a preset activates the feature automatically. No extra config.

Schema Presets (Cross-engine)

nuxt-auto-api ships per-dialect column preset helpers so every table gets timestamps, softDelete, tenant, id, audit, and json columns with one import — and because auto-api's features are convention-driven (detect deletedAt by name; updatedAt self-manages via drizzle $onUpdate), importing a preset activates the feature automatically. No extra config.

Why per-dialect

Drizzle has no cross-dialect column abstraction: integer comes from sqlite-core, timestamp/jsonb from pg-core, datetime/json from mysql-core. Presets are thin per-dialect modules behind three subpath exports — exactly how you already choose sqliteTable vs pgTable.

EngineImport subpath
better-sqlite3 / D1 / Turso@websideproject/nuxt-auto-api/schema/sqlite
Postgres@websideproject/nuxt-auto-api/schema/pg
MySQL / PlanetScale@websideproject/nuxt-auto-api/schema/mysql

Usage (SQLite example)

import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { id, timestamps, tenant, softDelete, audit, liveUnique, json } from '@websideproject/nuxt-auto-api/schema/sqlite'

const sd = softDelete()   // { columns, indexes(t) }

export const articles = sqliteTable('articles', {
  ...id(),                  // uuid text PK
  title: text('title').notNull(),
  slug: text('slug').notNull(),
  ...tenant(),              // organizationId text
  ...timestamps(),          // createdAt + updatedAt (self-managing)
  ...audit(),               // createdBy + updatedBy (filled by audit-stamp plugin)
  ...sd.columns,            // deletedAt, deletedBy, deletionId, deletedReason
}, t => [
  ...sd.indexes(t),         // articles_trash_idx (deletedAt) + articles_batch_idx (deletionId)
  ...tenant.indexes(t),     // articles_tenant_org_idx
  liveUnique(t, t.slug, 'articles_slug_live'),  // partial unique among live rows
])

Postgres and MySQL are identical except the import path.

Preset reference

PresetReturnsAuto-activates
id(){ id } UUID PK—
timestamps(){ createdAt, updatedAt }updatedAt self-updates via $onUpdate
audit(){ createdBy, updatedBy }Filled by audit-stamp plugin (built-in, column-detected)
tenant(opts?){ organizationId } + tenant.indexes(t)Tenant scoping (if multiTenancy configured)
softDelete(opts?){ columns, indexes(t) }Full soft-delete pipeline (list hide, restore, purge, cascade)
json(name)Dialect-correct JSON column—
liveUnique(t, col, name)Partial unique index among live rowsPrevents unique-collision on deleted rows

softDelete() options

softDelete({
  by:     true,   // include deletedBy (userId who trashed). default: true
  batch:  true,   // include deletionId (batch restore/purge). default: true
  reason: true,   // include deletedReason. default: true
})

Index naming

softDelete().indexes(t) and tenant.indexes(t) auto-namespace their index names with the owning table (e.g. articles_trash_idx, articles_batch_idx, articles_tenant_org_idx). This is required because SQLite and Postgres index names must be globally unique within a database — a fixed trash_idx would collide the moment a second table adopts the preset. The table name is read from the column inside the index callback, so you don't pass it yourself.

Cross-engine differences

ConcernSQLitePostgresMySQL
UUID PKtext + $defaultFnuuid().defaultRandom()varchar(36) + $defaultFn
Timestampinteger(mode:'timestamp')timestamp({withTimezone})datetime
JSONtext(mode:'json')jsonbjson
Partial unique (liveUnique)✅ WHERE clause✅ WHERE clause❌ use liveUniqueMysql()

MySQL liveUnique caveat

MySQL has no filtered indexes. Use liveUniqueMysql() which documents the generated-column workaround:

import { liveUniqueMysql } from '@websideproject/nuxt-auto-api/schema/mysql'
// See the generated migration comment for the required ALTER TABLE statement.

The audit-stamp plugin

createdBy/updatedBy are filled server-side automatically when the columns exist — no extra config:

  • createdBy is set on create (null for system/task writes).
  • updatedBy is overwritten on every create + update (client-supplied value is ignored).
  • Raw drizzle writes (privacy erasure, migrations) bypass it — intended.

The plugin is registered in the default plugin set; tables without the columns are unaffected.

"Import = feature on" principle

Add this presetGets this auto-api behavior
...timestamps()updatedAt auto-refreshes on every update
...softDelete()List hides deleted, restore route, purge/trash, cascade
...tenant()Tenant scoping when multiTenancy is configured
...audit()createdBy/updatedBy stamped from request user

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.