Module Kit

Module Kit database helpers

Give your module tables, add columns to another module's table, ship both dialects and commit migrations.

Your module needs a table of its own, or one more column on a table another module owns. It must run on SQLite and PostgreSQL, and its test fixtures need committed migrations. This page is the full reference for module authors. Set up the module first: Overview.

App developers start at Database. Database extensions explains why tables are extended this way.

Helpers

These helpers work on the schema that NuxtHub generates, not on the database.

HelperSignaturePurpose
registerDatabaseTable(nuxt: Nuxt, options: DatabaseTableOptions) => voidThe owning module registers its schema file.
extendDatabaseTable(nuxt: Nuxt, options: TableExtensionOptions) => voidAnother module adds a column file to the tables it extends.
defineTableColumns<T extends Record<string, ColumnBuilderBase>>(columns: T) => TTypes a columns object in an extension file.
onDatabaseSchemaExtend(nuxt: Nuxt, extender: DbExtenderFn) => voidHooks hub:db:schema:extend to add schema files by hand.
defineExtendableTable<T>(table: ({ name, columns }) => T) => ({ name, columns }) => TDeprecated; export plain tables.

The calls only register hooks. NuxtHub does the work when it builds the schema, so there is nothing to await. The option and callback types are exported from @frigear-nu/module-kit/utils: DatabaseTableOptions, TableExtensionOptions, DbExtenderFn and TableExtenderFn.

Registering a table

The owning module ships a plain Drizzle schema file and registers it:

src/runtime/server/db/schema.sqlite.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'

export const users = sqliteTable('users', {
  id: integer().primaryKey({ autoIncrement: true }),
  email: text().notNull().unique(),
})

export const userPasskeys = sqliteTable('user_passkeys', {
  id: integer().primaryKey({ autoIncrement: true }),
  userId: integer().notNull().references(() => users.id, { onDelete: 'cascade' }),
})
src/module.ts
import { createResolver } from '@nuxt/kit'
import { registerDatabaseTable } from '@frigear-nu/module-kit/utils'

// in setup()
const resolver = createResolver(import.meta.url)

registerDatabaseTable(nuxt, {
  resolver,
  schema: dialect => `./runtime/server/db/schema.${dialect}`,
})

Every export initialised by a table call (sqliteTable(...), pgTable(...)) is registered. Other exports are ignored. All tables of the file are generated into one file, so they can reference each other, as the foreign key above does. The base file decides the table function and the SQL table name of the merged result.

registerDatabaseTable options

OptionTypeDefaultPurpose
resolverReturnType<typeof createResolver>requiredResolves the schema path.
schema(dialect: string) => stringrequiredThe schema file for a dialect, such as dialect => `./runtime/db/schema.${dialect}`.
namestring | string[]every table exportRestricts which table exports are registered.
overridestringnoneA path from the app's root that replaces the generated table. 'default' keeps the generated one.
extensionFile(dialect: string) => stringserver/db/extend.<dialect>.tsMoves the extension file each layer can have. Also turns off the legacy lookup.
factorystringnoneDeprecated; export plain tables.

name is for restricting only. Auth passes it because which of its tables exist depends on the enabled features:

packages/auth/src/module.ts
const tables = ['authUserTokens']
if (withWebAuthn) tables.push('userCredentials')
if (withPasswordReset) tables.push('passwordResets')
if (withMagicLink || withVerifyEmail) tables.push('magicLinks')

registerDatabaseTable(nuxt, {
  name: tables,
  resolver,
  schema: dialect => `./runtime/db/schema.${dialect}`,
})

A name that the file does not export throws. So does a name whose export is not a table call.

One schema file per dialect

NuxtHub resolves schema and file for the host app's dialect, sqlite or postgresql. So every schema and extension file ships twice, as .sqlite.ts and .postgresql.ts. Both export the same tables, with the same table, column and index names.

registerDatabaseTable throws at build time when the file for the active dialect is missing. The error names the dialect, the expected file and the override option.
src/runtime/server/db/schema.postgresql.ts
import { pgTable, text, serial, integer } from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: serial().primaryKey(),
  email: text().notNull().unique(),
})

