Schema Presets (Cross-engine)
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.
| Engine | Import 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
| Preset | Returns | Auto-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 rows | Prevents 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
| Concern | SQLite | Postgres | MySQL |
|---|---|---|---|
| UUID PK | text + $defaultFn | uuid().defaultRandom() | varchar(36) + $defaultFn |
| Timestamp | integer(mode:'timestamp') | timestamp({withTimezone}) | datetime |
| JSON | text(mode:'json') | jsonb | json |
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:
createdByis set on create (null for system/task writes).updatedByis 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 preset | Gets 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 |
Multi-Tenancy
Build multi-tenant SaaS APIs where every organization only ever sees its own rows.
Permissions Cookbook
Recipes for fine-grained access control, from "public blog" to "plan-gated, per-field, per-organization". The concepts behind them are in Authentication & Authorization.