Upgrading
henri 1.0 (2026) moved the framework to a current toolchain and 1.1 hardened it. Both break an application written for 0.37. The 1.2 to 1.3 changes come first, then 1.1 to 1.2; everything after them is the 0.37 to 1.x list, in the order you will meet it. The per-package details are in the changelogs.
From 1.2 to 1.3
Section titled “From 1.2 to 1.3”henri new scaffolds a Drizzle application on sqlite
Section titled “henri new scaffolds a Drizzle application on sqlite”henri new my-app used to write the zero-config store (@usehenri/disk, a MongoDB inside the process). It now writes a drizzle store on sqlite:
{ "stores": { "default": { "adapter": "drizzle", "dialect": "sqlite", "url": "file:.henri/app.db" } }}with ":memory:" in config/test.json and @usehenri/drizzle and better-sqlite3 in the dependencies. This is Rails’ default: nothing to start on the first run, a file the .gitignore already covers, migrations from the first day, and a database that is a real one on the last. henri new --adapter disk is the zero-config MongoDB, one flag away.
It costs nothing to install: better-sqlite3 13 ships its compiled addon for darwin, linux, linuxmusl and win32 on arm64 and x64, so the scaffold lists it as better-sqlite3: false under allowBuilds in pnpm-workspace.yaml — pnpm skips a node-gyp rebuild that needs a C++ toolchain and produces nothing that gets loaded — and the Dockerfile of a sqlite application installs no toolchain either.
--adapter now accepts drizzle (the default), disk, mongoose, postgresql, mysql and mssql; --dialect sqlite|postgres|mysql picks a dialect for drizzle, and works on its own now that drizzle is the default adapter.
Nothing changes for an existing application: its stores block is what the boot reads.
postgresql and mysql are Drizzle
Section titled “postgresql and mysql are Drizzle”@usehenri/postgresql and @usehenri/mysql were thin dialects over @usehenri/sequelize. They are now thin dialects over @usehenri/drizzle, with the dialect and the driver chosen. The package names, the --adapter postgresql and --adapter mysql flags and the "adapter": "postgresql" store value are all unchanged: what changed is the ORM behind them, because the name means “henri’s PostgreSQL adapter” and henri’s PostgreSQL adapter is Drizzle.
This is a breaking change and there is no compatibility switch. It is the one change in this release that rewrites what an existing store does rather than adding to it, and it is here rather than in a 2.0 because henri has no installed base to protect. If you are on one of these adapters, read this whole entry.
What you get: generated, versioned migrations (henri db:generate, db:migrate, db:push, db:status), the Drizzle model API, and one fewer dependency to declare — the driver comes with the adapter package now, so pg and mysql2 can leave your package.json, and a store needs no dialect key.
What changes in the application:
- The model API. The global is the drizzle model, not a Sequelize
ModelStatic.findAll,findOne,findByPk,create,count,destroyandinstance.update()/instance.destroy()are all there and mean the same thing.Model.scope(),addScope(),addHook(),findAndCountAll(),bulkCreate(),upsert(),increment(),sequelize.literal(), the association mixins (post.getComments()),instance.previous()andinstance.changed()are not: they throw aTypeErrorthe first time they run, which is what you want from a missing method. Models is the API. - The calls that would have meant something else are refused, not run.
Model.update(values, { where })is Sequelize’s argument order and the opposite of this one, so it raisesHENRI_MODEL_INVALID_QUERYrather than updating whatever matched the values. An option this adapter does not read (attributes,fields,raw,transaction,individualHooks) raisesHENRI_MODEL_UNKNOWN_OPTIONrather than being dropped. A condition keyed by Sequelize’sOpsymbols would narrow nothing, so it is refused rather than answering every row.instance.get({ plain: true })istoObject(). The full list is in What it refuses. - Model options are refused too.
options: { indexes, scopes, defaultScope, hooks, tableName, underscored, freezeTableName }were Sequelize’s and are dropped by this adapter, so a model declaring one fails the boot naming the key and what to write instead.optionshere takestimestamps,paranoid,externalId,personalandretention. Hooks are the top levelhookskey of the model file, and the table name is its top levelname. { transaction }is gone and needed nowhere. Every model call made insidestore.transaction()joins it through the async context, so there is nothing to thread through.- The tables and columns are named differently. Sequelize wrote
TasksandcreatedAt; this adapter writestasksandcreated_at, plural snake_case throughout. There is nothing to reconcile — these adapters have never had migrations, so a schema was only ever built bysequelize.sync(), and a new one is built by the firsthenri db:generate— but a database that already holds rows under the old names is not the database this adapter will look for. Move the data yourself, or start the database again.
What to do, in order:
pnpm remove pg(ormysql2): the adapter package brings it.- Drop the
dialectkey from the store if it has one; the adapter fixes it. - Boot in development. Every model option this adapter cannot honour fails the boot, one message each, so the model files are done when it starts.
- Run the application’s own tests. The refusals above are what turns a Sequelize spelling into a failure you can see rather than a query that answers the wrong thing.
henri db:generate --name=init && henri db:migrateon a fresh database, and move the data across if you have any.
An mssql store stops changing the production schema by itself
Section titled “An mssql store stops changing the production schema by itself”Until 1.3 every Sequelize boot ran sequelize.sync(), in every environment. In development that is the point. In production it was DDL applied at boot, from whatever the models happened to say, with nobody reviewing it: it created the tables a typo named, and it stayed silent about every table that was already wrong, because sync() only ever creates what is missing and never alters what exists. mssql is the one adapter this still applies to, now that the others are Drizzle.
A production boot now changes nothing. It reads the database back instead, compares it with the models, and warns about every difference:
warn postgresql store default and the models differ in 2 place(s); run "henri db:status" to see them, "henri db:status --sql" for the DDL that would close themwarn postgresql tasks.priority: the column is missingwarn postgresql invoices: the table is missingNothing changes in development, and nothing changes for a drizzle store, which already refused to push in production.
What you have to do. If your deploy relied on the boot creating tables — a new model shipped and the table appeared — it no longer will, and the first query against the missing table is what will tell you. Two ways forward:
- Keep the old behaviour, deliberately:
"sync": trueon the store inconfig/production.json.henri auditreports it asschema.autosync(ASVS V14.1.1, A05) so it stays a decision rather than a default. - Bring the schema up as part of the deploy and leave the boot out of it.
henri db:status --jsonsays whether the database matches before you cut over, andhenri db:status --sqlwrites the DDL to review.
henri db:status reads an mssql store back
Section titled “henri db:status reads an mssql store back”The mssql adapter has no migrations and is not getting any: Drizzle has no SQL Server dialect, which is the whole reason @usehenri/sequelize is still here. What it gained is the ability to answer the question migrations exist to answer — does this database match my models? — which until now no henri command could:
henri db:status # what the database and the models disagree abouthenri db:status --sql # the DDL that would close it, for you to reviewhenri db:status --json # `clean: false` and the differences, for CIIt reports a missing table, a missing column, a column whose type or nullability differs, a missing index, and a column that is in the database and in no model. It writes no DROP — a column henri does not recognize may hold the only copy of something — and it applies nothing at all: every statement is for you to read and run. On sqlite a column change is reported with no statement, because sqlite has no ALTER COLUMN.
henri db:generate, db:migrate and db:push belong to the drizzle adapter, which is every SQL store but this one. On an mssql store they answer HENRI_CLI_MIGRATIONS_UNSUPPORTED and say where to look instead of failing with “has no migrations”.
henri doctor gained two checks that read the files rather than the database: schema.migrations-ignored (there are migrations in db/migrations and the store’s adapter — mssql — can never apply them) and schema.migrations-pending (a drizzle store whose production configuration does not set "migrate": true, so a production boot warns and applies nothing).
Moving a Sequelize store to drizzle
Section titled “Moving a Sequelize store to drizzle”Only an mssql store is still on Sequelize, and it stays there because Drizzle has no SQL Server dialect. If you are moving to another database as well, this is the shape of it, and it starts from the database you already have — no dump, no reload, no dropped database.
-
Check that the database matches the models first.
henri db:statuson the Sequelize store, and close whatever it reports. A move that starts from a drifted database inherits the drift. -
Point the store at the new adapter:
"adapter": "postgresql"or"adapter": "mysql"(each brings its driver), or"adapter": "drizzle"with a"dialect". -
Adopt the existing tables.
henri db:generate --name=initwritesdb/migrations/0000_init.sqlfrom the models. When the tables are already there and already match, henri records that migration as applied instead of running it, and says so:Wrote db/migrations/0000_init.sql (12 statement(s))The database already matches: recorded as appliedhenri db:statusthen shows0000_initapplied and nothing pending. From here on, a schema change ishenri db:generatethenhenri db:migrate. -
If it does not say “recorded as applied”, the two adapters disagree about something and
henri db:pushwould show you the statements. They do not spell every type identically — a SequelizeSTRINGisVARCHAR(255), a drizzletext()isTEXT— and they do not name tables and columns identically either: Sequelize wroteTasksandcreatedAt, drizzle writestasksandcreated_at. Expect to rename, or to move the rows into new tables, and to do it on a copy of the database rather than on production.
The model files change too: the henri schema format (type, required, default, enum, unique, index) is the same on both adapters, but Sequelize data types written out longhand, raw sequelize.query() calls, anything reaching for Model.sequelize, and the options keys Sequelize owned (indexes, scopes, hooks, tableName, underscored) have no drizzle equivalent — the last of those fail the boot naming the key. Read Models before you start, and move one store at a time.
The numeric id stops resolving, and stops travelling inside a foreign key
Section titled “The numeric id stops resolving, and stops travelling inside a foreign key”1.2 gave every record a public uuid and took its own primary key out of what leaves the server. It left two holes, and 1.3 closes both. See Identifiers.
A number in a url does not answer any more. Model.findById() takes the externalId and nothing else; a primary key gets the same null an unknown uuid gets, which the controller you already wrote answers as a 404. In 1.2 /tasks/42 still worked next to the uuid, so an attacker never had to type a uuid at all.
What breaks: every call that hands findById() a value read from the database.
// beforeconst fresh = await Task.findById(task.id);// afterconst fresh = await Task.findByKey(task.id);findByKey() is the primary key, and only the primary key. findById() is the one that takes what arrived from outside, so a controller doing Model.findById(req.params.id) needs no change at all – that is the case this is for. findByIdAndUpdate() and findByIdAndDelete() refuse a primary key too. On every SQL adapter, findByPk() is now an alias of findByKey() and no longer accepts a uuid; findByExternalId() is the explicit other half.
henri’s own session and token lookups were moved to the key lookup, so signing in, staying signed in and every account flow are unaffected.
A foreign key travels as the public identifier of the row it names. A proposal that belongs to a speaker used to answer speakerId: 4812, which is another row’s sequential id: hiding a record’s own id and handing out its neighbour’s is not hiding anything. henri now replaces every declared foreign key on the way out.
What breaks: a client reading a numeric speakerId reads a uuid. A form that posts one back posts the uuid, and Model.findById() on the target turns it into the key the column wants.
henri only translates a relation the model declared – belongsTo() in associate(models), references: { model } on the field, ref on a Mongoose path – and reads no field name to decide. A column holding an id and saying nothing is left as it is; declaring the relation is the one-line fix. A controller that presents its records builds a plain object, which carries no model: call henri.model.publish() first and present second. The full list of what henri will not guess is in Foreign keys.
Neither change touches the database: the columns, the joins and the indexes are what they were, and a model with options: { externalId: false } behaves exactly as it did. There is no migration.
externalIds restores either behaviour for an application that cannot move yet, and henri audit reports it when it is:
{ "externalIds": { "lookup": "any", "references": false } }DECIMAL and BIGINT are exact columns, and their value is a string
Section titled “DECIMAL and BIGINT are exact columns, and their value is a string”The schema format gained two types, decimal and bigint, and the Sequelize spellings a model file may already carry now point at them. See Exact numbers.
The column changes. On a drizzle store, DECIMAL and NUMERIC used to resolve to number — a double — and BIGINT to integer, 32 bits. They are numeric(19, 4) and bigint now (decimal(19, 4) on MySQL), which is what those names always meant. It is a schema change like any other: henri db:generate writes it and henri db:migrate applies it, on a copy of the database first if the column holds rows. A DECIMAL was storing money in a double, so the values in it are already whatever a double made of them.
The value changes. A decimal and a bigint cross into JavaScript as exact decimal strings — '19.99', not 19.99 — on every adapter, and that is what a JSON answer, a version diff and a cached record hold. What breaks is arithmetic on the way out:
// beforeconst total = invoice.amount * quantity;// after: henri ships no arithmetic, and the string is the exact valueconst total = Number(invoice.amount) * quantity; // a double again, deliberatelyA page that only prints the value needs no change; a sum needs a decimal library and a string handed back to henri. Values are still written the way they always were — Invoice.create({ amount: 19.99 }) works — but a value with more decimal places than the scale is now refused rather than rounded, so 0.1 + 0.2 fails validation instead of landing in the column. Rounding is the application’s.
Two things fail the boot rather than doing something else. A bare DataTypes.DECIMAL on a Sequelize store: a decimal with no precision is DECIMAL(10, 0) on MySQL — whole units, so money loses its cents — and writing { type: 'decimal', precision: 12, scale: 2 } gets the same column on every dialect. And either type on a Sequelize store on sqlite, which reads it back through a double; that one is a configuration henri new never wrote, since sqlite goes to Drizzle.
From 1.1 to 1.2
Section titled “From 1.1 to 1.2”Every record has a public uuid, and the numeric id stops leaving the server
Section titled “Every record has a public uuid, and the numeric id stops leaving the server”This is the change of 1.2 that touches an application everywhere: urls change, JSON payloads change, and a database migration is required. Read it before upgrading.
Every model now carries externalId, a uuid stored in an external_id column that is NOT NULL and UNIQUE in the database, generated on the insert. It becomes the only identifier that leaves the server: routes, _links, the Location header of a 201, the path helpers and the data a page receives all carry it, and toJSON() no longer serializes the primary key. The primary key itself does not change: it is still what the foreign keys and the joins are made of, and belongsTo, hasMany, include() and populate() are untouched. See Identifiers.
What breaks:
- Urls:
/tasks/42becomes/tasks/0199a5c1-1f7e-7a3c-bb0d-2b1a4f6d9c11. In 1.2 old links kept working, becauseModel.findById()took the primary key as well; in 1.3 they stop (above). Bookmarks, sitemaps and anything holding an id of yours will need remapping. - Payloads: a serialized record has
externalIdand noid(no_idon MongoDB). A client readingbody.idreadsbody.externalIdnow. - The public user:
henri.user.publicUser(user)andreq._henri.useranswer{ externalId, email, roles }instead of{ id, email, roles }. - Your own code: anything building a url or a key from
record.idusesrecord.externalId. Server side,record.idis unchanged and still the right thing for a query, a join or a foreign key. - Generated code:
henri generate scaffoldandcrudwrite controllers and pages aroundexternalId. Regenerate them with--forceto pick it up.
The migration:
-
MongoDB (
disk,mongoose): the field and its unique index are created on boot. Documents written before the upgrade have noexternalId, and the unique index counts them as one sharednull, so backfill them before the second one is written:// db/seeds.js, or a one-off script run with `henri console`const { uuidv7 } = require('@usehenri/mongoose/external-id');for await (const doc of Task.find({ externalId: { $exists: false } })) {await Task.updateOne({ _id: doc._id }, { $set: { externalId: uuidv7() } });} -
Drizzle:
henri db:generatewrites the column and the constraint. On a table that already holds rows the generatedADD COLUMN ... NOT NULLfails: split it into the three steps below, which is what the showcase application’sdb/migrations/0001_external_id.sqldoes.gen_random_uuid()is version 4, which is fine for rows that already exist; everything written afterwards gets a version 7 from the adapter.ALTER TABLE "tasks" ADD COLUMN "external_id" uuid;--> statement-breakpointUPDATE "tasks" SET "external_id" = gen_random_uuid() WHERE "external_id" IS NULL;--> statement-breakpointALTER TABLE "tasks" ALTER COLUMN "external_id" SET NOT NULL;--> statement-breakpointALTER TABLE "tasks" ADD CONSTRAINT "tasks_external_id_unique" UNIQUE("external_id"); -
Sequelize (
mssql): there are no migrations, andsequelize.sync()does not alter a table.henri db:status --sqlwrites theADD COLUMNfor you to review (see The schema of an mssql store); on a table that already holds rows, split it into the same three steps as above (UUID()on MySQL and MariaDB,NEWID()on MSSQL) before deploying.
A model that must keep its old shape opts out, and then nothing above applies to it:
module.exports = { options: { externalId: false }, schema: { name: { type: 'string' } },};henri new scaffolds an Inertia application
Section titled “henri new scaffolds an Inertia application”The default renderer is now inertia: henri new writes "renderer": "inertia" in config/default.json, scaffolds .jsx pages under app/views/pages and builds them with Vite. henri new --renderer react still scaffolds the Next.js application, and the React engine is supported: it is frozen on the pages router rather than removed, because the contract that hands a controller’s data to a page has no equivalent in the app router. See Views.
Nothing changes in an existing application. Its renderer key is what the boot reads, and henri generate scaffold|crud reads the same key: a React application keeps getting .js pages using withHenri and @usehenri/react/forms, an Inertia one gets .jsx pages using useHenri() and <Form>. There is no migration, and no supported way to convert an application from one engine to the other: the pages are rewritten by hand.
Two things follow from the renderer in the generated code, if you compare a fresh scaffold with yours:
- A failed write answers a browser differently. The React controllers answer a
422and the forms show it field by field; the Inertia controllers callres.inertia.errors()and render the form page again. API clients get the same422from both. henri generate testwrites the Inertia page object assertions next to the HAL ones in an Inertia application, and the HAL ones alone in a React one.
Timestamps are on by default
Section titled “Timestamps are on by default”Every model now gets createdAt and updatedAt, like every Rails table. Before 1.2 they were added only when the model declared options: { timestamps: true } on the Mongoose (disk, mongoose) and Drizzle adapters; the Sequelize adapters (mysql, postgresql, mssql at the time; mssql alone now) already added them by default, so nothing changes there.
What this means for an existing application:
- MongoDB (
disk,mongoose): nothing to do. New and updated documents get the two fields; the documents already stored keep whatever they have, and reading them is unaffected. - Drizzle: the models gain two
NOT NULLcolumns, so the schema and the database no longer agree. In development the boot pushes them; in production write the migration withhenri db:generateand apply it withhenri db:migratebefore deploying. On a table that already holds rows, review the generated statements: aNOT NULLcolumn needs a default for the existing rows. - Sequelize: unchanged,
sequelize.sync()already created them.
Add options: { timestamps: false } to any model that should keep its old shape. options: { timestamps: true } still works and is now redundant: henri generate model no longer writes it.
The minimum password length moves from 6 to 12
Section titled “The minimum password length moves from 6 to 12”henri.user.encrypt() used to refuse anything shorter than 6 characters. It now refuses anything shorter than 12, and anything longer than 72 bytes (bcrypt’s ceiling, past which it silently ignores the rest rather than telling you).
Nobody is locked out by this. The policy governs setting a password: signing in never applies it, so an account created with a six character password keeps signing in with it, and its hash is quietly upgraded when it does. What changes is registration and password changes.
What to check before deploying:
- Your own interface. If your signup form, its validation, or the copy next to it advertises a shorter minimum, it will start refusing passwords you told people were acceptable. Update the form, or set the minimum you actually want.
- Seeds and fixtures. Anything creating a user with a short password (
db/seeds.js, test helpers) now throws. The showcase application’s seeded password moved fromshowcasetolineup-showcasefor exactly this reason.
config.user.password.minLength sets it. It will not go below 8 — the floor every comparable framework sits at:
{ "user": { "password": { "minLength": 8 } } }henri.user.validatePassword(password) is the same rule without the hashing, and answers { valid, errors: [{ code, message }] } so a form can say what is wrong. See Passwords.
Passwords are hashed with argon2id, and old hashes are upgraded on sign-in
Section titled “Passwords are hashed with argon2id, and old hashes are upgraded on sign-in”New hashes use argon2id when @node-rs/argon2 resolves — it is an optionalDependency of @usehenri/core, so pnpm install picks up a prebuilt binary on every platform Node runs on and skips it silently anywhere it cannot. Where it is missing, bcrypt is used, at cost 12 instead of 10.
Nothing to do, and nothing to migrate: bcrypt hashes still verify, and each one is written again in the current format the next time its owner signs in successfully. Two things worth knowing:
- A hash written under argon2id needs argon2id to verify. If some of your machines have the binding and others do not, they will disagree. Pin
"password": { "algorithm": "bcrypt" }if you need every deployment to be interchangeable, or"argon2id"to fail the boot rather than silently fall back. - Cost. bcrypt at 12 is about four times cost 10 (a quarter second per sign-in with the pure-JS bcrypt henri ships); argon2id at the OWASP parameters is faster than that and uses 19 MiB per hash.
config.user.password configures all of it, including an optional pepper.
Sign-in attempts are capped per account
Section titled “Sign-in attempts are capped per account”Ten failures against one account inside fifteen minutes and it stops accepting sign-in attempts for the rest of the window, whoever is sending them. This is new: the rate limit only ever counted per address, so a slow attempt spread across many addresses was unbounded.
What to check: anything that signs in repeatedly with the wrong credentials — a health check pointed at POST /login, a test suite that asserts many failures for one account — now meets a 429 where it used to get a 401. Failures are counted for unknown emails too, so the answer is the same whether the account exists or not.
"lockout": { "max": 20, "windowMs": 300000 } under user retunes it, "lockout": false turns it off. The counter is in memory and per process; store takes a shared express-rate-limit store. See Sign-in lockout.
CSRF also checks where the request came from
Section titled “CSRF also checks where the request came from”The double-submit token is unchanged. On top of it, an unsafe request carrying a session cookie must now come from an origin this application recognizes: Sec-Fetch-Site: same-origin or none, or an Origin matching the host or listed in csrf.trustedOrigins. A sibling subdomain is refused, which is the case the token alone never covered.
What to check:
- A browser client on another origin that sends cookies. It needs its origin trusted. Whatever
config.cors.originalready allows is trusted for you, so an application that configured CORS properly needs no change. - A reverse proxy that rewrites the
Hostheader and sets neitherX-Forwarded-HostnorX-Forwarded-Protowill make the computed origin disagree with the browser’s. henri reads both whenconfig.trustProxyallows it (it istrueby default); check the setting is right for your setup.
Nothing changes for a client that sends no session cookie, or one authenticated with a bearer token. "csrf": { "origin": false } restores the token-only behaviour.
The GraphQL endpoint has depth, alias and complexity limits
Section titled “The GraphQL endpoint has depth, alias and complexity limits”Only applies if a model of yours declares graphql, which also means the application now installs @usehenri/graphql (below). A query is now refused when it nests more than 10 levels, uses more than 15 aliases, selects more than 1000 fields (fragments expanded) or holds more than 5000 tokens. A page’s own query is nowhere near any of these; a generated or machine-built query might be.
Each limit is a key of config.graphql, which is now an object as well as a path, and false lifts one. The same object adds authenticated, roles and loopbackOnly, because the endpoint has never had a guard of its own. See Bounding the endpoint.
GraphQL moves to @usehenri/graphql
Section titled “GraphQL moves to @usehenri/graphql”The GraphQL layer left @usehenri/core and lives in @usehenri/graphql, which ships it as a henri module. Core no longer depends on @apollo/server, @as-integrations/express5, @graphql-tools/merge, @graphql-tools/schema or graphql, and carries no GraphQL code at all: an application that never mounted a schema stops installing them.
If your models declare a graphql key, or your controllers render with { graphql }, install one package:
npm install @usehenri/graphqlNothing else changes. henri.graphql is the same object with the same run(), endpoint, active, error classes and toApolloError(), the endpoint is still /_henri/gql (and still configurable with the graphql key), and the schema is still built from the models’ graphql keys.
Without the package henri says so rather than going quiet:
- a model that declares a
graphqlkey fails the boot with the install line; res.render(view, { graphql })fails the request with the install line, instead of rendering a page whose data is missing;henri doctorreportsdeps.declaredwhen a model declares types or the configuration setsgraphql;henri.graphqlisundefinedrather than an object that does nothing, and a page has nographqlkey among its view options. Code reading it guards withhenri.graphql &&; the type declarations make it optional, so TypeScript and a JSDoc-annotated file say so too.
The queue registers itself
Section titled “The queue registers itself”Nothing to change unless your application enqueues jobs. @usehenri/jobs was already a package of its own, but @usehenri/core carried the module that loaded it: an application without the package still had a henri.jobs, an inert object whose every method explained what to install. The package ships the module itself now, the way @usehenri/graphql does.
If your application has app/jobs or a jobs configuration block, make sure it depends on the package:
npm install @usehenri/jobsIt probably already does — nothing worked without it. henri doctor reports it as a missing dependency when it does not, and henri jobs says the same.
Nothing else changes: the queue, the runner, the retries, the dead letter queue, the recurring schedules, the tables and every henri jobs command are untouched, and the module still sits at level 4 so a runner binds no port.
What is different is the application that has no queue:
henri.jobsisundefinedrather than an object that does nothing. Code reading it guards withhenri.jobs &&; the type declarations make it optional, so TypeScript and a JSDoc-annotated file say so too.deliverLater({ wait })ordeliverLater({ at })without the package now fails with the install line instead of sending the mail immediately.deliverLater()with nothing to honour is unchanged: it delivers out of band, silently, as documented in Mail.
henri new does not add the dependency, and the boot of an application without it says nothing at all.
New in 1.2
Section titled “New in 1.2”Nothing below breaks anything, they are additions:
options: { paranoid: true }turns deletes into adeletedAtstamp on every adapter, and queries hide the stamped records. See Soft deletes.Model.paginate({ page, perPage })answers{ records, page, perPage, total, pages }on every adapter:await Task.paginate(req.pagination())replaces a find and a count. See Pagination.henri.model.errors(error)turns any adapter’s validation failure into{ field: message }, and answersnullfor anything else. The controllers written byhenri generate scaffoldandcruduse it andModel.paginate(), so regenerate them (--force) to pick both up. See Validation errors.henri db:seedrunsdb/seeds.jswith the models loaded, on any adapter.henri newscaffolds the file. See Seeds.config.user.password.peppermixes a server-side key into every hash, so a stolen table cannot be cracked offline. It is off by default, it is its own key rather thanconfig.secret, and losing it makes every peppered password unverifiable. See The pepper.- A development server answers
/_henri/runtimeon the loopback interface, andhenri mcpturns it into tools that read the last errors with the request that caused them, the logs, the routes really mounted, the database (reads only) and the answer to a request it makes. Nothing is recorded and nothing is mounted in production. See Asking the running application.
Toolchain
Section titled “Toolchain”- Node.js 22 or newer. The
henribinary refuses to start on an older version. - Express 5 (Express 4 in 0.37). Middlewares you register yourself must follow its rules: wildcard routes are written
/{*splat},req.queryis a getter, rejected promises in handlers reach the error handler. - Next.js 16 with Turbopack and React 19.
next,reactandreact-domare peer dependencies: add them to the application (henri new --renderer reactdoes). Theinfernoandpreactrenderers are gone. The Vue renderer only loads with"experimental": { "vue": true }and has not been exercised since Nuxt 2. - Mongoose 9 and Sequelize 6 with current drivers. Mongoose queries take no callbacks:
Model.update()andModel.remove()are gone, useupdateOne(),findByIdAndUpdate(),deleteOne()andfindByIdAndDelete(). - Tests run on Vitest, not Jest (see Testing).
Project layout
Section titled “Project layout”config/default.jsonis meant to be committed and the secret to live in.envasHENRI_SECRET, which henri reads on boot. Asecretin the JSON still works.- The disk adapter stores its data under
<app>/.henri/datainstead of a temporary directory keyed on the project path. Data is not migrated; add/.henrito.gitignore(the scaffold does). - The React renderer needs
app/views/next.config.jsandapp/views/jsconfig.json; the engine creates them on first boot when they are missing.config/webpack.jsstill works and switches the bundler to webpack;config/next.jsextends the Next.js configuration under either bundler. Both are read once: edit, then restart. - Tests live in
test/**/*.test.jswith avitest.config.jsat the root (see below).
Server and HTTP
Section titled “Server and HTTP”- Development binds to
127.0.0.1. Usehenri server --host=0.0.0.0,HENRI_HOSTorconfig.hostto listen on the network; production still binds to0.0.0.0. - CORS is off unless
config.corsis set. - Unmatched routes get a content-negotiated 404 and controller errors a logged 500 (message and stack in development only).
X-Powered-Byis gone./_routesand/_controllersanswer in development and only from the machine running the server. res.boomis built in (express-boom is gone); the response shape is the same, see Controllers.- A misconfigured store, adapter, view or controller fails the boot instead of being logged and skipped.
henri.init()rejects with anErrorwhosecauseis the module error;pen.fatal()returns anErrorfor the caller to throw;henri.stop()resolves with the array of errors of the modules that failed to stop. SIGINTandSIGTERMstop the server gracefully, with a 5 second timeout; a second signal exits at once.
Users, sessions and requests
Section titled “Users, sessions and requests”- Log out with
POST /logout.GET /logoutanswers405. POST /loginanswers{ user }to JSON clients and redirects browsers toconfig.user.afterLogin; failures are401(400when a field is missing) or a redirect to<loginPath>?error=invalid.- Views,
req._henri.userand JSON answers receive the public user only:{ id, email, roles }plus the fields listed inconfig.user.public.req.useris still the model instance server side. - The
passwordfield is not selected by default:User.findOne(...).select('+password')on Mongoose,User.scope('withPassword')on SQL.henri.user.findByEmail()returns it,findById()does not. emailis trimmed, lowercased and unique. Existing rows are not rewritten; lookups lowercase their argument.rolesis dropped from mass-assigned creates and updates. Change roles withuser.setRoles(roles),User.setRoles(id, roles)or by passing{ unsafe: true }to the operation.- Double-submit CSRF protection: unsafe requests (
POST,PUT,PATCH,DELETE) that carry the session cookie must send thehenri.csrfcookie back as theX-CSRF-Token(orX-XSRF-TOKEN) header or a_csrffield, or they get a403. The Reactfetch()andhydrate()helpers, the Inertiafetch()helper andFormdo it; a plain form needs the_csrffield; requests with a bearer token are exempt;"csrf": falsedisables it. - The session cookie is
httpOnly,SameSite=Lax,Securein production and lives 30 days (config.user.sessionMaxAge). req.logout()takes a callback (passport 0.7). Prefer the built-inPOST /logout.- Use
req.permit('title', 'body')instead ofreq.bodywhen creating or updating records.
Models
Section titled “Models”- Models are written in the henri format (
{ type: 'string', required: true, default, enum, unique, index }, see Models) and normalized per adapter. The SQL adapters throw on keys they do not know: Waterline-stylevalidations,isInordefaultsTofrom old model files must becomeenumanddefault. The Mongoose adapter passes unknown keys through. - A store without
url(orhost) fails the boot instead of leaving a broken adapter.host,port,database,usernameandpasswordare accepted instead ofurl. - Model files may export
associate(models), called once every model exists (beforesync()on SQL). Adapters exposeping(),transaction(fn)and, on SQL,query(sql, params). - On SQL,
rolesis a JSON column (TEXT with a JSON getter on MSSQL) andemailis validated as an email.
fetch()inwithHenriuses the nativefetchand resolves with the parsed body (it resolved the axios response before:.datais gone). Failed requests reject with aRequestErrorcarryingmessage,statusCode,erroranddatafrom the boom body.withHenrireads onlyreq._henrion the server: query string values no longer become page props.errors,graphql,csrfandlocalUrlreach the page anduseHenri().hydrate()keeps the current data when the answer is not a henri page and exposes the error asuseHenri().error.pathFor()andgetRoute()replace whole parameter names (:idno longer rewrites:identifier).- Forms: sanitizers chain (
trimthenescape), the form stays disabled until the request settles,Selectrenders a real placeholder option,Editoris controlled by the form data and loads Quill in the browser only.prop-typesandshallowequalare gone. - There is no
helpers/import alias: any folder underapp/views(components/,styles/,assets/) is importable by name,app/helpersis not. - Handlebars:
/artworkresolves topages/artwork.{hbs,html,htm}thenpages/artwork/index.*and nothing else; a route without a page is a 404; the view options are data variables ({{@user.email}}).
GraphQL
Section titled “GraphQL”henri.graphql.run(query, variables, contextValue)returns{ data, errors }and forwardscontextValueto the resolvers (res.render()passes{ req, res }).- Apollo Server 5: the error classes on
henri.graphql(AuthenticationError,ForbiddenError,UserInputError, …) areGraphQLErrorsubclasses with anextensions.code.
henri testspawns the application’s Vitest withNODE_ENV=testand exits with its code. Addvitestand@usehenri/testingto the devDependencies and avitest.config.js(see Testing);@usehenri/testingexportssetup,teardown,request,agentandhenri.henri buildbuilds the React views without booting the stores: it no longer needs a database.- Generators write plural, unscoped resources (
Postgivesapp/controllers/posts.js,resources postsandapp/views/pages/posts/), answer validation errors with a 422 and pick attributes withreq.permit(). Existing files are skipped unless--forceis given.generate controlleradds a route per action,generate workerandgenerate testare new, andhenri routesprints the routes table. utils.checkPackages()never installs anything: it prints the install command and throws.
Packages
Section titled “Packages”@usehenri/mailerand@usehenri/websocketare not published; the mailer lives in core (henri.mail).express-sessionis a peer dependency of@usehenri/sequelizeand@usehenri/mongoose(core depends on it, so applications need nothing).BaseModulelost its unusedsetup(),start()andinfo()stubs. Custom store adapters must implement the adapter contract:getSessionConnector()is async andfindUserByEmail,findUserById,userIdandtoPlainare required.