export const userPasskeys = pgTable('user_passkeys', {
  id: serial().primaryKey(),
  userId: integer().notNull().references(() => users.id, { onDelete: 'cascade' }),
})

Map the types like this, so the Drizzle data types match:

sqlitepostgresql
sqliteTable (drizzle-orm/sqlite-core)pgTable (drizzle-orm/pg-core)
integer().primaryKey({ autoIncrement: true })serial().primaryKey()
integer({ mode: 'timestamp' })timestamp({ withTimezone: true })
integer({ mode: 'boolean' })boolean()
text({ mode: 'json' })jsonb()
integer() holding epoch millisecondsbigint({ mode: 'number' })
customType({ dataType: () => 'text' })the same customType from pg-core

A Module Kit test fails when a .sqlite.ts schema has no .postgresql.ts twin. Keep the two files in step with a test in test/schema-parity.test.ts. expectSchemaParity from @frigear-nu/module-kit/testing throws and lists every difference. It compares tables, columns (Drizzle data type, not null, primary key, unique, defaults, $defaultFn, $onUpdateFn), indexes and foreign keys. compareSchemaDialects returns the same list instead of throwing.

test/schema-parity.test.ts
import { it } from 'vitest'
import { expectSchemaParity } from '@frigear-nu/module-kit/testing'
import * as sqlite from '../src/runtime/server/db/schema.sqlite'
import * as postgresql from '../src/runtime/server/db/schema.postgresql'

it('schema.postgresql matches schema.sqlite', () => {
  expectSchemaParity(sqlite, postgresql)
})
Runtime code must work on both dialects too. Avoid db.run, db.batch, PRAGMA, rowid and other SQL that only SQLite understands, unless you add a PostgreSQL branch. changeId in packages/users/src/runtime/server/user-store.ts shows one.

Extending a table

Another module adds columns to a table it does not own with extendDatabaseTable. The column file exports, under the table's name, a table call, a plain columns object or a defineTableColumns call:

src/module.ts
// in setup()
extendDatabaseTable(nuxt, {
  resolver,
  file: dialect => `./runtime/db/users.${dialect}`,
})
src/runtime/db/users.sqlite.ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'

export const users = sqliteTable('users', {
  locale: text().default('da'),
})
src/runtime/db/users.postgresql.ts
import { pgTable, text } from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  locale: text().default('da'),
})

The file is merged only into registered tables it has a matching export for. Other tables are untouched, and so are other exports of the file. So you never need to name the table. When the owning module is not installed, nothing matches and the call does nothing.

OptionTypeDefaultPurpose
resolverReturnType<typeof createResolver>requiredResolves the file path.
file(dialect: string) => stringrequiredThe column file for a dialect. A missing file is skipped, with a warning when the sqlite file exists.
namestring | string[]every tableOffers the file only to these tables. Matching exports already decide, so it is rarely needed.

Merge order

Columns are merged in this order. A column that appears again replaces the earlier one and moves to the end of the table.

  1. The base schema file of the owning module.
  2. Files registered with extendDatabaseTable, in registration order.
  3. For each Nuxt layer, from the base layers towards the host app: the legacy server/db/<name>.extend.<dialect> file, then the shared server/db/extend.<dialect>.ts. One shared file can extend several tables:
    server/db/extend.sqlite.ts
    export const users = defineTableColumns({ locale: text().default('da') })
    export const posts = defineTableColumns({ summary: text() })
    

Legacy <name>.extend.<dialect> files with a default export still work. Passing extensionFile replaces the shared file location and turns off the legacy lookup.

Constraints of schema files

Schema and extension files are parsed as text, not executed. The generated file is built from what the parser finds, so code it does not understand is lost.
  • Only top-level exports initialised by a table call are registered. In an extension file, a plain columns object or a single-argument wrapper such as defineTableColumns({ ... }) works too.
  • Named imports are carried over and de-duplicated, and a value import wins over type X. Relative paths such as '../types' become absolute. Default and namespace imports are not carried over.
  • Top-level helpers, constants and type aliases are not carried over. Declare what a column needs inline, or import it by name. packages/events/src/runtime/server/db/schema.sqlite.ts writes a customType inline this way.
  • Indexes and constraints (the third argument of the table call) of the base file are kept. In an extension file they are dropped with a warning, and so is a SQL table name that differs from the base. Put indexes on extension columns in the base file or in a migration.

