Upgrade Guide
Upgrading from 0.5.6 to 0.6.0
Update every installed @holo-js/* package to 0.6.0 together. Change the versions in package.json and run your package manager's install command, such as npm install. Keep your lockfile. No database migration is needed: the next npx holo migrate, npx holo build or npx holo dev writes your types from the database you already have. 0.6.0 removes public APIs and changes how holo build and migrate --isolated work, so review the sections below before deploying. The changelog lists the release changes.
0.6.0 makes your database the only description of your schema, as in Laravel. Holo no longer keeps a schema registry in memory or replays migrations that already ran. After migrate, migrate:rollback, migrate:fresh and holo build, and before holo dev, holo seed and holo media:regenerate start, it reads the default connection's database and writes the generated schema from it, and a model's casts set the types the database can't state. See Migrations and model types.
The release also brings Laravel's primary(), dropPrimary(), dropUnique(), dropConstrainedForeignId(), dropMorphs(), dropTimestamps() and dropSoftDeletes(). On SQLite, schema.table() can now rename and drop columns, rename indexes, and add and drop foreign keys.
Deploy checklist
- Give the build environment access to the default connection's database.
holo buildreads it and fails when it can't. On SQLite, the database file must exist where the build runs. - If you run
npx holo migrate --isolated, make every server use the sameredisordatabasedefault cache store before you deploy. Seemigrate --isolateduses a cache lock. - Run this one deploy's
npx holo migratefrom a single replica. - Once every replica runs 0.6.0, drop the
_holo_migration_lockstable. - On SQLite, rewrite numbers and booleans that earlier releases stored in text columns as
'0.0'or'42.0'. See SQLite binds booleans and whole numbers as integers.
holo build reads your database
holo build no longer loads or runs your migrations. It reads the tables and columns of the default connection's database and writes .holo-js/generated/schema.generated.ts from them. The build environment needs the same database settings as production and a reachable database. If it can't read the database, holo build fails with an error that names the connection. Run your migrations before you build. See Build.
A schema.generated.ts written by an earlier release can still call .generated(), which 0.6.0 removes. Every command, including the holo prepare that your install runs, replaces such a file with an empty generated schema, prints a one-line notice and continues. npx holo migrate, npx holo build and npx holo dev then write it again from your database.
holo dev now writes the generated schema from the database when it starts, so a fresh clone gets its model types without a separate migrate. If it can't read the database, it warns and starts with the schema it has. holo seed and holo media:regenerate write it before they run, too, and continue with the schema they have when they can't read the database.
On SQLite, only npx holo migrate and npx holo migrate:fresh create a missing database file and its directory. holo build fails with the error that names the connection instead. Your app no longer opens the SQLite file when it starts, and it never creates it: pages that don't query the database work without it, and a query fails with Database file at path "./storage/database.sqlite" does not exist. Run "holo migrate" to create it. On a fresh clone, run npx holo migrate before you build.
Model types come from the database and casts
A model's attribute types start from its columns in the database. A built-in cast now sets the attribute's type where the database can't state it:
'boolean','number'and'string'casts type the attribute asboolean,numberandstring.'date','datetime'and'timestamp'casts type it asDate, and'vector'asreadonly number[].- A
'json'cast types it as atable.json()column is typed.
The cast's type applies when you read the attribute and when you pass it to create(), update(), make(), fill() and pendingAttributes. Enum, encrypted, binary and custom casts, enumCast() included, keep the column's type. See How casts and types fit together.
New SQLite tables use Laravel's type names: tinyint(1) for booleans, date for dates, datetime for datetimes and timestamps, varchar(255) for strings, and text for JSON. SQLite tables created before 0.6.0 keep their stored INTEGER and TEXT types, so the database reports their booleans as numbers and their dates and JSON as strings. Add casts to type those columns, and add a 'json' cast to every SQLite JSON column, old or new:
import { defineModel } from '@holo-js/db'
export default defineModel('users', {
casts: {
is_admin: 'boolean',
preferences: 'json',
email_verified_at: 'datetime',
},
})You can instead give those columns the new SQLite types with a migration that redeclares each one with change(). Holo rebuilds the table with Laravel's type name and keeps the rows, and the next npx holo migrate writes the column's real type to the generated schema. This is the only way to type a pivot column, such as one read through withPivot(), because pivot columns have no model casts. It also makes the generated types from your existing database match those from a fresh database, such as one in CI, that ran the same migrations:
import { defineMigration } from '@holo-js/db'
export default defineMigration({
async up({ schema }) {
await schema.table('post_tag', (table) => {
table.boolean('is_primary').default(false).change()
})
await schema.table('users', (table) => {
table.datetime('email_verified_at').nullable().change()
table.json('preferences').nullable().change()
})
},
})Restate nullable() and default() on each changed column, because change() replaces the column's definition. A json() column is still TEXT on SQLite, so keep its 'json' cast.
The model's own timestamp columns need no cast. As in Laravel, created_at, updated_at, the soft-delete deleted_at and the pivot columns of withTimestamps() are read and typed as Date even when the column is TEXT. A Date written to an old TEXT column, or passed to where(), is stored and compared as UTC text in the format of SQLite's CURRENT_TIMESTAMP, such as 2026-01-02 03:04:05, as Holo now writes every SQLite date. Earlier releases wrote ISO 8601 text such as 2026-01-02T03:04:05.000Z. Holo still reads those values as the right Date, and password-reset and email-verification links issued before the upgrade still work. But those values don't compare or order correctly against the new format or against values your CURRENT_TIMESTAMP defaults stored. For example, a notification sent after the upgrade is listed below older ones from the same day, and the database session sweep keeps sessions that expired earlier the same day. Holo's own tables hold these values too. Rewrite every date column once in a migration, your tables and Holo's:
import { defineMigration } from '@holo-js/db'
const dateColumns = [
{ table: 'posts', key: ['id'], columns: ['created_at', 'updated_at'] },
{ table: 'post_tag', key: ['post_id', 'tag_id'], columns: ['created_at', 'updated_at'] },
{ table: 'users', key: ['id'], columns: ['email_verified_at', 'created_at', 'updated_at'] },
{ table: 'sessions', key: ['id'], columns: ['created_at', 'last_activity_at', 'expires_at', 'invalidated_at'] },
{ table: 'auth_identities', key: ['id'], columns: ['created_at', 'updated_at'] },
{ table: 'personal_access_tokens', key: ['id'], columns: ['last_used_at', 'expires_at', 'created_at', 'updated_at'] },
{ table: 'password_reset_tokens', key: ['id'], columns: ['expires_at', 'used_at', 'created_at', 'updated_at'] },
{ table: 'email_verification_tokens', key: ['id'], columns: ['expires_at', 'used_at', 'created_at', 'updated_at'] },
{ table: 'auth_multi_factor_credentials', key: ['id'], columns: ['enabled_at', 'created_at', 'updated_at'] },
{ table: 'notifications', key: ['id'], columns: ['read_at', 'created_at', 'updated_at'] },
{ table: 'media', key: ['id'], columns: ['created_at', 'updated_at'] },
{ table: '_holo_migrations', key: ['id'], columns: ['migrated_at'] },
]
export default defineMigration({
async up({ db, schema }) {
if (db.getDialect().name !== 'sqlite') return
for (const { table, key, columns } of dateColumns) {
if (!(await schema.hasTable(table))) continue
for (const column of columns) {
const rows = await db.table(table).select(...key, column).where(column, 'like', '%T%Z').get()
for (const row of rows) {
const match = key.reduce((query, name) => query.where(name, row[name]), db.table(table))
await match.update({ [column]: new Date(String(row[column])) })
}
}
}
},
})Replace posts and post_tag with your own tables, their date columns, and the columns that identify a row: its primary key, or both foreign keys of a pivot table without an id. The other entries are the tables that Holo's auth, session, notification and media migrations create, and Holo's migration log. If you renamed one, such as a password broker's table or the media table, use your name. A table that doesn't exist is skipped. The database cache, lock, queue job and failed job tables store their times as integer milliseconds, so they need no rewrite.
PostgreSQL and MySQL state booleans, JSON, timestamps and enum values themselves, so their models need no new casts. See SQLite tables created before 0.6.
Two cast writes change:
- A
'json'cast stores SQLNULLfornull, and encodes a string as a JSON string, as Laravel's does. Before,nullwas stored as the JSON textnulland a string was stored as given. Pass the value itself, not a string you encoded withJSON.stringify(), or it is encoded twice. Rows written earlier with the textnullstill read asnull, butwhereNull()doesn't match them. - A
trueorfalsewritten to an integer column, for example through a'boolean'cast on PostgreSQL, is stored as1or0.
On SQLite, diffSchema() reports the columns of tables created before 0.6.0 as type mismatches against your table definitions, for example TEXT against varchar(255). New date and datetime columns have SQLite's NUMERIC affinity, so a numeric-looking string such as '20240101' written to one is stored as a number.
migrate --isolated uses a cache lock
npx holo migrate --isolated takes an atomic lock named migrations from your application's default cache store, as Laravel's does, instead of a row in the _holo_migration_locks table. A run that finds the lock held still skips its migrations and exits successfully. The lock is released when the run ends, and expires after an hour if a runner stops. Holo doesn't renew it during the run.
--isolated now needs a default store that every server shares, such as redis or database. Without a config/cache.ts, the default store is the file store in storage/framework/cache/data, which only coordinates processes that share that folder, so replicas on different servers all run the migrations. If Holo can't load the default store, or the store can't give a lock, --isolated fails with an error that says what to set up. Plain npx holo migrate doesn't use the cache. If your app has no cache config yet, install one:
npx holo install cache --driver redisA database store keeps its locks in a table, so create the cache tables with a plain migrate before the first isolated run:
npx holo install cache --driver database
npx holo cache:table
npx holo migrateRun the upgrade deploy's migrate from one replica
Replicas still on 0.5.x lock through the _holo_migration_locks table, and replicas on 0.6.0 lock through the cache. The two locks don't see each other, so during a rolling upgrade both versions could migrate at once. For the deploy that ships 0.6.0, run npx holo migrate from a single replica. Go back to --isolated on every replica in later deploys.
Holo no longer creates the _holo_migration_locks table. Once every replica runs 0.6.0, drop it with a migration:
import { defineMigration } from '@holo-js/db'
export default defineMigration({
async up({ schema }) {
await schema.dropTable('_holo_migration_locks')
},
})dropTable() drops the table only if it exists, so the migration also runs on databases that never used --isolated.
MigrationService.migrateIsolated() and the IsolatedMigrationResult type are removed from @holo-js/db. Use npx holo migrate --isolated.
change() restates every modifier
change() replaces the column's definition with exactly the modifiers you give, as in Laravel. Before, Holo kept modifiers such as nullable() and default() from the column's earlier definition. Restate every modifier you want to keep:
import { defineMigration } from '@holo-js/db'
export default defineMigration({
async up({ schema }) {
await schema.table('users', (table) => {
table.text('bio').change()
table.text('bio').nullable().default('').change()
})
},
})Check every change() call, including those in migrations that already ran: migrate:fresh and new databases, such as CI's, run every migration again. On SQLite, change() now rebuilds the table from the database, so it keeps every other column, index, foreign key and trigger, including columns your migrations never declared. See change().
Removed APIs
| Removed | Use instead |
|---|---|
SchemaService.sync(), SchemaService.previewSync(), the SchemaSyncPlan type | A migration that calls createTable() |
The generated() column modifier | A database default() or defaultNow(), or a generated column created with raw SQL |
SchemaService.register(), SchemaRegistry, createSchemaRegistry(), DatabaseContext.getSchemaRegistry(), the schemaRegistry option of createDatabase() | The generated schema, which holo migrate and holo build write from your database |
MigrationService.planSquash(), MigrationService.hasRan(), MigrationService.getExecutionPolicy(), the MigrationSquashPlan and MigrationExecutionPolicy types | MigrationService.status() |
createSchemaDiff() | diffSchema() |
MigrationService.migrateIsolated(), the IsolatedMigrationResult type | npx holo migrate --isolated |
alterColumnOperation() and compileAlterColumn() no longer take the previous column definition.
Replace sync() with a migration, as in Laravel:
import { defineMigration } from '@holo-js/db'
export default defineMigration({
async up({ schema }) {
await schema.sync()
await schema.createTable('comments', (table) => {
table.id()
table.foreignId('post_id').constrained().cascadeOnDelete()
table.text('body')
table.timestamps()
})
},
async down({ schema }) {
await schema.dropTable('comments')
},
})Replace generated() with a default the database fills in, so the column stays optional in create():
table.timestamp('synced_at').generated()
table.timestamp('synced_at').defaultNow() A column the database fills in through a trigger, without a default, is now required in create(). A computed column, declared with storedAs() or virtualAs() or by raw SQL, stays out of the insert and update types, and models never write it. A model throws Column "total" is generated and cannot be written directly. before writing any row when one is named as an increment() target, an insertUsing() destination, an upsert() update column, a pivot attribute or a pivot relationship key such as product_id, or when the write fills it as a timestamp: created_at on inserts, updated_at on inserts and updates, and the soft-delete deleted_at on deletes and restores. Pivot rows of a withTimestamps() relation follow the same rule, so updateExistingPivot() works on a pivot whose created_at is computed, while attach() throws. On a pivot whose updated_at is computed, updateExistingPivot() returns 0 when nothing changes and throws when it would write. Computed columns are readonly properties on typed models, so set() and assignment reject them at compile time, and at runtime both throw the same generated column error without changing the model.
Read status() to see whether a migration ran:
const ran = await migrations.hasRan('2026_05_01_090000_add_views_to_posts')
const ran = (await migrations.status()).some(migration =>
migration.name === '2026_05_01_090000_add_views_to_posts' && migration.status === 'ran') diffSchema() takes the same arguments createSchemaDiff() did:
import { createSchemaDiff } from '@holo-js/db'
import { diffSchema } from '@holo-js/db'Tables resolve through the generated schema
DB.table('name'), model tables and pivot tables resolve through the generated schema only, and only on the default connection, the one the generated schema is read from. A table that createTable() created earlier in the same process, such as in a script that creates a table and then fills it, uses no definition until the generated schema is written again, so the JSON values you write to it aren't encoded, and booleans bind as 1 or 0, as Laravel's do on every driver. On PostgreSQL, a text column of such a table stores '1', where it stored 'true' before. Pass a table definition to DB.table() to use its columns right away. Name-only query-builder reads use the generated table definition when available, matching reads started with that definition: SQLite decimals return strings padded to their declared scale, booleans return booleans, and declared integer columns return numbers when safe or throw a HydrationError on overflow. Tables absent from the generated schema keep the driver's values. See Start a query.
A model on the default connection whose table isn't in the generated schema throws Model "posts" is not present in the Generated schema. Run npx holo migrate or npx holo build to write it. A model with connectionName set to another connection is untyped, as in Laravel: its attributes are unknown in TypeScript, and at runtime it uses the id key, the timestamp and soft-delete columns by convention, your casts, and the driver's values. If it sets only a non-empty guarded, it can write only the table's real columns, as in Laravel; with guarded: [] it can write any attribute or JSON path. A same-named table on the default connection no longer changes how it reads or writes, but TypeScript still types the model from that table. See Point a model at a connection.
On PostgreSQL, insertGetId() now returns an integer key as a number, as Laravel's insertGetId() does, whether you pass a table name or a definition. Before, it returned the driver's text, such as '5'. A UUID or other non-numeric key still comes back as a string. Compare the ID with numbers, not strings.
Schema operations that now fail instead of doing nothing
These changes can make a migration that hasn't run yet fail, as it would in Laravel. Run your pending migrations against a copy of your database first.
dropIndex()with a name that doesn't exist raises an error on SQLite and PostgreSQL, as it already did on MySQL. Indexes keep their old names afterrenameTable(), so drop them by those names or by their columns.dropIndex()also takes columns now. On PostgreSQL, drop the constraint of aunique()column withdropUnique(). See dropIndex().- On PostgreSQL and SQLite, creating an index whose name another table already uses fails instead of skipping the index. This happens when you create a table under a name you renamed away; give its indexes names of their own. Running the same
createTable()again still succeeds, now on MySQL too. See unique(columns). - A
unique()column whose generated name another table's index already uses fails the migration instead of skipping the unique. - On SQLite,
change()inside your own transaction throwsSchemaErrorwhen another table references the rebuilt table, because SQLite can't turn off foreign key checks there. Run it in a migration or outside the transaction. A rebuild that would leave a foreign key pointing at a missing row throwsSchemaErrorand changes nothing. - On SQLite,
dropColumn()raises SQLite's error for a column that an index, unique constraint, primary key or foreign key uses. Drop the index or unique first. See dropColumn().
Unique and foreign key names
unique() columns are now named {table}_{column}_unique on every database, shortened with a hash past 63 bytes. Before, PostgreSQL named them {table}_{column}_key, MySQL named them after the column, and SQLite named their index sqlite_autoindex_{table}_{n}. Foreign keys declared inside createTable() or added with a new column are now named {table}_{column}_foreign on PostgreSQL and SQLite too. Before, PostgreSQL named them {table}_{column}_fkey and SQLite left them unnamed. Existing constraints keep their old names.
migrate:fresh and every new database, such as CI's or a new developer's, run your migrations against tables that this release creates, so they carry the new names. A migration that drops a constraint by its old name, such as dropUnique('tenants_owner_email_key'), works on your upgraded database and fails on a fresh one. Drop uniques, indexes and foreign keys by their columns instead:
await schema.table('tenants', (table) => {
table.dropUnique(['owner_email'])
table.dropForeign(['owner_id'])
})Holo reads the table from the database and drops the constraint that covers exactly those columns, whatever its name, so the same migration works on both. Laravel computes the conventional name instead. dropIndex([...]), dropConstrainedForeignId() and dropMorphs() work the same way. If more than one constraint covers the columns, the error lists their names; drop by name only in a migration that runs on one database. foreign(column, name) inside createTable() now takes a constraint name, as it already did inside table(). See dropUnique() and dropForeign().
Composite primary keys
primary(columns) gives a table a primary key on several columns. Models don't support composite keys, as in Laravel: a model on such a table throws SchemaError when an operation needs its key, such as find(), create() or save(), unless you set its primaryKey option to one of the key columns. Defining the model, and holo prepare and holo migrate, don't fail. Query the table with DB.table() or use it as a belongsToMany pivot. See Primary keys.
The CLI reads database settings only from config/database.ts
holo migrate, migrate:rollback, migrate:fresh, holo build and the other database commands use the connection that config/database.ts resolves, as the app does and as Laravel's artisan does. Before, DB_DRIVER, DB_URL, DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE, DB_SCHEMA, DB_SSL and DB_LOGGING set in your shell overrode the default connection for those commands only, even where the config hard-coded a value, so a command could migrate a different database than the app used.
A variable now matters only where your config reads it with env(). If you set DB_SSL or DB_LOGGING in your shell or CI, or relied on a shell DB_* variable to override a value your config sets literally, read it in config/database.ts:
import { defineDatabaseConfig } from '@holo-js/db'
import { env } from '@holo-js/config'
export default defineDatabaseConfig({
connections: {
main: {
driver: 'mysql',
database: 'my_app',
database: env('DB_DATABASE', 'my_app'),
ssl: env('DB_SSL', false),
logging: env('DB_LOGGING', false),
},
},
})New projects read DB_LOGGING this way, and new MySQL projects also read DB_SSL. On PostgreSQL, leave ssl out and set sslmode in DB_URL or PGSSLMODE, which the driver reads when ssl isn't set.
PostgreSQL schema sets search_path
A PostgreSQL connection's schema option now also sets the connection's search_path, as Laravel does. Before, Holo read the configured schema but ran unqualified statements in the server's default search_path, usually public. If you set schema to something other than public without a search_path, holo migrate and unqualified queries now create and use tables in that schema. With schema: 'public', as new projects set it, the search_path is public alone, so move tables that live in a schema named after the database user into public. A search_path in the URL's options parameter or in PGOPTIONS still wins over schema. See PostgreSQL schema.
On PostgreSQL, schema.getTables(), getColumns(), getIndexes() and getForeignKeys() read the schema that unqualified statements use, so they find the tables your migrations created. That is schema, or the first existing schema of a search_path that wins over it. On MySQL, getColumns() reports the enum and uuid logical types, and getTables() leaves out views. See getColumns().
MySQL bigint values above the safe integer range come back as text
A MySQL bigint above Number.MAX_SAFE_INTEGER now comes back as its exact text, such as '9007199254740993', in raw queries, queries started with a table name absent from the generated schema, and untyped models. Before, the driver rounded it to the nearest number, such as 9007199254740992, so the value you read could point at a different row. Smaller values are still numbers, and DECIMAL values are unchanged.
Models and table definitions that type such a column as number throw a HydrationError for it, on every database. If a column can hold larger values, use uuid(), ulid() or snowflake() keys, or read it with raw SQL as text. See Large integers.
PostgreSQL connections use app.timezone as their session time zone
Holo now sets each PostgreSQL connection's session TimeZone to app.timezone, as it already did on MySQL. Before, the server's time zone applied, so on a server whose time zone isn't app.timezone, defaultNow() and timestamps() defaults on timestamp columns were written in the server's wall-clock time and read back off by the difference. Rows written that way keep their values; Holo doesn't rewrite them. To keep the server's time zone, set TimeZone in the URL's options parameter, in the pg options you pass to createPostgresAdapter(), or in PGOPTIONS. See PostgreSQL dates and time zones.
SQLite binds booleans and whole numbers as integers
On SQLite, Holo now binds true, false and whole numbers as integers, as Laravel does through PDO. Before, it bound every number as a real, so a TEXT or varchar column stored false as '0.0', true as '1.0' and 42 as '42.0'. A 'boolean' cast reads those old values correctly, but where('flag', false) or where('code', 42) compares against '0' or '42' and doesn't match rows still holding '0.0' or '42.0'. Once, for each text column that Holo wrote booleans or numbers to, rewrite the old values:
UPDATE my_table SET my_column = CAST(my_column AS INTEGER)
WHERE my_column GLOB '*.0' AND CAST(my_column AS INTEGER) || '.0' = my_columnThe statement only changes text that is a whole number followed by .0, so running it twice is safe. Integer, boolean, real and date columns need no change. A raw ? / ? with whole-number bindings now divides as integers. See SQLite.
Upgrading from 0.5.5 to 0.5.6
Update every installed @holo-js/* package to 0.5.6 together. Change the versions in package.json and run your package manager's install command, such as npm install. Keep your lockfile. No required migrations and no app code changes. The changelog lists the release changes.
0.5.6 regenerates the model and auth types after holo migrate, holo migrate:fresh and holo migrate:rollback, so models and auth.user() are typed from the new tables without running holo prepare again.
Upgrading from 0.5.4 to 0.5.5
Update every installed @holo-js/* package to 0.5.5 together. Change the versions in package.json and run your package manager's install command, such as npm install. Keep your lockfile. No required migrations. The changelog lists the release changes.
0.5.5 adds most of Laravel's remaining query builder and Eloquent methods, such as whereNotLike(), whereToday(), updateOrInsert(), truncate(), findOr() and createOrFirst(). Two existing queries now behave as in Laravel, so check them before upgrading:
On PostgreSQL,
whereLike()ignores case (ILIKE). Before, it was case-sensitive. Passtrueas the third argument to keep a case-sensitive match:tsawait DB.table('users').whereLike('name', 'Mo%', true).get()where(column, null)compiles toIS NULLandwhere(column, '!=', null)toIS NOT NULL. Before, they compiled to= NULLand!= NULLand matched no rows. Remove such calls if you relied on them returning nothing.
On SQLite, Date values for columns the schema doesn't define are stored as ISO strings instead of throwing an error.
Upgrading from 0.5.3 to 0.5.4
Update every installed @holo-js/* package to 0.5.4 together. Change the versions in package.json and run your package manager's install command, such as npm install. Keep your lockfile. No required migrations and no app code changes. The changelog lists the release changes.
0.5.4 fixes holo commands failing when app files import package subpaths such as next/headers, and a command file that fails to load no longer breaks the other holo commands.
Upgrading from 0.5.2 to 0.5.3
Update every installed @holo-js/* package to 0.5.3 together. Change the versions in package.json and run your package manager's install command, such as npm install. Keep your lockfile. No required migrations and no app code changes. The changelog lists the release changes.
0.5.3 enforces enum() values in the database on PostgreSQL and SQLite, and holo install cache writes only the stores the chosen driver needs.
Enum columns are enforced on PostgreSQL and SQLite
enum() columns created from now on get a CHECK constraint named <table>_<column>_check on PostgreSQL and SQLite, so the database rejects values outside the list. MySQL already used a native ENUM and doesn't change.
Enum columns created before this release keep accepting any string: Holo doesn't alter existing tables on upgrade. Changing another column of the same table with change() leaves them as they are, on SQLite too, where change() rebuilds the whole table. To add the constraint, redeclare each enum column with change() in a new migration:
import { defineMigration } from '@holo-js/db'
export default defineMigration({
async up({ schema }) {
await schema.table('users', (table) => {
table.enum('status', ['active', 'disabled']).default('active').change()
})
},
})The migration fails, and changes nothing, if existing rows hold a value outside the list. Find them first, for example with SELECT DISTINCT status FROM users, and update them or add the value to the list. On PostgreSQL, change() replaces a constraint that already has the <table>_<column>_check name, such as one you added by hand.
Upgrading from 0.5.1 to 0.5.2
Update every installed @holo-js/* package to 0.5.2 together. Change the versions in package.json and run your package manager's install command, such as npm install. Keep your lockfile. No required migrations and no app code changes. The changelog lists the release changes.
0.5.2 fixes Next.js page size: pages that don't call useValidationErrors() no longer load the validation errors provider and @holo-js/validation.
Upgrading from 0.5.0 to 0.5.1
Update every installed @holo-js/* package to 0.5.1 together. Change the versions in package.json and run your package manager's install command, such as npm install. Keep your lockfile. No required migrations. Review the removal below before deploying. The changelog lists the release changes.
0.5.1 adds the digits(), minDigits(), and maxDigits() number rules, and useValidationErrors() for Next.js server actions posted from forms.
serializeValidationException() is removed
@holo-js/validation no longer exports serializeValidationException(). You no longer need to turn a validation failure into a payload yourself:
- Route handlers answer with a 422 when
validate()throws. useForm()shows the errors a server action returns.- A server action passed to
<form action>shows its errors throughuseValidationErrors(), with or without JavaScript.
Remove try/catch blocks that only returned the payload:
export async function createPostAction(formData: FormData) {
try {
const data = await validate(formData, postForm)
await Post.create(data)
redirect('/posts')
} catch (error) {
const payload = serializeValidationException(error)
if (payload) return payload
throw error
}
}To handle the error in your own code, narrow it with isValidationException(error). error.errors is its error bag, and error.toJSON() is the 422 response body. See Error Messages.
Upgrading from 0.4.4 to 0.5.0
Update every installed @holo-js/* package to 0.5.0 together. Change the versions in package.json and run your package manager's install command, such as npm install. Keep your lockfile. No required migrations. Review the two behavior changes below before deploying. The changelog lists the release changes.
0.5.0 adds task scheduling. Run holo install schedule to add it.
Next.js server actions return validation failures
When the browser calls a server action and it throws a validation or rate-limit error, the action now returns the { ok: false, ... } payload instead of answering with a 500. Forms that use useForm() show the field errors as before and need no change.
If your client code calls an action directly and catches the thrown error, read the returned value instead. The action's TypeScript return type doesn't include the payload, so in 0.5.0 read it with serializeValidationException(). The next release removes that function (upgrade section):
try {
await createPostAction(formData)
} catch (error) {
showErrors(error)
}
const failure = serializeValidationException(await createPostAction(formData))
if (failure) {
showErrors(failure.errors)
} Actions called from server code or from inside another action, form posts sent without JavaScript, redirect(), and other errors still throw. See Framework Recipes.
Redis connections with both a url database and a db
When a Redis connection sets a url with a database path, such as REDIS_URL=redis://127.0.0.1:6379/0, and also sets db, queues, sessions, the rate limiter, and cache stores now use the db database. Before, they used the URL's database. When db is not set, the URL's database still applies.
After the upgrade, jobs, sessions, rate-limit counters, and cache entries stored in the URL's database are no longer read. Before deploying, drain those queues, or make the url and db name the same database. See Redis cache and Redis queues.
Upgrading from 0.4.3 to 0.4.4
Update every installed @holo-js/* package to 0.4.4 together. No breaking changes or required migrations. The changelog lists the release changes.
Change the versions in package.json and run your package manager's install command, such as npm install. Keep your lockfile. From 0.4.4, raising every Holo pin no longer makes npm stop with ERESOLVE ... Conflicting peer dependency, including across minor releases.
Upgrading from 0.4.2 to 0.4.3
Update every installed @holo-js/* package to 0.4.3 together. No breaking changes or required migrations. The changelog lists the release changes.
Hydrating rows loaded outside a model query
From 0.4.3, use Model.hydrate(row, sourceConnection?) to build a saved model from a row you already read from the model's table. Unlike make(), it skips mass assignment checks, applies the query read casts, fires retrieved, and returns a model with exists() equal to true and no changes. Pass only database rows, never request input.
const tenant = Tenant.make(row)
const tenant = await Tenant.hydrate(row) When the row comes from a connection whose driver differs from the model's driver, pass that source connection, such as await Tenant.hydrate(row, tx), so values such as JSON decode correctly. This controls decoding only; relations and writes use the model's own connection. Keep using make() for new, unsaved models.
Upgrading from 0.4.1 to 0.4.2
Update every installed @holo-js/* package to 0.4.2 together. Review these two behavior fixes before deploying. The changelog lists the release changes.
PostgreSQL typed query results
DB.table(tableDefinition) now reads id(), integer(), bigInteger() and foreignId() columns as numbers on every driver, including columns from table definitions passed to join(). On PostgreSQL these values were previously strings, despite their inferred number types. Compare them with numbers and update any client types or cached values that depended on strings. Values above Number.MAX_SAFE_INTEGER throw a HydrationError instead of losing precision.
Queries started with a table name and raw SQL keep the driver's values. See Typed results.
SvelteKit Node deployment origin
withHoloSvelteKit() no longer copies build-time APP_URL into paths.origin. If your deployment relied on that behavior, choose how to set its public origin:
- To reuse a build across deployment URLs, use
import adapter from '@holo-js/adapter-sveltekit/node'invite.config.ts, passadapter()to your existing SvelteKit configuration, and setAPP_URLwhen starting the server. The default output starts withnode build/index.js;HOSTandPORTselect its listener. - For a fixed origin, set
paths.originexplicitly and rebuild when it changes. This setting takes precedence over runtimeAPP_URLin the Holo Node adapter.
The Holo Node adapter accepts adapter({ out: 'custom/build' }) for a separate output directory inside the project, started with node custom/build/index.js. Output must not overlap source or framework directories or traverse symbolic links. BODY_SIZE_LIMIT defaults to 512 KiB and accepts bytes or a K, M, or G suffix, such as 64M. The adapter uses the socket address for client identity; configure trusted proxies separately when deploying behind a proxy.
The native @sveltejs/adapter-node 6 remains supported. It does not read runtime ORIGIN; use its build-time origin or trusted proxy configuration.
Upgrading from 0.3.x to 0.4.0
0.4.0 is a breaking release. Before you install it, upgrade Node.js and your framework: see Node.js 22.18 or newer, Framework peer dependencies, and SvelteKit 3. Then work through the sections below that apply to your app. The changelog lists every change.
TIP
With npm, changing the @holo-js/* versions in package.json and running npm install stops with ERESOLVE ... Conflicting peer dependency on this upgrade. Remove the Holo packages first, then install them at the new version. Your lockfile keeps your other dependencies:
npm uninstall @holo-js/core @holo-js/cli @holo-js/db
npm install --save-exact @holo-js/[email protected] @holo-js/[email protected] @holo-js/[email protected]List every @holo-js/* package from your package.json in both commands.
Realtime and Broadcast are Experimental in 0.4.0. Review their production limits before relying on live updates. This label does not change runtime behavior.
Apple callbacks and signing-key rotation
Serve your Apple callback over HTTPS. Holo now sends its state cookie with SameSite=None; Secure, allowing Apple's cross-site form POST while keeping state validation. Keep the callback as a POST handler and exclude it from CSRF protection. Behind a proxy that terminates TLS, list the proxy in trustedProxies so Holo sees the HTTPS request and sends Secure; no other configuration changes are required.
Apple, Clerk, and WorkOS signing keys now expire after ten minutes. An unknown key ID can refresh them at most once per minute, and concurrent requests share the fetch. A newly rotated key may therefore take up to a minute to be accepted. Tokens with invalid signatures under a known key ID no longer trigger a fetch. If a refresh fails, Holo keeps the keys it last loaded for at most ten minutes after that last successful fetch, then rejects tokens until a refresh succeeds. Failed refreshes never extend that window.
Forwarded headers follow Laravel's TrustProxies
Holo now reads forwarded headers exactly as Laravel's TrustProxies middleware does:
- Only
X-Forwarded-For,X-Forwarded-Host,X-Forwarded-Port,X-Forwarded-Proto, andX-Forwarded-Prefixare honored. TheForwardedheader is ignored. If your proxy sends onlyForwarded, configure it to sendX-Forwarded-*headers. - When a header lists several values, Holo takes the first, not the last.
X-Forwarded-Protovalues other thanhttps,on,ssl, and1mean HTTP instead of answering400.X-Forwarded-PortandX-Forwarded-Prefixnow change the port and path ofresolveRequestUrl().X-Forwarded-Forentries that aren't IP addresses are skipped instead of becoming the client IP.- WorkOS sign-in sends WorkOS the client IP that Holo resolves through
trustedProxies, instead of the firstX-Forwarded-Forentry orX-Real-IP. - Nuxt requests that arrive over a Unix socket, including in
nuxt dev, come from127.0.0.1. List'127.0.0.1'intrustedProxiesto read forwarded headers from that proxy. - When the framework doesn't expose the connection address, as in Next.js, forwarded headers are trusted only with
trustedProxies: '*'. A Next.js app with a list of proxies now ignores them, soip(request)and IP-keyed rate limits fail with a configuration error. SetTRUSTED_PROXIESin production, as described below. See Next.js.
New projects trust no proxy until TRUSTED_PROXIES is set
Projects created with holo new used to scaffold trustedProxies: '*' in Next.js, which trusts forwarded headers from any client. The scaffold now reads the list from the TRUSTED_PROXIES environment variable, and trusts no proxy when it's unset. Apps scaffolded earlier keep their '*', so they still trust any connection. To make the setting come from the environment, replace that line in config/security.ts and keep the rest of the file:
import { env } from '@holo-js/config'
import { defineSecurityConfig } from '@holo-js/security'
const proxies = env('TRUSTED_PROXIES').trim()
export default defineSecurityConfig({
trustedProxies: proxies === '*' ? '*' : proxies.split(',').map(entry => entry.trim()).filter(Boolean),
})Then set TRUSTED_PROXIES in each production deployment: * on Vercel, or * when you self-host behind a proxy that overwrites X-Forwarded-For and the Next.js server listens only on localhost or a private network. A list of addresses doesn't work in Next.js. A Next.js deployment without it doesn't fall back to a shared rate-limit bucket. Its first IP-keyed rate-limit check fails with a configuration error. Make sure your proxy is in place before you deploy. See Trusted proxies.
Temporary URLs behind a proxy
Public and temporary storage URLs read the scheme, host, port, and path prefix from forwarded headers only when trustedProxies lists the proxy, as resolveRequestUrl() does. Before, the Nuxt storage route accepted X-Forwarded-Proto from any client, and the other forwarded headers were ignored.
If your Nuxt or SvelteKit app runs behind a proxy that terminates TLS and serves temporary URLs, list the proxy:
import { defineSecurityConfig } from '@holo-js/security'
export default defineSecurityConfig({
trustedProxies: ['10.0.0.1'],
})Without that, the app sees http, and a temporary URL signed with https:// returns 404. Public files keep working because their path doesn't depend on the scheme.
Realtime definitions must reduce to browser stubs
Holo now builds the browser copy of each server/realtime file only from the definitions it can reduce, and sends none of your server code. A definition it can't reduce stops the build or dev server with the file and export named; nothing is left out silently. See Define queries.
Holo recognizes a definition that is exported directly:
export const posts = query({...})orexport default query({...}), and the same withmutation(),defineRealtimeQuery(), ordefineRealtimeMutation().- The factory imported by name (
import { query } from '@holo-js/realtime'), or called through the package's default or namespace import (realtime.query({...})).
import { query } from '@holo-js/realtime'
const LIST_POSTS = 'posts.list'
export const listPosts = query({
name: LIST_POSTS,
access: 'public',
handler: async ({ db }) => await db.table('posts').get(),
})Check these before you upgrade:
namemust be a string literal, a template literal without${}, or aconstin the same file set to one. A name from an import,env(), or any other expression fails the build. Write it inline or as a same-fileconst.- Don't spread another object into the options after
name, or into options that have noname, since the spread could change the name. Those definitions fail the build. A spread beforenameis fine. - Export each definition directly. A definition re-exported through a local alias, such as
const posts = query({...})followed byexport { posts }, fails the build. - A
server/realtimefile with no definitions fails the build. Move server-only helpers out ofserver/realtime. - Files inside
server/realtimecan't be symbolic links, and neither can folders that point outside it. Holo stops with an error naming the link.server/realtimeitself can be a link to another folder. With Next.js, that folder must be inside the Turbopack root (turbopack.root, or the workspace root Next.js finds from your lockfile).
Browser source maps for server/realtime modules no longer include the server source, so a deployment that serves source maps doesn't expose handler code. No change is needed for this.
HOLO_SECURITY_TRUST_PROXY is removed
HOLO_SECURITY_TRUST_PROXY is gone. Holo now identifies visitors by their connection address, and reads forwarded headers only from proxies you list in config/security.ts:
import { defineSecurityConfig } from '@holo-js/security'
export default defineSecurityConfig({
trustedProxies: ['127.0.0.1', '::1'],
})- Remove
HOLO_SECURITY_TRUST_PROXYfrom your.envfiles. - If you set it, list your proxies' addresses or ranges instead. Use
'*'for a hosting platform whose proxy address you don't know. See Trusted proxies. - Next.js apps need
trustedProxies, because Next.js doesn't expose the connection address. - Call
ip(request)without the second argument. It now followstrustedProxies.
Realtime subscription isolation
Server subscriptions now authorize and execute independently, including subscriptions to the same query and arguments. No query definition or subscribe call changes are required. Check that each definition's access and handler restrict results to its caller; anonymous callers are rejected by authenticated definitions. Refreshes retain the caller’s authentication credentials and database without retaining a completed transaction. Mutation patches and backfill batches stay within the originating database. Size database capacity for one initial execution and independent refreshes per server subscription. Browser client store sharing is unchanged.
Realtime relations honor hidden attributes
Realtime patches for a relation loaded with with(...) now follow the related model's serialization rules. A relation whose related model sets hidden, visible, casts, accessors, or appended is re-run instead of patched, so values hidden by the model no longer reach subscribers through belongs-to, has-one, has-many, or belongs-to-many relations. The same applies to a root model that sets those rules and loads a belongs-to relation: Holo re-runs the query when the foreign key changes. A handler that calls makeHidden(), makeVisible(), setHidden(), setVisible(), append(), setAppends(), or withoutAppends() on the records it loads gets the same re-run. No code changes are required. Subscriptions to those relations and queries run the handler more often. Relations to plain related models keep incremental patching.
Model scope grouping
Review model queries and relation constraints that use orWhere(). Caller conditions, each global scope, and the soft-delete filter now form separate groups joined with AND. Queries can no longer use OR to reach foreign-tenant or deleted rows, and instance writes remain limited to their target row. Existing method signatures are unchanged.
Use the existing withoutGlobalScope() / withoutGlobalScopes() and withTrashed() / onlyTrashed() controls when deliberately querying outside those filters; apply scope-removal methods before caller conditions as described in Scopes.
Mass assignment is closed by default
A model without fillable or guarded now rejects every column in create(), update(), make(), fill(), set(), property assignment, and query update(), upsert(), increment(), and decrement() extra values. Before, it allowed every column. Add a fillable list to each such model, or guarded: [] to allow every column except the primary key and timestamps:
export default defineModel('posts', {
fillable: ['title', 'body', 'status'],
})The primary key and the created_at and updated_at columns are now guarded unless you name them in fillable. fillable: ['*'] and guarded lists no longer let request data write them. Holo still generates IDs and timestamps itself. Query upsert() rows that include created_at or updated_at now need those columns in fillable.
Mass assignment rules are now checked when you assign attributes, not when you save. fill(), set(), and property assignment throw the SecurityError immediately, and save() writes every changed column. A JSON path such as settings->role follows its column's rules. forceFill() assigns without checking, so the next save() writes the columns you pass it, even when they're guarded. Code that relied on forceFill() failing for guarded columns should use fill() instead.
Saving or deleting a loaded model now targets the row it was loaded from, even after its primary key changes. See mass assignment.
Because make() checks these rules and its model is unsaved, make(row) no longer turns a row you already loaded into a model: it throws a SecurityError for the primary key. When upgrading to 0.4.3 or newer, use hydrate(). Don't add the primary key to fillable for this.
Raw SQL conditions on update() and delete()
unsafeWhere() and orUnsafeWhere() conditions on update() and delete() now need security: { allowUnsafeRawSql: true } on the connection, as they already did on reads. Before, the default policy let them run on writes, so a condition built from user input could match every row. Without the setting, these calls throw a SecurityError and change nothing. Rewrite the condition with where() and its related methods, or turn the setting on for that connection if you need raw SQL. Password reset and email verification links keep working with the default policy.
security: { allowUnsafeRawSql: true }Attribute names __proto__, constructor, and prototype
Model writes and forceFill() reject __proto__, constructor, and prototype as attribute names with a SecurityError, even when the model is unguarded. Reading an attribute with one of those names returns undefined unless the model has such a column. Rename any column with one of these names.
Postgres JSON path segments
A JSON path segment on Postgres is now matched as one key, however it is written. Before, a comma split a segment into two keys, and a quote, backslash, or brace changed which key the path named, so whereJson('settings->a,b', ...) read settings at a then b. Queries whose segments contain these characters now match the key they name. Check any stored query or saved path that relied on the old split.
SQLite JSON path segments
On SQLite, a JSON path segment that isn't a plain identifier is now matched as one key. Plain identifiers such as theme are unchanged. Before, a key with a comma, space, quote, backslash, bracket, dot, or leading digit matched no rows in whereJson(), and updateJson() on it changed nothing without an error. Those paths now match and update the key they name, so check any updateJson() call that targets such a key.
Memory cache limit
The memory cache driver keeps at most 10,000 entries when maxEntries isn't set, and removes the oldest entries first. Before, it had no limit, so a long-running process could grow without bound. Set maxEntries to choose a different limit. See Memory.
Lifecycle, factories, and seeders
Attribute changes made in creating, updating, and saving handlers are now written. Before, the insert or update was built before those handlers ran, so their changes were dropped. Mutators run once per write, so make() followed by save() and factory-made records no longer apply them twice. make() stores the values you pass without running mutators or casts; they run when the record is saved.
save() on a model with no changes now fires saving and saved, as in Laravel, and writes nothing. Clean saves no longer write updated_at or fire updating and updated; before, a timestamped model's clean save did both. An updating handler that reverts every change fires saved without an UPDATE. After a save, wasChanged() and getChanges() report what the save wrote, so edits a saved handler makes don't appear there. A save that creates a record reports no changes, as in Laravel. Lifecycle handlers now receive the model you called save() on rather than a copy, and after save() returns the model is clean even when a saved handler changed an attribute: that change is not written. If your saving or saved handler assumed a clean save runs no code, guard it on isDirty().
Models loaded with withCount() or withExists() save without error, and their computed values are never written. Changes made in place to JSON and date attributes, such as pushing onto a JSON array, are detected and saved.
Factories and holo seed now run without mass-assignment guards, as in Laravel. Remove unguarded() wrappers around factories and seeders; code outside them still follows the guards.
Error reporting
Errors Holo catches in background work, such as failing queue hooks, background cache refreshes, auth cleanup, and broadcast worker messages, now go through one reporter. Without configuration they are still written to the console, now with console.error and a [holo:<capability>] <operation> prefix instead of the previous per-package messages. To send them to your error tracker, add exceptions.report and, optionally, exceptions.dontReport to config/app.ts, and use report(error) from @holo-js/core for errors you handle yourself. See Reporting errors.
Next.js servers close Holo resources on shutdown
When a Next.js server receives SIGINT or SIGTERM, it now starts closing Holo's database, Redis, queue, and other connections right away, then exits once the close finishes or 30 seconds after the signal. Requests still running at that point can fail if they use those connections, so remove the server from your load balancer before you send the signal. Give your process manager at least 30 seconds to stop the server. For example, set terminationGracePeriodSeconds in Kubernetes or TimeoutStopSec in systemd. Nuxt already closed Holo resources on shutdown and now reports a failed close through exceptions.report. No code changes are required. See Stop the server.
Unexpected SvelteKit errors
Without a handleError export in src/hooks.server.ts, an unexpected error in a SvelteKit app is logged through the reporter, and the response carries SvelteKit's generic message, Internal Error, instead of the error's message. The error's message is still returned while debug is true in config/app.ts. Export handleError if your client needs a different message. See Unexpected errors in SvelteKit.
Debug defaults to false
debug in config/app.ts now defaults to false when APP_DEBUG is unset, as in Laravel. Before, it defaulted to true, so apps without APP_DEBUG showed the messages of unexpected errors in SvelteKit responses. Set APP_DEBUG=true in your local .env file to keep those messages during development. Apps that set APP_DEBUG already, including the scaffold's .env, keep their value. SvelteKit error responses and Nuxt's useHoloDebug() follow the same setting. Next.js doesn't read it. See Configuration.
Broadcast subscription errors and channel authorization
A refused channel subscription no longer closes the WebSocket. The worker sends pusher:subscription_error for that channel and keeps the connection open, so Flux keeps the browser's other channels and tries the refused channel again the next time you subscribe to it. If your own client waited for close code 4001 after a refused subscription, listen for pusher:subscription_error instead.
holo broadcast:work now checks unsigned private and presence subscriptions with the user from the connection's session cookie or Authorization header, and refuses them when nobody is signed in. Before, those rules received null. Subscriptions signed by /broadcasting/auth are unchanged.
The worker also stops sending internal error messages. Unexpected realtime errors reach clients as Realtime request failed., failed publishes return Broadcast publish failed., and the original errors go to your error reporter. See Errors.
Broadcast connection limits
Each Broadcast browser connection can hold 100 channels and 100 realtime query subscriptions, and can send 200 messages in each one-second window. Clients within these limits need no change.
A subscription beyond the channel or realtime limit fails with status 429, and the socket stays open. A connection that exceeds the message rate, or that has more than 4 MB of output waiting for it, is closed with code 1008. If a page needs more live queries than one connection allows, split them across pages.
Browser connections reconnect
Realtime and Flux reconnect on their own after the WebSocket closes or fails. They wait about half a second before the first try, then wait longer after each failed try, up to 30 seconds, with some randomness so browsers don't all retry at once. Live queries and channels are sent again on the new connection, and a live query shows the current result. Flux re-authorizes private and presence channels during reconnect.
Two behaviors changed for app code. A worker restart no longer calls the onError of every active subscription, so don't rely on that callback to detect an outage. A realtime mutation that was already sent when the socket closed rejects with a connection error and isn't sent again, because the server may already have run it. Catch that error and retry only when the mutation is safe to repeat. See When the connection drops.
Broadcast dead connections and presence after a crash
The Node.js worker now pings each WebSocket connection every 30 seconds and closes any that don't answer. Browsers and clients built on a WebSocket library that answers pings, such as ws, need no change. A custom client that never answers WebSocket pings is disconnected within 60 seconds, so it must answer pings or send a message at least that often.
Workers that use Redis scaling now write a lease key, holo:broadcast:scaling:<connection>:nodes:<node-id>, every 10 seconds, and read presence keys to remove members of crashed workers. If your Redis user is limited by ACL rules, allow SET and EXISTS on holo:broadcast:scaling:*. Without them, the worker can't refresh its lease and its presence members disappear from other workers.
Realtime queries refresh across processes
With Redis scaling on, a query-builder or model write that commits in your web app or queue workers now publishes to holo:broadcast:scaling:<connection>:events once per Broadcast worker or app runtime sender in that process. Every Broadcast worker refreshes the realtime queries that read the changed rows, and each worker applies a write once, by its write ID, however many copies arrive. Those processes need the Redis connection, ioredis installed, and permission to PUBLISH on holo:broadcast:scaling:*. Without the permission, writes still commit, but realtime queries on other workers don't update.
Broadcast workers upgrade together
Upgrade every Broadcast worker that uses Redis scaling to 0.4 at the same time. A presence entry written by a worker from before this change has no lease. The upgraded workers treat it as belonging to a dead node and remove it, so during a mixed rollout users still connected to an old worker drop out of presence until they reconnect.
Stop all old workers, then start the 0.4 workers. Workers that don't use Redis scaling are unaffected.
Queue jobs interrupted by a stopped worker
Queue workers now recover jobs left behind by a worker that crashed or was killed. With the redis driver, another worker picks the job up once at least the connection's retryAfter seconds have passed; before, it could stay stuck until you cleared it. A running Redis worker now renews its hold on the job, so jobs longer than retryAfter are no longer handed to a second worker.
An interrupted run counts as an attempt on both the redis and database drivers. When a recovered job has no tries left, the worker fails it into the failed jobs table and calls its onFailed hook, instead of running it again. Raise tries on jobs that should survive a worker crash on their last attempt.
A redis or database connection's retryAfter must now be at least 1. Before, 0 was accepted; now the config fails to load with retryAfter must be greater than or equal to 1. If you set it to 0, remove it to use the default 90, or set another whole number of seconds.
A job whose name isn't registered now fails into the failed jobs table and the worker keeps going. Before, the worker stopped with an error and left the job reserved. If you rename or delete jobs, drain their queued jobs first or retry them later with npx holo queue:retry. See When a worker stops unexpectedly.
queue:listen waits for its worker
queue:listen now passes Ctrl+C, SIGINT, and SIGTERM to its worker and exits only after the worker has closed, so stopping queue:listen no longer leaves a worker running. A change that restarts the worker waits for the old worker to exit. If the old worker reports a process error while it stops, queue:listen exits with that error instead of starting a replacement. Before, it started one.
If you run queue:listen under a process manager, check that it stops when the manager sends SIGTERM. Your code doesn't change. See Develop with queue:listen.
Windows .env percent references
On Windows, .env values expand %NAME% from the process environment variables, as cmd.exe does, and %% becomes %. Variables defined elsewhere in the same .env file are not used for expansion. A value such as DATA_DIR=%USERPROFILE%\data now resolves to your profile folder, and a value that contains %% now contains a single %. A %NAME% whose variable isn't set stays as written. A value that contains %NAME% is rewritten when NAME is set in the process environment, including a secret, so write %% for a literal %. Check Windows .env values that contain a percent sign. Values on macOS and Linux are unchanged. See Values and types.
Sensitive input in validation failures
Passwords and fields marked sensitive() are now left out of every validation failure, including inside nested objects and arrays. This covers the 422 response in Next.js, Nuxt, and SvelteKit, ValidationException values and toJSON(), and submission.fail() and submission.serialize(). If your code reads a password back from a caught ValidationException, read it from the request instead.
In Nuxt, the cookie that flashes errors after a native form post no longer contains the submitted values, and it is marked Secure on HTTPS requests. useValidationErrors() works as before.
Validation input limits
Form bodies and query strings are now limited to 1,000 non-file fields, with a separate limit of 1,000 uploaded-file entries per form, and a list index must be lower than the number of different indexes sent for that list. Requests that break these limits, or that use __proto__, constructor, prototype, or the same name used with two shapes (an object and a list, or numbered indexes and []) in any order, fail validation with a 422 instead of throwing. Split forms that exceed either limit into smaller submissions; JSON can be used for larger non-file payloads. See Input limits.
A JSON or form body that can't be parsed is now validated as {} instead of throwing.
Errors thrown inside custom() and customAsync() are no longer turned into a validation message on _root. They reach your framework's error handling and respond with a 500. If a custom rule calls code that can throw for invalid input, catch the error in the rule and return a message string instead.
SvelteKit validation flash
The cookie that carries validation errors and submitted values to the next page in SvelteKit is now HttpOnly and encrypted with APP_KEY, like Holo's session cookies. Scripts can no longer read it, so useForm and useValidationErrors no longer read document.cookie. Instead, the root layout passes the flash through page data:
import { readValidationFlash } from '@holo-js/adapter-sveltekit'
export const load = ({ url }) => ({
holoValidationFlash: readValidationFlash(url),
})Pass url as shown. SvelteKit reruns the layout load on each navigation because it reads the URL, and readValidationFlash(url) hands the flash only to the page it was written for, so errors from one form never appear on another page. Pass the page data to each form that shows flashed errors or restores its input:
const login = useForm(loginForm, { pageData: data })
const errors = useValidationErrors('default', { pageData: data })Passing pageData is required for flash restoration. Without the layout load and pageData, a failed submission shows no field errors and doesn't restore the submitted input, on the server or in the browser. If APP_KEY is empty, Holo writes no flash cookie and logs a warning when debugging is on.
SvelteKit 3
Holo supports SvelteKit 3 (@sveltejs/kit 3.1 or newer and Svelte 5.57.1 or newer). SvelteKit 2 is no longer supported. Follow SvelteKit's migration guide, starting with npx sv migrate sveltekit-3, then make these Holo changes:
- Move the options from
svelte.config.jsinto thesveltekit()call invite.config.ts, wrapped withwithHoloSvelteKit(), and deletesvelte.config.js. SvelteKit 3 ignores that file, andholo preparestops with an error while it exists.
import adapter from '@holo-js/adapter-sveltekit/node'
import { withHoloSvelteKit } from '@holo-js/adapter-sveltekit/config'
import { sveltekit } from '@sveltejs/kit/vite'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
sveltekit(withHoloSvelteKit({
adapter: adapter(),
preprocess: vitePreprocess(),
})),
],
})- Replace
$libimports with#lib, and include the file extension. Add the subpath imports topackage.json:
{
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
}
}import { loginForm } from '#lib/schemas/auth.js'Make
tsconfig.jsonextend$app/tsconfigand listincludeandexcludeyourself. Holo's scaffold writes this for new projects.Import
Handle,HandleFetch,HandleServerError,Reroute, andsequencefrom@sveltejs/kit/hooks.json()is deprecated, so returnResponse.json(...)from endpoints.If your
handleErrorreadsstatusormessagefrom its input, read them from thekindanderrorfields instead. Holo's handler does this for you, and returns the same messages as before.Configure your Node deployment origin. When upgrading directly to 0.4.2 or newer, follow SvelteKit Node deployment origin.
Update your tsconfig.json
New projects get the tsconfig.json that each framework's own generator writes. Existing projects keep working, so this step is optional.
In Nuxt, replace { "extends": "./.nuxt/tsconfig.json" } with the layout nuxi init writes:
{
"files": [],
"references": [
{ "path": "./.nuxt/tsconfig.app.json" },
{ "path": "./.nuxt/tsconfig.server.json" },
{ "path": "./.nuxt/tsconfig.shared.json" },
{ "path": "./.nuxt/tsconfig.node.json" }
]
}Server files are then type-checked with Nuxt's server settings instead of the browser ones, and Holo adds its generated types and config/ to that server config. Run npx nuxt prepare and then npm run typecheck.
In Next.js, start from the tsconfig.json that create-next-app writes and add ".holo-js/generated/**/*.ts" to include. Its **/*.ts pattern also covers files at the project root, such as proxy.ts, so npm run typecheck may report errors in files it skipped before.
In SvelteKit, start from the tsconfig.json that sv create writes and add "server", "config", and ".holo-js/generated/**/*.ts" to include.
Request body limit
Holo now reads at most 1 MiB of any request body that isn't multipart/form-data before your code runs. This covers CSRF token checks, Nuxt CSRF, form validation, Realtime requests, Broadcast auth, and social sign-in callbacks. A larger body gets a 413 response. Set maxBodyBytes in config/security.ts to raise that limit for large JSON or text payloads. Multipart bodies, which carry file uploads, are limited to 8 MiB by maxUploadBodyBytes. Set it to raise that limit. See Request body limit.
Realtime query and mutation endpoints now accept only application/json requests and answer other content types with 415. The built-in Realtime client already sends JSON. Clients that call the endpoints directly must send Content-Type: application/json.
Forms throttle counts every submission
A form with throttle validates the submission, then checks the rate limit for every attempt, valid or invalid, before your handler runs. A throttled submission returns 429 with the submitted values and the field errors from validation. The limiter's key resolver receives the values after the schema's transforms, so [email protected] and [email protected] share one bucket when the schema lowercases and trims the email. For an invalid submission, values is undefined, so a .by() key resolver must return a non-empty string for every request. A limiter without a .by() resolver uses the default request key.
make:channel patterns
make:channel accepts only dot-separated segments of letters, digits, _, and -, with optional {param} placeholders, such as orders.{orderId}. Other patterns stop the command before any file is written. The generated file quotes the pattern with double quotes.
S3 file names with special characters
S3 disks now store files under their exact names. Before 0.4.0, a name with characters other than letters, digits, and -_.!~*'() was stored under its URL-encoded form: reports/Q1 café.pdf became the object reports/Q1%20caf%C3%A9.pdf, and its url() returned access denied or not found. After upgrading, Holo looks for reports/Q1 café.pdf, so rename those objects before you deploy. With the AWS CLI:
bucket=my-app-assets
aws s3api list-objects-v2 --bucket "$bucket" --query "Contents[?contains(Key, '%')].Key" --output text \
| tr '\t' '\n' \
| while IFS= read -r key; do
[ -n "$key" ] && [ "$key" != None ] || continue
aws s3 mv "s3://$bucket/$key" "s3://$bucket/$(node -p 'decodeURIComponent(process.argv[1])' "$key")"
doneLocal disks also change to exact names; see Local file names. If you call the low-level getKeys() on an S3 disk, it now returns :-separated keys in the same format as local disks.
S3 requests now time out, and writeStream() uploads parts of at most 64 MiB, so one stream can upload up to about 620 GiB. See S3 timeouts.
Nuxt builds don't keep storage credentials
nuxt build no longer writes the S3 credentials of your storage disks (accessKeyId, secretAccessKey, and sessionToken) into the build's options. The running server reads them when it starts, from the environment that config/storage.ts reads, such as STORAGE_DISKS_S3_ACCESS_KEY_ID. Set those variables on the server. Values that exist only in the build environment aren't available at runtime. See S3 storage.
S3 uploads now carry a Content-Type
S3 uploads now carry a Content-Type, taken from the file's first bytes or its extension. Before, S3 stored every object as binary/octet-stream, so browsers downloaded it. New uploads of browser-renderable types, such as images, PDF, text, and JSON, now display inline when a public bucket or CDN serves them. HTML, SVG, and XML are still stored as application/octet-stream, so they keep downloading. Existing objects are unchanged. See Content types.
Local file names
Local disks now store files under their exact names, like Laravel. Before 0.4.0, Holo stored reports/Q1 café.pdf as reports/Q1%20caf%C3%A9.pdf on disk. After upgrading, Holo looks for reports/Q1 café.pdf, and path() returns that location.
After you deploy 0.4.0, rename the existing files once for each local disk:
npx holo storage:decode-names --disk=local
npx holo storage:decode-names --disk=publicThe command prints what it renamed and what it skipped, and running it again right away changes nothing. Run it once, before new uploads arrive, and rename skipped files by hand. See storage:decode-names.
Low-level keys still use : between folders, but only %, :, and $ are escaped inside a name, so the key for reports/Q1 café.pdf is reports:Q1 café.pdf instead of reports:Q1%20caf%C3%A9.pdf. Old-style keys still read the same file. On local disks, file names ending in $ are now reserved for metadata, so put('prices.json$', ...) throws an error.
Local storage on Nuxt and symlinks
Nuxt apps now use the same local disk code as Next.js, SvelteKit, and plain Node. On Nuxt, stat(), listFiles(), readStream(), and writeStream() now work on local disks instead of throwing an unsupported-operation error. Files keep their names and locations, so no data moves.
The Nuxt adapter no longer adds holo:<disk> entries to Nitro's storage option or a holoStorage key to runtimeConfig. If your server code calls Nitro's useStorage('holo:local'), switch to Holo's disk:
import { useStorage } from '@holo-js/storage/runtime'
export default defineEventHandler(async () => {
return await useStorage('local').get('reports/daily.txt')
})@holo-js/storage no longer exports the Nitro helpers applyNitroStorageConfig(), buildStorageConfig(), buildStorageConfigWithDriver(), or the StorageConfig, NitroStorageLike, and StorageAdapterOptionsLike types.
On every framework, local disks now refuse to delete through symlinks that lead outside the disk root. delete(), removeItem(), and clear(base) throw instead of deleting the outside file, and clear() with no base empties the disk root without replacing a symlinked root folder. exists() returns false for a folder instead of throwing. If your app keeps files outside the disk root and links them in, delete them through a disk whose root contains them.
Local disk permissions
New files and folders on local disks use Laravel's permissions: 0600 files and 0700 folders on private disks, and 0644 files and 0755 folders on public disks. Existing files and folders keep their permissions. If another OS user serves your public files, check that it can still read them.
Media strings are file paths
addMedia() no longer downloads a string that starts with http:// or https://. A string is always a local file path, so post.addMedia('https://example.com/photo.jpg') now throws instead of downloading the file. Replace it with addMediaFromUrl():
// Before
await post.addMedia('https://example.com/photo.jpg').toMediaCollection('images')
// After
await post.addMediaFromUrl('https://example.com/photo.jpg').toMediaCollection('images')addMedia({ url }) is also removed. Use addMediaFromUrl(url) in its place.
Remote downloads now have a 30-second limit for the whole download, including reading the file. Slow downloads throw an error instead of waiting indefinitely.
Collections without disk() now use the media disk, which is public unless config/media.ts sets disk. Before 0.4.0 they used the default storage disk when it was public, and otherwise the public disk if one existed. Projects created before 0.4.0 have an empty config/media.ts, and that file doesn't read MEDIA_DISK, so setting the variable does nothing until you change the file to:
import { env } from '@holo-js/config'
import { defineMediaConfig } from '@holo-js/media/config'
export default defineMediaConfig({
disk: env('MEDIA_DISK', 'public'),
})Check this before deploying if your default storage disk is private or S3, since uploads otherwise go to public.
Image conversions reject images over 40 million pixels or 1,000 frames, the same limits upload validation uses. Holo generates images before its own database transaction starts. If your code calls addMedia() inside your own transaction, the image work still runs inside that transaction, so upload after it commits. Use addMediaFromDisk(path, disk) to attach a file that's already on a storage disk.
Database queue workers on MySQL
The database queue driver now needs MySQL 8.0.17 or later. Workers on MySQL and PostgreSQL skip jobs another worker is taking instead of waiting for them, so a MySQL worker no longer keeps retrying a job another worker already took. No config or code changes are required. See Run several workers.
Node.js 22.18 or newer
Holo now requires Node.js 22.18 or newer on the 22 line, or Node.js 24 or 26. The build tooling (tsup 9) needs 22.18, so Node.js 22.14 through 22.17 no longer install the framework. Check node --version in development, CI and production, and upgrade Node.js 22 installations to 22.18 or newer before installing 0.4.0.
Queue job timeouts, dispatch, and retries
A job that runs past its timeout now stops the worker, as in Laravel, and counts as an attempt. Before, the worker marked the job failed at once and moved on. Now the job stays reserved and runs again after the connection's retryAfter while tries remain. To keep failing on the first timeout, set failOnTimeout: true on the job. See Timeouts.
A job timeout, or the worker's --timeout, must now be less than its connection's retryAfter. The dispatch throws, or queue:work refuses to start, when it isn't. Loading the queue config also throws when a registered job's timeout isn't below its connection's retryAfter. timeout and sleep above 2147483 seconds are rejected too. Raise retryAfter or lower the value.
New dispatch options: .afterCommit() on a dispatch, and afterCommit: true on a queue connection (default false), queue the job only once the surrounding database transaction commits. @holo-js/queue now lists @holo-js/db as an optional peer for this. Install @holo-js/db if you use it; without it, dispatch runs right away.
Queued mail, notifications, and event listeners accept tries and backoff, and queue:work and queue:listen accept --tries and --backoff. These jobs still run once unless you set tries. The worker's --backoff applies only to jobs that don't set their own backoff.
After-commit queue failures reach the transaction
A job dispatched with .afterCommit(), or on a connection with afterCommit: true, that can't be queued when its transaction commits now makes the DB.transaction() call reject with that error. Before, Holo reported the error and the call resolved, so the caller never saw the failure. Catch the error around DB.transaction() if the request should carry on. onFailed still runs first, and after-commit callbacks registered after the failed dispatch in that transaction are skipped. See Dispatch after a database commit.
Event listener order and after-commit notification failures
Event listeners run in registration order, with sync and queued listeners interleaved. Before, every sync listener ran first and queued listeners were sent after them. If a listener depended on running after the queued listeners, move it after them or change it to a queued listener. A listener that throws stops the dispatch, so listeners registered after it don't run. See Order and errors.
A notification channel that fails after a transaction commits is reported through exceptions.report with capability notifications and operation channel.afterCommit. Before, it was dropped without a report. Configure exceptions.report if you want these failures in your error tracker. See Reporting errors.
Framework peer dependencies
Holo adapters now use your app's framework packages as peer dependencies instead of installing their own copies. Before you install 0.4.0, upgrade your framework to a release that fixes its published security advisories:
| Adapter | Required in your app's package.json |
|---|---|
@holo-js/adapter-next | next 16.4.3 or newer on the 16 line, and react 19.2.6 or newer on the 19 line |
@holo-js/adapter-nuxt | nuxt 4.6.2 or newer on the 4 line, and vue 3.5.13 or newer on the 3 line |
@holo-js/adapter-sveltekit | @sveltejs/kit 3.1.0 or newer on the 3 line and svelte 5.57.1 or newer on the 5 line. See SvelteKit 3. |
@holo-js/adapter-nuxt no longer installs h3 or nitropack. Its server routes and plugins use the h3 and Nitro that come with your Nuxt version. @holo-js/adapter-sveltekit no longer installs Svelte. It accepts any Svelte 5 release from 5.57.1 instead of one exact version.
Holo's own dependencies now require patched releases too: ws 8.21.0, sharp 0.35.5, mysql2 3.24.6, and valibot 1.5.0 or newer. Your package manager installs these for you. If your lockfile pins older versions, refresh it after upgrading.
Application environment from APP_ENV
The environment now comes only from APP_ENV. The --env option wins over APP_ENV from your shell, which wins over APP_ENV in .env. When none of them sets it, the environment is production instead of development. Holo uses that environment to pick which .env files load. HOLO_ENV is no longer read, and NODE_ENV no longer picks the environment. A value other than development, production, or test now stops Holo with an error instead of counting as development.
An env file that exists but can't be read, such as a .env that is a directory, also stops Holo with the file-system error. Before, it was skipped, so the environment fell back to production.
Before installing 0.4.0:
- Add
APP_ENV=developmentto your local.env. Without it, local commands andholo devrun asproduction. - Replace
HOLO_ENVwithAPP_ENV, or with--env, in scripts, CI, and hosting settings. - Set
APP_ENVanywhere you relied onNODE_ENVto pick the environment. For example, Vitest setsNODE_ENV=test, so setAPP_ENV: 'test'in your test config as shown in Testing. - In
config/app.ts, set the environment the way new projects do:
import { defineAppConfig, env } from '@holo-js/config'
export default defineAppConfig({
env: env('APP_ENV', 'production'),
})If you use the config cache, run npx holo config:cache again on every server after upgrading. Holo ignores caches written by 0.3.x and loads config/*.ts until you rebuild the cache, because an old cache can hold the wrong environment. Mail previews check the environment from APP_ENV (or --env) rather than a cached app.env.
holo start now runs your framework server with NODE_ENV=production, even when your shell sets another value. Nuxt servers now pick their .env files from APP_ENV when they start, not from the environment the app was built in. In Nuxt, useHoloEnv(), useHoloDebug() and useHoloDb() now return the running app's values, and throw if called before Holo starts, so call them inside server routes or handlers. If you call configureMailRuntime() yourself without Holo, pass environment, or previews treat the app as production. See Which .env files load.
Destructive commands confirm in production
migrate:fresh, migrate:rollback, and seed now ask you to confirm when the application environment is production. The prompt appears only in a terminal. Without one, the command stops with an error unless you pass --force. migrate:rollback accepts --force now; before, it ignored the flag. --force skips the prompt for all three commands.
Before upgrading, check every deploy script, CI job, and cron entry that runs these commands with APP_ENV=production, and add --force to them. Otherwise they stop instead of running. The npx holo seed --only roles --force example in the seeding guide shows the flag in use.
Migration runs can be isolated
npx holo migrate --isolated takes a database lock, so replicas that migrate at deploy time don't apply the same migrations. A replica that finds the lock held skips the migrations and exits successfully. Holo creates a _holo_migration_locks table the first time an isolated run starts. The lock expires an hour after it was last renewed, and a single migration that runs longer than an hour can let another runner in. Plain migrate doesn't use the lock. Use --isolated in your deploy step if more than one replica runs it.
Generated broadcast credentials and strict env values
Holo no longer uses app-id, app-key, or app-secret when BROADCAST_APP_ID, BROADCAST_APP_KEY, or BROADCAST_APP_SECRET are unset. Without them, your config still loads and holo prepare still runs. The broadcast worker, publishing, and channel authorization fail with an error that names the variables. holo new writes generated values into .env, and existing apps get them the same way:
npx holo install broadcastThe command adds BROADCAST_APP_* lines that .env doesn't have yet. It leaves existing lines alone, so if a line is present but empty, fill it in or delete the line and run the command again. Use the same three values for your web app and your broadcast worker, and set them in your hosting provider's environment rather than copying your development .env.
Holo also throws when a typed env() value doesn't convert: APP_DEBUG=maybe with a boolean fallback, or APP_PORT=abc with a number fallback. Previously Holo used the fallback. Fix or remove those values before deploying.
If you use the config cache, the cache now stores the env values it was built with, secrets included. Config modules that contain functions, such as app with an exception reporter or security with rate-limit callbacks, still load from their files, and the env() values they read while loading are frozen in the cache as well. Code that calls env() after startup, such as a reporter's body, reads the runtime environment. Holo ignores 0.3.x caches, so rebuild it after upgrading. Keep .holo-js/generated/config-cache.json out of version control; the scaffolded .gitignore already does.
Encrypted session, remember-me, and CSRF cookies
Holo now encrypts the cookies it sets: the session cookie, the remember-me cookie, and XSRF-TOKEN. It uses AES-256-GCM with a key derived from APP_KEY, and rejects any cookie that was changed. Your own cookies, including ones written with cookie() or cookies.make(), are not encrypted.
Cookies set by 0.3.x can't be read after the upgrade. Every user is signed out once, remember-me cookies stop working, and the first form each browser submits with an old XSRF-TOKEN gets a 419 response. The next page load issues new cookies.
APP_KEY must be set wherever Holo sets these cookies. Without it, signing in and issuing the CSRF cookie throw an error. Run npx holo key:generate if your .env has no key. Changing APP_KEY later signs everyone out again.
If you call sessionCookie() or rememberMeCookie() from @holo-js/session directly, they now encrypt the value. Don't read the session ID or remember-me token back from the cookie yourself. Use Auth to resolve the current session. CSRF tokens from csrf.token() and csrf.input() are now random values with no signature, and are no longer the same string as the XSRF-TOKEN cookie value. Both are still accepted in the _token field and the X-CSRF-TOKEN header.
Per-user remember-me tokens
Remember me now works like Laravel's: each user has one remember_token, and the user stays signed in for rememberMeLifetime even after their session expires. Add the column to every table an auth provider reads from, such as users and admins:
npx holo make:migration add_remember_token_to_usersimport { defineMigration } from '@holo-js/db'
export default defineMigration({
async up({ schema }) {
await schema.table('users', (table) => {
table.string('remember_token').nullable()
})
},
async down({ schema }) {
await schema.table('users', (table) => {
table.dropColumn('remember_token')
})
},
})Add remember_token to the model's hidden list so it never reaches the browser:
import { defineModel } from '@holo-js/db'
export default defineModel('users', {
fillable: ['name', 'email', 'password', 'avatar'],
hidden: ['password', 'remember_token'],
})Behavior changes to expect:
logout()gives the user a new remember token, so remember-me cookies on every device stop working. Other devices with an active session stay signed in until that session ends. UselogoutOtherDevices()to end those too.- Changing the user's password also stops their remember-me cookies.
- Signing in without remember me clears that browser's remember-me cookie but keeps the user's token, so a copy of the cookie keeps working until
logout(),logoutOtherDevices(), or a password change. - With multi-factor auth, the remember-me cookie is set after the user passes the challenge, not when they enter their password.
- A custom
AuthRuntimeContextcan report whether it can set cookies withcanAppendResponseCookie(). Auth checks it before any side effect. Without it, Auth uses whetherappendResponseCookieexists. - A custom
AuthRuntimeContextthat omitsappendResponseCookiecan no longer log users in or out. The calls throwcookie_write_unavailable, the same error a read-only Next.js render raises. Provide a response cookie writer in every request context that signs users in or out. - Confirming multi-factor enrollment ends remember-me cookies issued before it, so the user signs in with the challenge the next time. The browser that confirmed enrollment stays signed in.
The sessions table no longer uses remember_token_hash. You can leave the column in place or drop it. issueRememberMeToken() and consumeRememberMeToken() are removed from @holo-js/session, and session records no longer have rememberTokenHash. rememberMeCookie() is unchanged.
If you wrote a custom auth provider, add getRememberToken(user) and updateRememberToken(user, token) to it. Without updateRememberToken, remember: true and logout() for users who have a token throw an AuthError with the code runtime_unconfigured.
Rate-limit windows are fixed in every driver
Every rate-limit driver now uses the same window. A window starts with a key's first attempt and lasts decaySeconds. Rejected attempts count but don't extend it. Before, the Redis driver counted attempts over a sliding window that rejected attempts extended. See How the window works.
Redis stores each key as a counter instead of a sorted set. Buckets left by 0.3.x are sorted sets, and an attempt on a key that still holds one fails with a Redis WRONGTYPE error until that bucket expires. Clear them once after the upgrade:
npx holo rate-limit:clear --allThe memory driver keeps at most 10,000 keys per process and never evicts a live key. When it is full, new keys are limited until one expires. The file driver deletes expired keys in the background at most once a minute. The low-level createSecurityRedisAdapter() no longer takes a now option.
CSRF tokens stored in the session
The CSRF token now lives in the visitor's session, as in Laravel. The XSRF-TOKEN cookie carries that session token encrypted, and a token copied from another browser or planted by a subdomain no longer matches.
- Tokens reissue after upgrading. The first unsafe request each browser sends after the upgrade gets
419, because its session has no token yet. The next page load stores a token in the session and issues a newXSRF-TOKENcookie. - Install
@holo-js/session. CSRF protection needs it, soholo install securitynow adds it. If your app enablescsrf, or leaves it on by default, without Auth, add@holo-js/sessionto your dependencies. Without it, the app fails at startup withSession support requires @holo-js/session to be installed.Without aconfig/session.ts, Holo uses the default file session store. - Send the session cookie. Visitors without a session get one on their first page load. Scripts and tests that post with only the
XSRF-TOKENcookie must also send theholo_sessioncookie (or yoursession.cookie.name). X-XSRF-TOKENis also accepted._tokenandX-CSRF-TOKENstill accept the plain token or the cookie value, so existingfetchcalls keep working. The newX-XSRF-TOKENheader accepts the cookie value, as in Laravel.- Custom session stores need
setDataIfAbsent. The token is written with one atomic store call, so a rotation or logout that runs at the same moment can't bring back the old session. Built-in file, database and Redis stores support it. A custom store must addsetDataIfAbsent(sessionId, key, value)before CSRF-protected pages load. Return{ found: false }for a missing or expired session without recreating it. Otherwise, preserve an existing key or insert the value if absent, then return{ found: true, value: storedValue }. For the reserved_tokenkey, treat a non-string or empty value as absent and replace it atomically; preserve a valid token initialized by another request. Keep the session's other data and expiry unchanged. useFormkeeps server-rendered tokens. A_tokenfield already in the form, such as one fromcsrf.input(), is no longer replaced by the cookie value. Withcsrf.endpoint, the fetched token is sent in thecsrf.headerheader fromconfig/security.tsinstead of alwaysX-CSRF-TOKEN, and the cookie value is no longer added as_token. If the configured header isX-XSRF-TOKEN, the fetched token goes incsrf.fieldinstead; that header accepts only encrypted cookie values.
CSRF checks are on by default
csrf.enabled now defaults to true, as in Laravel's web group. A config/security.ts that has no csrf block checks every unsafe request (POST, PUT, PATCH, DELETE). Before, the default was off. Apps created with holo new or holo install security already set csrf.enabled: true and don't change.
- Unsafe requests without a token get
419. Forms andfetchcalls that don't send the token, such ascsrf.input(),useForm, or theX-CSRF-TOKENheader, now fail. Routes that receive webhooks or API calls from other clients can be listed incsrf.except. - Opt out with
enabled: false. Set it to restore the old behavior for the whole app. This removes forgery protection, so preferexceptfor the routes that need it.
import { defineSecurityConfig } from '@holo-js/security'
export default defineSecurityConfig({
csrf: {
enabled: false,
},
})CSRF endpoints for separate frontends
For a separate frontend using useForm({ csrf: { endpoint } }), your token endpoint can now return { token, field }. Use const { name: field, value: token } = await csrf.field(request). The client sends the token in that field, so custom API CSRF names work without reading API cookies. Existing { token } responses keep their current behavior.
CORS wildcard origins with credentials
config/cors.ts can no longer combine origins: ['*'] with credentials: true. Holo throws at startup instead of echoing every origin with credentials. List your frontend origins in origins, or set credentials: false. A wildcard origin now always answers Access-Control-Allow-Origin: *.
Social sign-in links only verified accounts
Social sign-in no longer signs in as an existing user whose email isn't verified. When a provider's verified email matches such a user, callback() from @holo-js/auth-social throws SocialAuthConflictError. Catch it in your callback routes and ask the user to sign in with their password and verify their email; their next social sign-in links the accounts. See Link an existing account.
Email verification links now only verify the address they were sent to. If a user changes their email, links sent to the old address fail with email_verification_token_expired.
WorkOS sign-in requires a session ID
WorkOS sign-in no longer uses the access token or the authorization code as the session ID. Before, when WorkOS's response had no session ID, sign-in succeeded and logoutWithWorkos() sent that token or code to WorkOS as session_id. Now the callback fails with workos_auth_failed and creates no session. WorkOS AuthKit always returns a session ID in the WorkOS session ID shape (session_ followed by letters, digits, _ or -), so this only affects malformed responses. Sessions created before the upgrade keep the old value until they expire. Logging out of them ends the local session only: logout sends a stored value to WorkOS only when it has the shape of a WorkOS session ID, and a token or code never does.
The sign-in state cookie is marked Secure when session.cookie.secure is true, even over HTTP, and behind a trusted proxy that terminates TLS when X-Forwarded-Proto is https.
Sessions don't cross between 0.3 and 0.4
Sessions written by 0.4 aren't readable by 0.3, because a session no longer stores a copy of the user. A mixed-version rolling deploy, or a rollback to 0.3 after deploying 0.4, signs users out. Deploy 0.4 to every instance at once, or accept that users sign in once more after a rollback.
Redis sessions use one key per session
Each Redis session is its own key: the store's prefix, then holo:session:, then the session ID. Sessions spread across a Redis Cluster, and they never sit under a bare session ID.
- Users sign in again once. Sessions stored under the old keys aren't found after the upgrade. Users who chose remember me sign back in from their remember-me cookie; everyone else signs in again. Old keys expire at their own TTL, so you don't need to delete them.
- Deploy to every instance at once. Follow Sessions don't cross between 0.3 and 0.4 for mixed-version deploys. An instance on 0.3 can't read the new keys.
- Set a
prefixto share a Redis database. Holo'sholo:session:namespace keeps session keys apart from other data, and aprefixseparates apps that use the same database. - Rotation takes two Redis steps. Rotating a session copies it to the new ID and then deletes the old key, instead of running one script. If the process stops, or the delete fails, between the two steps, the old ID stays valid until its expiry. Under heavy concurrent writes to the same session, rotation falls back to reading the latest state, copying it, and deleting the old key without a check. A write or flash value that lands between that final read and the delete can be lost or kept on both IDs.
Verification resend requires a signed-in user
resendEmailVerification() and sendEmailVerification() no longer take an email address, and verification.resend() no longer accepts email. A new link always goes to the signed-in user, and without one the call throws email_verification_user_missing with status 401. The unauthenticated resend is gone, so a guest can no longer ask for a link by typing an address.
Update the resend route and its page:
- Call
resendEmailVerification()with no arguments. Users are signed in afterlogin(), including unverified users, so the verification page can post to the route without an address. - Rate limit the route per user with the
emailVerificationResendlimiter, for examplelimit.perMinute(6).define(), passing the user's id as the key. New security configs include the limiter. If yourconfig/security.tsalready exists, add it torateLimit.limiters. The example in Resend the email shows the full route. - Delete the old
emailVerificationResendlimiter keyed by email and the resend form schema, and stop postingemailfrom the page.
Password reset signs out other sessions
A successful password reset now replaces the user's remember_token, as Laravel does, and signs the user out everywhere else. Remember-me cookies issued before the reset stop restoring. Every other browser's session stops working on its next request, including sessions that were already signed in. The browser that submits the reset keeps its place: if it was signed in as this user, its session ID is rotated and it stays signed in. The column already exists, so no migration is needed.
Tell users who are signed in on another device that they must sign in again after a reset. A custom auth persistence must provide sessionRevocations; core persistence already does. See Password reset.
Clerk sessions need an authorized party
Holo accepts a Clerk session token only when its azp claim names an authorized party from your config: the origins in authorizedParties, plus the origin of redirectUri. Before, the origin of each request's Host header counted too, so a token issued for another app on the same Clerk instance was accepted for any request that named that app's host. Holo no longer reads origins from the request.
New apps from holo new and holo install auth --clerk get authorizedParties: [env('APP_URL', 'http://localhost:3000')] in the Clerk provider, so they work out of the box.
Existing apps add that one line to the app entry of clerk in config/auth.ts, unless redirectUri is already set (its origin is then allowed):
clerk: {
app: {
// ...existing options
authorizedParties: [env('APP_URL', 'http://localhost:3000')],
},
},A Clerk provider with neither redirectUri nor authorizedParties rejects every session token, so sign-in fails until you add one of them. Add any other origin that serves your app, or that is allowed to issue its tokens, to authorizedParties. WorkOS isn't affected, because it doesn't use azp.
See Clerk configuration.
Hosted sign-in copies only verified emails
Clerk and WorkOS sign-in copy the provider's email to the local user only when the provider reports it verified. This also applies to an email that your user mapper sets: an unverified address in the final update is dropped. A first sign-in with an unverified email creates the user with the hosted placeholder address instead, and it never links to an existing local account with that address. Holo matches an existing local account to a hosted identity only by the verified email. A returning user whose unverified email matches another account signs in as before, and that account is left alone. An unverified address doesn't replace the stored email, and the user keeps the verification they had. A stored email that changes, through sign-in or the auth runtime, now clears email_verified_at so a verification never carries over to a different address.
Users whose email changed before this upgrade keep the verified timestamp they had. If you need every stored verification to match its address, ask those users to verify again.
Apple email comes from the signed token
Sign in with Apple takes the user's email only from the signed id_token. Before, Apple's form user field supplied it when the token had none. Apple's form field isn't signed, so Holo ignores its email now and still reads the user's name from it. A callback whose token has no email gets the placeholder address <id>@apple.social.local when email verification is off, and fails with requires a verified email address when it's on. To keep the real address, capture it on the first authorization. See Apple.
Failed logins and reset-link requests take at least 200 ms
Failed logins and every reset-link request now take at least 200 ms, whether or not the account exists, so response time doesn't reveal which emails have accounts. Successful logins aren't delayed. Your code doesn't change, but failed attempts answer a little slower.
A reset-link request that sends its email synchronously can still take longer than 200 ms when the mail server is slow, and that difference can reveal an account. Queue the reset email so the request returns before the mail is sent. Publish the notification with npx holo auth:notifications:publish and queue it, as described in Customize the email.
User records load on every request
auth.user() reads the user from the provider once per request, and session records no longer store a copy of the user. Existing sessions keep working, and the stored copy is ignored. Users who were deleted or changed are reflected on their next request. Your provider's findById() now runs on each request that reads the user.
Cache flushes clear the whole store
cache.flush() and npx holo cache:clear now delete everything in the store, as Laravel does, and a key prefix no longer limits them. A Redis store flushes its whole Redis database, a database store empties its cache table, and a file store empties its folder. If your Redis cache shares a database with queues or sessions, move it to its own database before you flush, or the flush deletes that data too.
Add a cache connection to config/redis.ts on a database that holds nothing else, and point the Redis store at it. The scaffold uses database 1:
cache: {
url: env('REDIS_URL') || undefined,
host: env('REDIS_HOST', '127.0.0.1'),
port: env('REDIS_PORT', 6379),
username: env('REDIS_USERNAME'),
password: env('REDIS_PASSWORD'),
db: env('REDIS_CACHE_DB', 1),
},redis: {
driver: 'redis',
connection: 'cache',
}Entries written with put() or remember() expire on their own. Entries written with forever() stay in the old database until you remove them, so clear that database with your Redis tools once the cache has moved. On a Redis Cluster, flush() clears every primary node, so give the cache its own cluster. Give each app its own folder or cache tables for the file and database stores. See Cache drivers.
Transaction nesting and commit errors
Check three behaviors when you upgrade:
- A
transaction()call on a connection object made while a transaction is open on that connection now becomes a savepoint. Before, it started a second transaction that could commit the outer work early. If you relied on that early commit, move the work out of the outer callback. - When an
afterCommitcallback throws,DB.transaction()rejects with that same error. It used to reject with aTransactionErrorwrapping it. Update anycatchthat checked forTransactionErrorin that case. timeoutMsandsignalon a transaction only limit how long it takes to start. Commit and rollback are never interrupted. If you relied on a timeout to abandon a slow commit, the commit now finishes. See Timeouts and cancellation.
To retry a transaction after a deadlock or lock timeout, pass attempts. See Retry deadlocks.
Rollback callbacks and failed rollbacks
Check three behaviors when you upgrade:
afterRollback()callbacks run in reverse registration order. Before, they ran in registration order.- A callback that throws no longer stops the callbacks after it. When one fails,
DB.transaction()rejects with anAggregateErrorthat holds the original error and aTransactionError, so acatchthat checked for the original error must read theerrorsarray. - When the rollback itself fails,
afterRollback()callbacks don't run, andDB.transaction()rejects with the rollback error. The query logger'sonTransactionRollbackhook receives that failure once.
Models after a rolled-back transaction
If you roll back a transaction yourself after a model's save() in it succeeded, Holo doesn't restore the model. What the rollback undoes depends on the save:
- An insert is undone. The row is gone, but the model still reports
exists(). - An update is undone. The row keeps the values it had before the transaction, but the model keeps the new values.
In both cases the model keeps the values its set mutators produced and is clean, so a save() with no changes writes nothing. To write the record again, create a new model with make() and save(), or reload a model that was loaded from the database with refresh(). refresh() throws for a model that was never saved.
A save() that fails restores the model's state, unless it ran inside a transaction you started and its SQL had already completed. A failed COMMIT restores the model's persisted bookkeeping (whether it exists, its original values, and what it counts as changed) to what it was before the transaction's first save, and clears values the write generated, such as an id. Attributes you assigned, including later edits, keep their values and stay dirty, so a retry writes them.
Query logger hooks run outside the connection
Check three behaviors when you upgrade:
onQueryStartruns before its query waits for a slot on the connection. Before, a query that the queue limit rejected never ranonQueryStart; now it runs bothonQueryStartandonQueryError.- A hook can run queries on its own connection without stalling it, and queries a hook runs don't trigger the query hooks again. Before, a hook that queried a busy connection could hang it or loop through the error hook.
- A hook inside a transaction that waits for the transaction to finish, or for other work that needs the transaction's connection, still hangs, because the transaction waits for the hook.
findMany splits large key lists
findMany() can split a key list of more than 256 keys into chunks of 256, as eager loads do, instead of sending every key in one statement. A chunked call runs its own queries, so the query count grows with the number of chunks. Duplicate keys are removed, and each row is returned once. Soft-deleted rows stay excluded, and global scopes still apply to every chunk. Update code or tests that count queries or expect one statement for a long key list.
Query operators, date values, and foreign-key toggles
Check three behaviors when you upgrade:
whereColumn()accepts only=,!=,>,>=,<,<=, andlike. Laravel also accepts bitwise and regex operators here; Holo rejects them. Any other operator throwsSecurityErrorwhen the query runs. Change a query that used another operator to one of the supported operators.- On PostgreSQL,
dateandtimestampvalues useapp.timezone, which comes fromAPP_TIMEZONE(defaultUTC), instead of the Node process's time zone. Adatereads back as aDateat midnight inapp.timezone, and aDatequery value is sent with its offset, so the day doesn't change when you save it again. Before,timestampvalues were read as UTC wall-clock time, so in an app whoseapp.timezoneis not UTC they read back shifted; they now read back as saved. Typed model and query-builder writes ofDatevalues,Datedefaults ontimestampcolumns, and Holo's auth, notification, and database session writes are formatted inapp.timezonetoo. Before, these wrote UTC wall-clock text, so in a non-UTC app password-reset, email-verification, access-token, and database session expiries were off by the zone offset, and the database session sweep removed sessions at the wrong time. Holo doesn't rewrite existing rows. Typedtimestampcolumns written by 0.3, and Holo's auth, notification, and database sessiontimestampcolumns, hold UTC wall-clock values, so they read back shifted in a non-UTC app. Once, before the upgraded app writes to those columns, convert them withUPDATE my_table SET my_column = (my_column AT TIME ZONE 'UTC') AT TIME ZONE 'America/New_York', using the zone in yourAPP_TIMEZONE. For a database session table, convertcreated_at,last_activity_at, andexpires_at. Don't convert columns written by untypedDB.table()inserts ofDatevalues: those already stored app-time values, and converting them shifts them again. Running a conversion twice shifts the values twice. On PostgreSQL, atimestampcolumn without a time zone can't tell the two instants of the hour that repeats when clocks fall back apart. With a non-UTCapp.timezonethat observes daylight saving time, an expiry in that hour can be honored up to one hour late, and atimestampwritten at the earlier instant reads back an hour later. SetAPP_TIMEZONE=UTC, which Holo recommends for PostgreSQL apps, to avoid this. Dates inside arrays are unchanged. The PostgreSQL adapter now sets its date and timestamp parsers on each query, so injectedclient,pool, andcreatePoolobjects parse them the same way as the built-in pool. Custom clients and pools must accept a query config object ({ text, values, types }) aspgdoes. - On MySQL,
date,datetime, andtimestampvalues useapp.timezone, which comes fromAPP_TIMEZONE(defaultUTC), as on PostgreSQL. Holo sets the session time zone toapp.timezoneon every pool connection it creates. MySQL needs its time zone tables loaded for any zone other thanUTC; load them withmysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql. Without them, queries fail, and the reason is reported throughexceptions.report. Thetimezoneoption no longer changes how dates are read or bound. Adatetimethat holds UTC wall-clock time from 0.3 reads back shifted by the zone offset whenapp.timezoneisn'tUTC. Holo doesn't rewrite existing rows, so convert them or setAPP_TIMEZONE=UTC.timestampvalues are stored as instants. In the hour that repeats when clocks fall back, MySQL shows both instants with the same wall-clock time, so atimestampstored at the later instant reads back an hour early, and saving it back stores the earlier instant. Values stored at the earlier instant read and save unchanged. Writes ofDatevalues from models, the query builder, and Holo's auth, session, and notification tables are formatted inapp.timezonetoo. Before, they were sent as UTC wall-clock time, so in a non-UTC app password-reset, email-verification, access-token, and database-session expiries were off by the zone offset. Zero dates and dates with a zero month or day, such as0000-00-00or2024-01-00, come back as their stored text. Holo converts dates only on the pools it creates. Injectedclient,pool, andcreatePoolobjects keep their own mysql2 settings and date handling, and Holo doesn't set their session time zone. An app that setsdateStringsortypeCast: falseon a MySQL connection gets the stored date strings, which are already inapp.timezone, and Date bindings are still formatted inapp.timezone. - Foreign-key
onDelete()andonUpdate()values other thancascade,restrict,set null, andno actionthrowSchemaError. MySQLenumvalues and string defaults that contain a backslash are now stored as written. Before, MySQL read the backslash as an escape, so the stored value could differ. Existing columns keep their stored values; check them withSHOW COLUMNSif they contain backslashes. - MySQL
enumvalues can't contain a backslash or a NUL character. The schema build fails withSchemaErrornaming the value. Before, such a value brokeCREATE TABLEor was stored altered. - On MySQL, queries with bindings run as server-side prepared statements, so a quote in a bound string can't end a SQL literal under
NO_BACKSLASH_ESCAPES. Custom MySQL pools passed tocreateMySQLAdapter()must implementexecute()for bound queries; the adapter throws a clear error otherwise. Each connection keeps at most 256 prepared statements, where mysql2 keeps 16000 by default. The server allows 16382 prepared statements in total, so many statement shapes, such asINlists of different sizes, would otherwise exhaust it and block every connection. Change the cap withmaxPreparedStatementsin the pool options passed tocreateMySQLAdapter(). - A transaction started inside
withoutForeignKeyConstraints()runs on the same pinned connection, one at a time. A query issued on that connection while the transaction is open fails withTransactionErrorinstead of joining the transaction. Before, such a query joined the transaction and was rolled back if the transaction failed. Await the transaction before running other queries, or run the query inside the transaction. A transaction that reaches that connection after the callback has returned fails withTransactionErrorand writes nothing. withoutForeignKeyConstraints()keeps every query in its callback on one pooled connection. The standalonedisableForeignKeyConstraints()andenableForeignKeyConstraints()still change only the connection they run on. On PostgreSQL or MySQL, wrap the work inwithoutForeignKeyConstraints()instead. See Foreign key constraints.
Pivot columns and one-of-many relations
Check three behaviors when you upgrade:
pivoton a many-to-many relation holds only the pivot keys and the columns declared withwithPivot()andwithTimestamps(). Before, it held every pivot column. AddwithPivot('column')for each column your code reads frompivot. A custom pivot model fromusing()receives the same columns, so add'id'towithPivot()if you read the pivot'sid.- A one-of-many relation (
latestOfMany,oldestOfMany,ofMany, and the morph variants) whose column isNULLon every related row loadsnull, as in Laravel. Before, it loaded the first related row. To keep the old result, skip theNULLrows with a constraint:scopeRelation(latestOfMany(() => Post, 'user_id', 'id', 'score'), query => query.whereNotNull('score')). - Eager loads run one query per 256 parent keys, except a relation whose constraint sets
limit()oroffset(), which runs as one query so the limit or offset applies to the whole load. A constraint callback passed towith()orscopeRelation()can run more than once: once per chunk, and for one-of-many relations once for the grouped lookup and once for the outer query. Don't count or log calls inside it.
MySQL ssl with a connection URL
A MySQL connection that sets ssl next to url now uses that setting, which adds TLS settings to the URL. The URL's own ssl parameter takes precedence over the setting, and ssl: false doesn't disable TLS that the URL requests. Before, the setting was ignored when url was set. Before you deploy, check that the server accepts the connection with ssl and that its certificate is trusted, or remove ssl if the connection should not use it.
Query writes set updated_at
On a model with timestamps, these query writes now set updated_at to the current time unless you pass your own value: update(), updateJson(), increment(), and decrement(). Query upsert() sets created_at and updated_at on new rows, and sets updated_at on conflict when you list columns to update. On a soft-delete model, query and instance delete() and restore() set updated_at together with deleted_at. Before, all of these left updated_at unchanged. Instance restore() also saves the model's unsaved changes, as Laravel's restore does, so a changed attribute or primary key is written when you restore.
Check code that reads updated_at to find changed rows, such as a "recently updated" list, a cache key, or a sync cursor. Rows you write from now on change updated_at; existing rows keep their stored values. forceDelete() and the table builder (DB.table()) don't change. Use the table builder for a write that must leave updated_at alone. See Query updates.
Nested relation callbacks apply to the leaf relation
A callback on a nested eager load applies only to the relation it names. with('posts.comments', query => ...) loads posts unconstrained and filters comments, as Laravel's with(['posts.comments' => fn]) does. Before, the callback was also applied to posts, so it could only use columns that every model on the path had, and a comments column failed at query time.
If your code depended on the old behavior, give each level the constraint explicitly:
User.with({
posts: query => query.where('published', true),
'posts.comments': query => query.where('approved', true),
})Relation callbacks now also type their query builder as the related model, so a callback that uses a column of another model is a type error. whereRelation() and orWhereRelation() on a nested path check their column against the leaf model too. See Constraining eager loads.
Lifecycle handlers and scope constraints are typed
Handlers in a model's events option receive the model's entity, and the callback you pass to scopeRelation() receives a query on the related model. TypeScript checks the columns those callbacks use and any parameter annotation you wrote. An annotation that doesn't match the entity, or a constraint that names a column the related table lacks, is now a type error. The runtime behavior doesn't change. Correct the annotation or the column name.
Scope names must not match builder methods
Model scopes are now also methods on every query builder for the model, so a scope name has to be free on the builder as well as on the model. Defining a model with a scope named like a query builder method (where, count, get) or an internal builder property throws a configuration error. Before, the scope silently replaced that method on the model, so a call such as Post.where(...) ran the scope instead.
Rename the scope. A name such as whereActive or activeOnly keeps the meaning clear and does not collide with a builder method. See Local scopes.
Password hashing cost
New password digests use scrypt with N = 2^15, r = 8, p = 3, up from N = 2^14, r = 8, p = 1. Each hash takes about 6 times as much CPU, because N doubles and p triples, and uses about 32 MiB of memory.
A process runs at most four password hashes at once, so later logins queue instead of exhausting memory. Budget for this on small or constrained hosts.
No migration is needed. A digest made with the old settings still verifies. A successful login replaces it with a new digest for the built-in database providers. A custom provider keeps its old digest until the user changes their password. See Authentication.
Dev server exits non-zero on a port conflict
holo dev now exits with a non-zero status when another holo dev for the same project is already running. Before, it exited 0 and left the first server running. A port already in use by another process has always exited non-zero, and it still does, now with the port named. holo dev never stops a process for you. Update any CI script or wrapper that treated a second holo dev as success. To replace a running server, stop it first, using the PID in the message.
Stopping holo dev, holo start, and queue:listen
A second Ctrl+C while holo dev, holo start, or queue:listen is shutting down now force-stops the server or worker and its child processes, and exits with status 130. Before, a second Ctrl+C did nothing, so a stuck shutdown needed kill -9. On macOS and Linux the server runs in its own process group, and the force stop ends the whole group. A closed terminal (SIGHUP) now stops the server through the same graceful path as the first Ctrl+C, so its child processes stop with it.
The server runs in its own process group, so if you kill -9 the CLI itself, the server can keep running. After a SIGKILL, check for a leftover server process and stop it. See the CLI reference.
holo install queue follows config/queue.ts
When Holo can read config/queue.ts, the config decides which queue packages holo install queue installs. --driver no longer adds a package the config doesn't use, and queue packages the config doesn't use are removed, as prepare already did. Before, the installer added the driver's package and the next prepare removed it, so reruns reported updated package.json and reinstalled dependencies. Set the connection's driver in config/queue.ts first, then run npx holo install queue. If Holo can't read the config, --driver still picks the package to add, and installed queue packages are never removed. See Installed packages.
Cookie helpers are no longer exported from @holo-js/kernel
@holo-js/kernel no longer exports encryptCookieValue or decryptCookieValue. Holo uses them for its own session, remember-me, and CSRF cookies, and they remain only as cookieEncryptionInternals for Holo's packages. Remove those imports from your app or plugin. Don't encrypt or decrypt Holo cookies yourself. To keep your own data, store it in the session with the session API, and read cookies through the session and auth APIs, which encrypt Holo's cookies for you. See Use sessions for your own data.
Realtime authorization accessors drop response helpers
RealtimeAuthRequestAccessors lists only getCookie() and getHeader(). Realtime authorization reads cookies and never writes cookies or redirects, so appendResponseCookie() and redirectResponse() are removed from the type. Remove them from the accessors you pass to subscribeRealtimeQuery(), because a typed object literal that still lists them fails to typecheck. Realtime authorization has no replacement for them.
Give custom auth contexts a request scope
If you supply your own AuthRuntimeContext, add getRequestScope(): object. Return one stable object per request, with a different object for every request. Holo uses it to cache remember cookies safely and combine concurrent guard restorations into one browser session. Built-in auth contexts provide this automatically. Remember-cookie writes require a scope and throw runtime_unconfigured before changing authentication when it is missing. This includes remembered login, clearing an existing remember cookie on login or logout, and renewing it through other-device logout. Without a scope, you can still restore remembered guards sequentially; cookie reads remain uncached.
Redis clients use ioredis 6
Holo's Redis packages (@holo-js/cache-redis, @holo-js/queue-redis, @holo-js/session, @holo-js/security, and Redis scaling in @holo-js/broadcast) now need ioredis 6. Install it in your app if you use ioredis directly:
npm install ioredis@^6ioredis 6 needs Node.js 20 or later. It uses RESP3, the newer Redis protocol, by default. Against a Redis server older than 6, it falls back to RESP2 automatically, so no config change is needed. The Redis queue driver uses BullMQ 6, which Holo installs for you.
Earlier 0.3.x releases
Recent Holo-JS releases moved config helpers into the package for each feature, and split database, queue, cache, and storage drivers into their own packages. Your config objects and runtime code keep the same shape. Only imports and dependencies change.
Follow these steps in order.
1. Update all Holo packages together
Holo packages are released as one group. Update every @holo-js/* package in package.json to the same new version, then install:
npm installMixing versions of Holo packages is not supported.
2. Move config helper imports
Each define...Config helper now comes from the package for that feature. env stays in @holo-js/config.
| Helper | Old import | New import |
|---|---|---|
defineAuthConfig | @holo-js/config | @holo-js/auth |
defineBroadcastConfig | @holo-js/config | @holo-js/broadcast |
defineCacheConfig | @holo-js/config | @holo-js/cache |
defineDatabaseConfig | @holo-js/config | @holo-js/db |
defineMailConfig | @holo-js/config | @holo-js/mail |
defineMediaConfig | @holo-js/config | @holo-js/media/config |
defineNotificationsConfig | @holo-js/config | @holo-js/notifications |
defineQueueConfig | @holo-js/config | @holo-js/queue |
defineRedisConfig | @holo-js/config | @holo-js/kernel |
defineSecurityConfig | @holo-js/config | @holo-js/security |
defineCorsConfig | @holo-js/config | @holo-js/security |
defineSessionConfig | @holo-js/config | @holo-js/session |
defineStorageConfig | @holo-js/config | @holo-js/storage |
For example, config/database.ts changes like this:
import { defineDatabaseConfig, env } from '@holo-js/config'
import { env } from '@holo-js/config'
import { defineDatabaseConfig } from '@holo-js/db'
export default defineDatabaseConfig({
defaultConnection: 'main',
connections: {
main: {
driver: 'sqlite',
url: env('DB_URL', './storage/database.sqlite'),
},
},
})The object you pass to each helper does not change.
3. Install the driver packages you use
Drivers are no longer bundled with the feature package. Install the package for each driver your config selects:
| If your config uses | Install |
|---|---|
SQLite (driver: 'sqlite') | @holo-js/db-sqlite |
PostgreSQL (driver: 'postgres') | @holo-js/db-postgres |
MySQL (driver: 'mysql') | @holo-js/db-mysql |
| Redis queue | @holo-js/queue-redis |
| Database queue or database failed-job storage | @holo-js/queue-db |
| Redis cache | @holo-js/cache-redis |
| Database cache | @holo-js/cache-db |
| S3 storage disk | @holo-js/storage-s3 |
For example, an app on Postgres with a Redis queue and S3 files:
npm install @holo-js/db-postgres @holo-js/queue-redis @holo-js/storage-s3Keep the feature packages too (@holo-js/db, @holo-js/queue, @holo-js/storage, and so on). Your app code still imports models, dispatch, Storage, and other APIs from them. Uninstall any driver package your config no longer uses.
If your code imports a driver directly, update the import:
| Old import | New import |
|---|---|
SQLite adapter from @holo-js/db | createSQLiteAdapter from @holo-js/db-sqlite |
Postgres adapter from @holo-js/db | createPostgresAdapter from @holo-js/db-postgres |
MySQL adapter from @holo-js/db | createMySQLAdapter from @holo-js/db-mysql |
Redis queue driver from @holo-js/queue | redisQueueDriverFactory from @holo-js/queue-redis |
Database queue driver from @holo-js/queue | databaseQueueDriverFactory from @holo-js/queue-db |
S3 driver from @holo-js/storage/runtime/drivers/s3 | default export of @holo-js/storage-s3 |
The @holo-js/storage/runtime/drivers/s3 path no longer exists.
4. Move schema imports to @holo-js/validation
@holo-js/forms no longer exports schema builders, and the @holo-js/forms/schema entry has been removed. Import schema and field from @holo-js/validation:
import { field, schema } from '@holo-js/forms'
import { field, schema } from '@holo-js/validation'
export const loginForm = schema({
email: field.string().required().email(),
password: field.password().required().min(8),
})Forms helpers such as validate() and useForm() accept these schemas directly.
Read validation errors across framework boundaries
If you use isValidationException(error) to recognize a serialized response or framework-wrapped error, switch to serializeValidationException(error) from @holo-js/validation. Releases after 0.5.0 remove it (upgrade section). It returns the failure payload, or undefined for unrelated errors:
const payload = serializeValidationException(error)
if (payload) {
return payload
}
throw errorRead field messages from payload.errors. Keep isValidationException() when you need to narrow a live exception and call its error bag methods. Holo's adapters and useForm() handle transported failures automatically. See Error Messages.
5. Add the session revocations table (apps using auth)
Auth now records signed-out sessions in an auth_session_revocations table, which other-device logout depends on. New projects include this migration. Existing apps need to add it.
Create an empty migration:
npx holo make:migration create_auth_session_revocationsReplace its contents with the same migration new projects get:
import { defineMigration } from '@holo-js/db'
export default defineMigration({
async up({ db }) {
const stringType = db.getDialect().name === 'mysql'
? 'VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin'
: 'VARCHAR(255)'
await db.executeCompiled({
sql: `CREATE TABLE auth_session_revocations (provider ${stringType} NOT NULL, user_id ${stringType} NOT NULL, generation INTEGER NOT NULL DEFAULT 0, retained_session_id ${stringType}, PRIMARY KEY (provider, user_id))`,
source: 'schema',
})
},
async down({ schema }) {
await schema.dropTable('auth_session_revocations')
},
})Then run it:
npx holo migrateThe table is required whichever session store you use. On MySQL it requires MySQL 8.0.17 or later.
WARNING
After you deploy this change, users who are signed in through the browser must sign in again. Existing API tokens keep working.
6. Plugin authors: depend on @holo-js/kernel
If you maintain a Holo plugin:
- Add
@holo-js/kernelas a dependency. - Import
defineHoloPluginand the plugin types from@holo-js/kernelinstead of@holo-js/cli. - Make sure plugin IDs and contribution names are unique. Holo rejects absolute paths and paths that point outside the plugin package.
- If your plugin adds a config file, register its normalizer with
registerConfigNormalizerfrom@holo-js/config, and augmentHoloConfigRegistrysouseConfig()infers its type.
See Writing Plugins for the full package layout.
7. Treat integer ids as numbers (0.3.22)
Since 0.3.22, models read id(), integer(), bigInteger() and foreignId() columns as JavaScript numbers on every driver. Before, PostgreSQL returned bigint columns, which id() and foreignId() create, as strings such as "1", so your code and your API responses received strings. A value above Number.MAX_SAFE_INTEGER now throws a HydrationError instead of losing precision.
Up to 0.4.1, DB.table() still returned these columns as strings on PostgreSQL, so a model id (1) and a query-builder value ("1") for the same row weren't equal. From 0.4.2, queries return numbers when you pass a table definition to DB.table(). With a table name, rows keep the driver's values.
Check code that:
- Compares ids with
===against strings, such as route parameters orDB.table()rows from a table name. Convert one side first, for exampleNumber(params.id). - Uses ids as
Mapkeys orSetvalues, or looks them up withincludes(), next to ids that are still strings. - Sends ids to clients. JSON responses now contain
1instead of"1". Update client types, or sendString(id)where the response must keep a string. - Reads ids saved before the upgrade in caches, sessions, queue payloads or JSON columns. Those still hold strings until they're rewritten.
If a column can hold values above 253 − 1, use uuid(), ulid() or snowflake() keys, which are read as strings.
8. Pass objects, not JSON text, to JSON columns (0.3.28)
Since 0.3.28, models JSON-encode the value you assign to a json() column once, whatever its type, so a string is stored as a JSON string. Before, a string was stored as-is and read back as the JSON it contained. Code that called JSON.stringify() before saving now stores a string and reads a string back:
await post.update({ settings: JSON.stringify({ theme: 'dark' }) })
await post.update({ settings: { theme: 'dark' } }) post.settings is { theme: 'dark' } after the second call, and the string '{"theme":"dark"}' after the first. Clients receiving the model as JSON see that string too. A json cast on a json() column already encoded once and doesn't change. DB.table() writes don't change either: they still store a string as the JSON it contains.
Rows saved from 0.3.28 on with pre-encoded text hold a JSON string. Find them, and if every string value in the column is encoded JSON, decode them in place (here for a settings column on posts):
| Database | Query |
|---|---|
PostgreSQL (jsonb) | UPDATE posts SET settings = (settings #>> '{}')::jsonb WHERE jsonb_typeof(settings) = 'string' |
| MySQL | UPDATE posts SET settings = CAST(JSON_UNQUOTE(settings) AS JSON) WHERE JSON_TYPE(settings) = 'STRING' |
| SQLite | UPDATE posts SET settings = settings ->> '$' WHERE json_type(settings) = 'text' |
Run the WHERE part as a SELECT first. A column that also holds real string values needs a narrower condition, because these queries decode every string.
9. Verify
Regenerate Holo's files and check that everything compiles and passes:
npx holo prepare
npm run typecheck
npm run buildRun your test suite as well. Fix any import errors the type checker reports; they point at the imports from steps 2 to 4. Then deploy as usual. Your deploy's build step runs holo build, which refreshes the generated files.
Next steps
- Configuration
- Packages: what each
@holo-js/*package is for - Deploying Holo Apps