Guides / Upgrading
Upgrading
What happens to your schema when a new version adds a column, on each database path.
0.9 to 0.10
No schema change. Two behaviour changes, both closing a hole from the 0.9.0 security review:
-
Rate limiting without proxy headers. If you set
rateLimitwithout akey, it keys oncf-connecting-ip,x-real-iporx-forwarded-for. Before 0.10, a request with none of them was not limited at all; now all such requests share one bucket, and the log says so once:rateLimit: a request arrived with no ... header. If you see that line, your limiter was off until now and is now global. Fix it one of two ways:- put the app behind a proxy that sets one of those headers (Vercel, Cloudflare and most load balancers do), or
- pass your own key:
rateLimit: { max, windowMs, key: (request) => sessionIdFrom(request) }.
A
keyfunction that returnsnullstill exempts the request, so health-check exemptions keep working. -
Empty user ids.
session.getUserIdreturning""(or anything that is not a non-empty string) is now a 401, with a warning logged once. If your helper doessession?.user?.id ?? "", anonymous requests were all sharing one user's inbox; change it to?? null.
Smaller changes that only affect code that was already wrong: a plugin route that reuses a core
route's path is now a startup error; a plugin store update or remove with {} throws; asking
for a new Telegram link code retires the previous one.
0.3.0 to 0.4.0
Four things to do, in this order:
- Secrets.
secretandcron.secretmust be at least 16 characters and not a placeholder, or startup throws. Generate real ones before deploying. - Schema.
notification_preferencegainsupdated_at.planPostgresMigrationemits theALTER TABLE ... ADD COLUMN IF NOT EXISTSfor it (verified against a 0.3.0-shaped table);drizzle-kitdiffs it; MongoDB needs nothing. Rows written before the upgrade have no value until they are next saved, so the unsubscribe freshness check does not apply to them yet. - Tokens. Signing keys are now derived per purpose, so every token issued by 0.3.0 stops verifying. Unsubscribe links in already-sent email will return 400; re-issue them.
- Plugins and clients.
PluginInitContext.secretis gone; usectx.sign(). Any client that POSTs withoutContent-Type: application/json, or from another origin, now gets 415 or 403. The shipped clients already send both correctly; addtrustedOriginsif your API lives on a different origin from the page.
Which path you're on
| You bootstrapped with | To upgrade |
|---|---|
createSchema() + drizzle-kit | drizzle-kit diffs the core tables for you. Regenerate the plugin file with renderDrizzleSchema first (Drizzle), then run drizzle-kit |
createPostgresTables() or renderPostgresDdl() | planPostgresMigration() for coreSchema and each plugin schema — see below |
createMysqlTables() | planMysqlMigration() per core and plugin schema — see the MySQL page |
createSqliteTables() | planSqliteMigration() per core and plugin schema, in one transaction — see the SQLite page |
createMongoIndexes() + createPluginIndexes() | Rerun both. createIndex is idempotent and additive. |
renderPostgresDdl cannot upgrade you. It emits CREATE TABLE IF NOT EXISTS, correct exactly
once. Rerunning it on an existing database does nothing at all — a version that adds a column
would otherwise leave that column silently missing until something hits it at runtime.
planPostgresMigration
import { INTROSPECT_SQL, planPostgresMigration, coreSchema } from "easy-ping/schema";
const plan = await planPostgresMigration(
async () =>
(await sql.unsafe(INTROSPECT_SQL)).map((row) => ({
table: row.table_name,
column: row.column_name,
type: row.data_type,
nullable: row.is_nullable === "YES",
})),
coreSchema,
);
for (const statement of plan.statements) await sql.unsafe(statement);
if (plan.unsupported.length) console.warn(plan.unsupported);
It introspects the live schema, diffs it against the declaration, and emits only what's missing.
Run the same call for any plugin's schema too — push({...}).schema, preferences().schema.
What it will and won't do
Additive only, deliberately. It adds missing columns and indexes, and creates tables that
don't exist yet. It never drops a column, never changes a type, and never touches a column it
doesn't declare — each of those lands in plan.unsupported as a message for a human, because
they're destructive and context-dependent in ways a generic tool shouldn't guess at.
One behavior worth knowing before you run it against a populated table: a required field with
no default is added nullable, because NOT NULL would abort against existing rows. That case
is also reported in unsupported — backfill the column, then ALTER ... SET NOT NULL yourself.