Hooks

Module Kit uses two build-time hooks. Hook them yourself only to add files by hand.

HookPayloadPurpose
hub:db:schema:extend{ dialect: string, paths: string[] }NuxtHub's hook. registerDatabaseTable pushes each generated file to paths.
module-kit:db:table:extend{ name: string, dialect: string, paths: string[] }Called once per registered table. extendDatabaseTable pushes its file to paths.

Generated files

Merged tables are written to .nuxt/module-kit/db/<kebab-case table names>.<dialect>.ts, such as .nuxt/module-kit/db/users-user-passkeys.sqlite.ts. A name over 80 characters is shortened and gets a hash suffix. NuxtHub builds the schema from exactly these files, so open them when a migration looks wrong.

NuxtHub bundles them into .nuxt/hub/db/schema.mjs on every boot and every nuxt prepare. It caches the generated types in .nuxt/cache/nuxthub/db/ and reuses them while the inputs are unchanged, so the bundle takes milliseconds after the first boot. Delete that folder to force a full rebuild.

Migrations and database-backed module tests

A migration is what changes the database. Every app that installs your module needs one, including each test fixture with committed migrations. Fixtures that have them are listed by ls packages/*/test/fixtures/*/server/db/migrations.

Generate migrations from the app's complete module configuration, including every module that extends a table. A migration generated from the owning module's tables alone misses every extension column.

Checking and updating a fixture's migrations

Run these steps from the repository root after changing a schema or an extension, for every fixture with committed migrations.

Check the fixture

nuxt db generate prepares the fixture, so its schema includes every extension, and compares it with the committed migrations. Pass --cwd as an absolute path: a relative one is resolved twice and fails.

pnpm --filter @frigear-nu/<package> exec nuxt db generate --cwd "$PWD/packages/<package>/test/fixtures/<fixture>"

Read the result

No schema changes, nothing to migrate and a clean git status mean the fixture is in sync, and you are done. Any other output means the fixture needs a new migration, and the command has already written one.

Keep or rename the migration it wrote

The fixture needs that migration. Keep it and go to the last step, or delete it and generate it again with a name in the next step. Delete a stray migration without replacing it only when the schema change itself was unintended.

Generate a named migration (optional)

With NuxtHub 0.10.8 the command does not pass --name on correctly. To pick the migration's name, use this manual flow, which gives the same result.
pnpm --filter @frigear-nu/<package> exec nuxt prepare "$PWD/packages/<package>/test/fixtures/<fixture>"
pnpm --filter @frigear-nu/<package> exec drizzle-kit generate \
  --dialect sqlite \
  --schema "$PWD/packages/<package>/test/fixtures/<fixture>/.nuxt/hub/db/schema.mjs" \
  --out test/fixtures/<fixture>/server/db/migrations/sqlite \
  --name <migration-name>
pnpm --filter @frigear-nu/<package> test

The filtered exec runs in the package folder, so --out is relative to the package and --schema is absolute. Use generate, not drizzle-kit check: it creates a migration only when something changed.

Commit the migration

Commit the SQL with meta/*_snapshot.json and meta/_journal.json. They are versioned schema history, so never edit or delete a committed migration. Never commit .nuxt, .data, node_modules or dist.
What you should see: the first step prints No schema changes, nothing to migrate, and git status shows only the migration you mean to commit.

Database-backed fixture requirements

  • Configure a test-only session password and a local SQLite or libsql database.
  • A fixture that includes a module using requireAuth or requireUser needs the auth_user_tokens migration. The Auth fixtures show it.
  • Use dev: true in the @nuxt/test-utils/e2e setup, so NuxtHub applies the fixture's committed migrations at startup. Turning off build-time migrations does not turn these off.
  • Remove only the fixture's own .data folder before the suite, and clean it up afterwards.
  • Assert a request's HTTP status, with the response body as the message, before its side effects, such as a session cookie. The underlying HTTP or database error then shows.
  • Run the suite twice, to check that migrations apply and that runs do not share data. Users and Events have working fixture lifecycles.

Next steps

Copyright © 2026