v0.17.0
Breaking
Under 0.x the minor is the breaking position: ^0.16.0 resolves
>=0.16.0 <0.17.0, so nothing here reaches a project until it deliberately moves
to 0.17. The entries below say what stops working and what to do; the reasoning
for each is in the detailed section it links to.
-
@rebasepro/adminis now@rebasepro/cms, and@rebasepro/admin-typesis
@rebasepro/cms-types. "Admin" named two things at once — the whole panel,
and the content-management half of it — and the ambiguity had already cost
something: spreadsheet views, entity history, users & roles and CSV import were
being sold as Studio features because there was no other name for the half they
actually belong to. The structure is now three peers under Rebase — Backend,
CMS, Studio — rather than a parent with two children. "Admin panel" survives
only as a lowercase phrase for CMS and Studio rendered together. -
import { RebaseAdmin } from "@rebasepro/admin";
-
import { defineCollection } from "@rebasepro/admin-types";
- import { RebaseCMS } from "@rebasepro/cms";
- import { defineCollection } from "@rebasepro/cms-types";
**Who this breaks, and what to do.** Anyone importing either package: change the
specifier, and `RebaseAdmin` to `RebaseCMS`. There is no alias and no
deprecation period — a shim would keep both meanings of "admin" alive, which is
the defect being fixed. `@rebasepro/admin` and `@rebasepro/admin-types` stop at
0.16.0 on npm and receive nothing after it, so a range like `^0.16.0` keeps
resolving to the last release rather than breaking; it simply stops moving.
**Your collection files do not change.** The `admin:` config key is deliberately
untouched, along with every identifier named after it (`AdminCollection*`,
`Admin*Options`, `ADMIN_COLLECTION_KEYS`), `DatabaseAdmin`/`databaseAdmin`,
`wsAdmin`, the `admin` auth role, and `/api/admin`. Those name something other
than the CMS product: the `admin:` block feeds a nav drawer Studio shares, and
`/api/admin` serves the RLS audit and API keys, both of which are Studio's.
Renaming them would have doubled the churn to no one's benefit.
The panel's mode value moved with the package, `"content"` → `"cms"`. It is
persisted per browser and migrates on read, so a browser that used the panel
before this keeps working instead of holding a mode nothing matches and
rendering neither half of the drawer.
- **Resources are declared, not configured.** `RebaseBackendConfig`'s
`dataSources` and `storageSources` are gone; declare them in `rebase.json` and
the config package instead. **A bundle built before this will not boot on a
current runtime — rebuild it with `rebase build`.** The runtime contract stays
at 1 deliberately; see the note under *Removed*.
- **A collection still carrying `admin.titleProperty` is rejected at boot.**
Use `admin.display.title` — the same string works there. This can stop a project
that starts today, which is the point: silence would mean a title quietly
reverting to the derived one with nothing to explain why. Details under
*Removed*.
- **`ctx.client` in a cron handler is now `ctx.rebase`**, and `userId` is no
longer an accepted identity spelling anywhere — `uid` everywhere. Both under
*Removed*, with the reason each alias was more dangerous than the rename.
- **`rebase eject infra` is gone**, along with `rebase.infra.json` and the
`{"$env": "..."}` indirection. Resources bind from the environment on the
`<BASE>__<KEY>` convention, which is the path every deployment already used.
- **`rebase build --legacy` and `rebase start --legacy` are now `--workspace`.**
The mode is supported, not retired, and the old name said otherwise.
- **Every deprecated API alias is deleted rather than warned about**, including
`WhereValue<T>` (use `WhereValueFor`) and `RENAMED_SLOTS`. The full list is
under *Removed*.
- **An incoherent Kanban board now fails at boot.** A board is two declarations
that have to agree, and every way of getting it wrong used to parse, boot,
serve rows and render — the only symptom being that dragging did not stick.
`checkBoardConfig` now runs wherever collections load, so the runtime,
`rebase schema generate`, the policy generator and `rebase doctor` all say it.
An `orderProperty` naming a property that does not exist, **or one that is not
a string, is fatal**; `kanban` with no `orderProperty` only warns, and the
board still boots without reordering.
**This can stop a project that boots today, and the docs are why.** An order
key is a `fractional-indexing` key in base36 (`"i0"`, `"i1"`, `"i0i"`), so a
`number` can never hold one — but the documentation said
`sortOrder: { type: "number" }` in every locale, and five translated copies
additionally nested `orderProperty` inside `kanban`, where nothing reads it.
All of that is corrected. If you followed it, change the property to a string:
```diff ts
- sortOrder: { type: "number" }
+ sortOrder: { type: "string" }
- A static app can no longer claim a path the backend serves. One process
serves the API and however many static apps a project declares, and mounting is
longest-path-first — so an app declaringpath: "/api"outranked the API
itself, and every request to it was answered with that app'sindex.html: a
200 carrying HTML where the caller wanted JSON, from a project that looked
deployed and healthy.rebase.jsonvalidation, the control plane at deploy
intake, and the router's own mount ordering now enforce the same reserved list
from@rebasepro/types. Matching is at segment boundaries, exactly as the
router matches:/apidocsis still fine,/api/v2is not.
PUT on the data API is not in this list: it was removed during this cycle
and put back before release, because every published SDK still sends it. See
PATCH is the update verb under Changed.
Removed
-
rebase eject infraandrebase.infra.json. The command wrote a file
documented as being "read before the environment", and nothing read it:
loadInfraConfigandbindResourceshad no caller outside their own tests,
in either repository. The three-tier binder they implemented — file, then
environment, then a local provisioner — never ran, and the header claiming
the control plane injected such a file was contradicted by the control plane's
own comment saying it deliberately does not.Resources bind from the environment on the
<BASE>__<KEY>convention, which
is the path every deployment has always used. Running the command now names
the removal rather than failing as an unknown app.packages/server/src/boot/local-provisioner.ts
went with it — it returnedSTORAGE_BUCKETandREBASE_STORAGE_ENGINE, names
the resolver has never read.Removing this drops the
{"$env": "..."}indirection with it. A self-hoster
wiring secrets from Vault or SOPS renders them into the environment, which is
what everyone was already doing — the alternative was maintaining a second
binding path no deployment has ever exercised.
Breaking: resources are declared, not configured.
RebaseBackendConfig's
dataSourcesandstorageSourcesare gone; declare them inrebase.jsonand
the config package. A bundle built before this will not boot on a current
runtime — rebuild it withrebase build.The runtime contract stays at 1. Pre-release, a breaking change is just a
change: there is no population of old bundles to protect, so a major would buy
nothing and invalidate therebaserange in every manifest and template.
Added
-
pnpm check:portable-core— what the request path depends on Node for. A request this server can answer without touching the database pool is a request an isolate could answer, and that set is larger than it looks: token verification, rate limiting, idempotency, storage URL signing, and every custom function. Eight modules on that path needed a Node process, for nine separate reasons. Five of those modules needed it for no reason anyone had chosen:randomUUIDfromnode:cryptowhere thecryptoglobal would do,node:pathto fold.out of a storage key that never touches a filesystem, SHA-256 and a constant-time compare that WebCrypto does just as well.Those five are gone, and the gate records what is left in
contracts/portable-core.txt. It is a ratchet rather than a wall: the file may shrink and may never grow, so a branch that puts a fresh dependency on Node in front of every request has to say so in review instead of a year later. Nothing has to reach zero for that to be worth having —drizzle-ormandpgneed a TCP socket, and that is a driver decision. What it buys is that a later port is a scoping exercise against a list, not an excavation.Three lines remain, each with its reasoning in the file: the JWT library, PEM key parsing, and the client's socket address — a per-adapter capability rather than something a portable module can reach, since Hono has no runtime-agnostic
getConnInfo.The SSRF guard joined the same list and needed two changes to clear it.
net.isIPbecameutils/ip-address.ts, a transcription of Node's own grammar held to it by a property test comparing the two directly — a validator that is stricter thannet.isIPsends a literal down the resolution path, and one that is looser judges bytes nobody else agrees with. And its default resolver is loaded on use rather than imported, so a runtime with nonode:dnscan be handed one instead of being unable to load the module at all. A host with neither fails closed and says which of the two it is missing: the alternative to resolving a name is not "allow it", it is "do not send". -
Custom functions have their own entry point:
@rebasepro/server/functions.import { defineFunction } from "@rebasepro/server"reaches the whole framework — the boot sequence, the collection loader, the backup routes, the SPA server,@hono/node-server,ws,jsonwebtoken, Drizzle. On Node that costs a little start-up time and nothing else, which is why it stood. It also meant a function file could only ever resolve inside a Node process, however portable the function's own code was — and since that import line is in every function file, every template and every documentation page, it is not a thing that can be changed later without breaking everyone who wrote one.The new entry point carries the authoring surface and nothing else:
defineFunction, therebasesingleton, route guards, typed context accessors, configuration readers,waitUntil,ApiError,HonoEnv. Its published bundle imports exactly two things,honoandhono/adapter, and the build refuses to ship it otherwise — a test walks the import graph from source and names the chain that broke the rule, and a second check evaluates the emitted file in a context holding web globals and noprocess,Bufferorrequireat all. Importing from the package root still works and still behaves identically; it is now the second-best way to write a function rather than the only one. -
Typed accessors for the request context.
getUser(c)returns{ uid, roles, …claims }orundefined, withrolesalways an array. Every documented example used to open withconst user = c.get("user") as { uid: string; roles?: string[] } | undefined— an assertion in a security-relevant position, copied once and never re-examined, and wrong for at least one auth path that reaches it.getUserId,getRoles,hasRole,isAdmin,isAuthenticated,getDriver,requireDriver,getApiKeyandgetRequestIdcome with it.requireDriver(c)replacesc.get("driver")!and, when there genuinely is no driver, says that the app was mounted outside the functions router instead of failing twenty lines later onundefined.requireRole("editor", "admin")joinsrequireAuthandrequireAdmin. All three read the identity the platform already resolved rather than parsing a token, which is what makes them portable — and is a distinction with no behavioural difference inside a function, where both auth middlewares have already run. Outside one, where nothing has, they answer 500 naming the wiring rather than 401 blaming the caller's token. -
waitUntil(c, promise)for work that outlives the response. An un-awaited promise looked equivalent and was not, in both directions. AtSIGTERMa floating promise is dropped mid-flight, so a rolling deploy has always been able to lose the webhook a request had already answered 200 for; shutdown now waits for tracked work, bounded, and says how much it had to drop. And on any host where the process does not outlive the request, an un-awaited promise is not slow but cancelled — silently, behind a clean 200.waitUntilis the one construct both cases honour. -
Configuration is read from the request:
getEnv,env,requireEnv,lazyResource.const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)at the top of a function file is a live defect today, not merely an unportable one: it is evaluated while the file is being imported, so an unset variable throws before any request exists and the loader reports the whole file as a skipped function. The route 404s, and the reason is one line in a boot log.lazyResource(env => new Stripe(env.STRIPE_SECRET_KEY!))builds the same client once, on first use, from that request's configuration.rebase doctorandrebase buildnow report module-scopeprocess.envreads in the functions directory. -
rebase buildrecords what each function needs from its host. The bundle manifest gains afunctionsarray — name, file, and whether the function's own source reaches a Node built-in or a package that needs one. Purely descriptive: nothing fails, and a function that opens a file or runs raw SQL is a fine function. It is recorded because the name is already the function's identity everywhere (/api/functions/<name>, thefunctions/<name>API-key permission,REBASE_FUNCTIONS_ONLY), and a host that wants to know what is in a bundle should not have to boot it to find out. -
Live schema editing, from the collection editor to the database. A running backend can now plan a schema change, show what it would do, and apply it only once somebody agrees.
planSchemaChangereads the live catalogue before it plans, because whether aNOT NULLcan be added is a question about rows and whether an enum value will land is a question about the type — neither is answerable from the collections alone. The editor's save path shows the verdict in a sentence, then each change with its remedy, and does nothing until confirmed.Applying is a second privilege, not the same one that opens the editor: it alters the database and it writes a commit into the project's repository under somebody's name, and an admin credential is not an author. For deployments with no working tree — a Cloud tenant runs a built bundle and its repository lives elsewhere — the commit goes through GitHub's Git Data API instead of
git. -
A managed development database, so
rebase devneeds no Postgres. Getting a project running wasdocker compose up -d db, thendb push, thendev— three steps, each a place to bounce, plus a compose file the developer then maintains.rebase devnow starts the database and pushes the schema itself. The managed database is PGlite behind a multiplexing socket server, anddb pull, the schema flows and the rest of the CLI were wired through to meet it.Realtime was the one thing it could not do, and it failed in the worst way available: every query succeeded,
LISTENreturned cleanly, and change events simply never arrived. It now works through a notification proxy. -
REBASE_DB_POOL_MAX, a ceiling every pool honours. The managed database is a single session, where two pooled clients holding overlapping transactions deadlock rather than error. -
The RLS audit runs on a schedule, and the backend serves what it found. Also
rls-check --html: the text report is written for a terminal, and the person who has to act on it is usually not the person who ran the scan. A--fail-onexit code stops a pipeline; it does not survive being forwarded to whoever owns the database. -
rls-check --role, because a check can only gate on a role it knows about. Every check reports a table as exposed only when a role an untrusted caller can arrive as holds privileges on it, and that set was hardcoded toPUBLIC,anon,authenticated,web_anonandrebase_user. A stack whose app role is calledapp_usergave every check nothing to gate on, so the scan printed a clean report for a database it had not cleared. The report now also listsunrecognizedGrantees— write-holding roles it can neither recognise as exposed nor explain as trusted — so it says "clean as far as I could tell" rather than "clean". -
Storage: byte-range requests and per-object access control. Media can be seeked, and who may read an object is declared rather than coded.
-
Bot protection on the auth endpoints that cost something to hit, development secrets that survive a restart, and auth email captured in development instead of refused.
-
An ANN index for every vector column, with pgvector shipped in the scaffolded database image.
-
Collections declare their indexes, and a hand-written index stops
disappearing. The collection model had noindexeskey: the DDL generator
emitted index statements for exactly two things, both structures a feature
owns rather than queries anyone wrote — the GIN index behind asearchblock
and the ANN index behind avectorproperty. The plain case, the btree behind
awhereclause, had no declaration site at all.
indexes: [
{ on: ["status", { prop: "publishDate", direction: "desc" }],
reason: "admin list: filter by status, newest first" },
{ on: ["publishDate"], where: { prop: "status", op: "=", value: "published" },
reason: "public feed is published-only" },
{ on: ["author"], reason: "an author's posts, and the ON DELETE cascade" }
]
So the only way to have one was to write it by hand — which is the other half
of this. `rebase db push` is declarative, so an index on a managed table that
is absent from `schema.sql` is drift and Atlas plans `DROP INDEX` for it.
`DROP INDEX` is not in `DESTRUCTIVE_PATTERNS`, so the auto-approved apply took
it with no prompt. Measured against atlas v1.2.3 and Postgres 18, not
inferred: create an index by hand, re-run an unchanged push, and the plan is a
bare drop. Every hand-written index in the field has been living on borrowed
time, and since a hand-written index was the *only* kind there was, that was
the only outcome.
Adding `DROP INDEX` to the destructive list would have been the wrong fix —
once indexes are declarable, removing one from your config *should* remove it
without a scare. Ownership is decided by the name instead, the arrangement
policies already use. An index is named `<table>_<columns>_ix_<7 hex>` (`_ux_`
when unique), which no other namer here can produce, so a declaration you
delete drops as intended and an index Rebase did not create is excluded from
the diff and never touched. That also settles the introspection round trip:
the existing indexes of a database you point Rebase at are foreign until
somebody declares them.
The hash is over the index's *semantics*, not its rendered SQL, so
reformatting the generator never renames a live object — and it is what makes
a redefinition take effect at all, since `CREATE INDEX IF NOT EXISTS` matches
on the name. (That bug is shipped today one layer over: `vector-index.ts`
leaves `WITH (m, ef_construction, lists)` out of its name, so retuning an HNSW
index is a permanent silent no-op.)
`prop` takes a **property key, never a column name**, because the two differ
in exactly the case people index most: a `belongsTo` resolves to its
`localKey`, so `author` becomes `author_id`. `where` is structured rather than
a SQL string — a string could not be checked against the collection's
properties and could not be fingerprinted without putting its own text in the
index name. And `reason` is required, and deliberately not hashed: an index is
the only thing a config can declare that costs money forever and whose benefit
is invisible from the config, so rewording the justification must not rebuild
it.
Both producers emit them — `db push` on the ordinary Atlas path, and
boot-time schema ensure with `CREATE INDEX CONCURRENTLY IF NOT EXISTS`. The
first cut only did the former, which a managed-runtime tenant never runs; the
derived-names contract caught it, with the whole suite green and the round
trip through real Atlas clean.
Not included, each its own subsystem: the deferred `CONCURRENTLY` builder for
a redefinition (today a DROP + CREATE holding a lock), a size-based push gate,
`doctor`'s index categories, introspection adoption, and the drizzle-schema
side. See [Indexes](/docs/backend/indexes).
- **A pod contract the chart and the control plane both answer to.** Probe paths, shutdown budgets, the bundle mount and the set of topology variables a deployer owns now live in one place that both pod builders read, instead of two hand-written lists that had already disagreed.
- **`rebase cloud resources` is priced, with no plan left to name**, and `rebase cloud projects info` prints a Storage line — plus a warning or a lockout notice when the project is near or past its limit. The shared pools already enforced a per-tenant disk ceiling by setting `CONNECTION LIMIT 0`; the tenant's first signal used to be their database refusing connections, with no number anywhere that would have warned them.
- **One-click deploy blueprints, an MCP registry manifest, and the security post.**
- **The eight documentation pages every locale was missing are translated**, with validation of what the model returns, and the landing page has a translation script of its own — the marketing pages read no markdown, so nothing had ever translated them.
- **Resources are declared, not configured — and there is one way to do it.** A
database, a bucket and a topic are all spelled the same way, in the project's
own config:
```ts
// config/resources.ts
export const main = database();
export const media = bucket("media", { engine: "s3" });
export const signups = topic<{ userId: string }>("signups");
Before this, storage topology was hand-written into rebase.json while
database topology lived in TypeScript, and the boundary between them was a
fact about what the control plane could read before a build — a platform
implementation detail a developer had no way to derive. Worse, a bucket could
be declared in both, and the runtime merged them: one engine was kept and
the other silently discarded. A declaration accepted and then ignored, which
is the class this release removed everywhere it appeared.
Kinds are registered, not hardcoded, because the cost of adding one is
exactly why the last two ended up in different homes — a new kind needed a
manifest schema edit, a validator edit and a switch statement, so the cheapest
thing was always to bolt it onto whichever home was nearest. cache, queue
or search now need none of that. Each kind owns its engine list and
custom:<id> is always accepted, which fixes engine having been a free
string: "s2" used to pass every check and fail far from the typo.
-
rebase resourceslists what a project declares;--writeregenerates
rebase.resources.jsonand--checkfails on drift. That file is generated
and committed, and it is what a host reads to decide what to provision
before running anything — which is how a console can say "wants amedia
bucket, has none" on a first deploy, and how acustomruntime (which emits
no bundle manifest) is visible to the platform at all. -
Binding is separate from declaration, and identical everywhere. A
declaration says a resource exists; the environment says where it lives, on
the<BASE>__<KEY>convention. Baking the address into the repository is how
a project ends up with its staging credentials in git, and it is why staging
and production can run the same commit against different infrastructure.The cloud is not a second mechanism: the control plane binds the same
variables a self-hoster sets, so a managed tenant runs exactly the code path a
self-hoster runs. -
Several buckets can share one account.
bucket("media", { account: "minio" })
reads its ownS3_BUCKET__MEDIAwhile the provider-level variables —
credentials, endpoint, region — fall back toS3_ACCESS_KEY_ID__MINIOand so
on. Fifteen buckets on one install go from ninety variables to eighteen, and
rotating a key is one edit rather than fifteen paired ones.The bucket name itself never falls back, and neither form falls through to the
unsuffixed variable: that one belongs to the default source, and letting a
named bucket inherit it would mean a mistyped key silently signs with another
source's credentials. -
Topics, delivered through the durable job queue. Publishing writes one
row per subscription, so each subscriber retries on its own schedule and a
broken one neither blocks the others nor makes them run again. Delivery is
at-least-once and says so —at-most-onceis refused at declaration rather
than quietly given the other guarantee. A publish inside a transaction that
rolls back never happened. Declaring a topic turns the job queue on by itself,
and a driver that cannot carry the queue refuses to boot rather than starting
a backend where every publish throws. -
The managed tier provisions what a project declares, and charges for it. A
second database is created on the project's own pool, owned by the same role,
and billed as a second shared-database line. The disk quota moved from
per-database to per-project for it: the ceiling used to be keyed ondatname,
so five declared databases would have held five full quotas against a volume
sized to budget one each — the pool would have run out of space with nothing
naming the cause. Sizes and allowances are both summed per project now, so a
second database brings its own space rather than splitting the first one's. -
Six-digit sign-in codes by email. A magic link opens the session on
whichever device holds the mailbox, which is the wrong device on a television,
a terminal, a kiosk or a second browser — the flow simply cannot be completed
there.auth.emailOtp(orAUTH_EMAIL_OTP=true) addsPOST /auth/otpand
POST /auth/otp/verify, andrebase.auth.sendEmailOtp/verifyEmailOtpin
the client.Six digits is a million possibilities, so what is stored is a hash of the
address and the code together: a guess is a guess against one named account
rather than against every account in the table, which is what a code-only
lookup would have made of it. Five verification attempts per address per
window, keyed on the address rather than the caller's IP because an IP is the
attacker's to rotate and the account under attack is not. Ten minutes, single
use, uniform digits.POST /auth/otpanswers identically for an address with
no account, so it cannot be used to ask whether somebody is a customer.AUTH_MAGIC_LINKarrives with it: both flows were code-level flags only, so a
bundle deployment — the shape every self-hosted and managed project runs —
could not turn either on without rebuilding. -
Storage triggers: run something when an object lands. A row has
beforeSaveandafterSave, a schedule has a cron job, and an upload had
nothing — so everything an upload implied had to be a second call from the
client, which means it does not happen when the client goes away between the
two.storageTriggersfires onfinalizeanddelete, matched with the same
pattern languagestoragePoliciesuses, for the multipart and resumable paths
alike. Handlers are awaited before the response, because a floating promise is
one a serverless runtime may freeze mid-flight; a handler that throws is
logged and does not fail the request, because the object is already stored and
an error would tell the client to repeat a write that succeeded. -
Image renditions can live in the storage source instead of one process's
memory. The transform cache was per-instance and did not survive a restart,
so every replica computed every variant and every deploy threw the lot away.
storageRenditionCache: { enabled: true }writes each rendition back to the
source's own bucket under_rebase/renditions/, keyed by the source object's
version so a replaced image serves the new one. Off by default, because
turning it on makes aGETwrite to somebody's bucket — and when that write
fails the request still succeeds from memory, with the reason logged once. -
The development mailbox is readable over HTTP. Auth mail with no SMTP
configured is captured and its links printed, which completes the flow for
somebody watching a terminal and leaves it incomplete for a server in Docker,
in another window, or one line above where the log has scrolled to.
GET /api/admin/dev/emailsserves the same capture,DELETEempties it. What
it hands out is a working login, so it is gated three times over: admin-only,
a sink must be registered, and the handler re-readsNODE_ENVper request —
there is no configuration that makes it readable in production. -
A pooled Postgres port for the callers that cannot hold one.
docker compose --profile pooler up -dadds pgbouncer on 6432, for the
serverless functions, scheduled scripts and BI tools that would otherwise
exhaustmax_connectionslong before the database is busy. Documented with
what transaction pooling takes away —LISTEN/NOTIFY, session-levelSET,
cross-statement advisory locks, prepared statements — which is why the runtime
keeps its direct connection.SET LOCALsurvives, so RLS behaves identically
through it. -
The runtime keeps a little history of itself, and
rebase cloud metrics
prints it. Drawing "CPU over the last hour" from Cloud Monitoring would have
made the panel unportable the day the platform moves, for a feature every
self-hoster also wants; metrics-server cannot help either, since it stores only
the latest sample by design. So the process samples itself —
process.cpuUsage()andprocess.memoryUsage(), no cluster and no vendor —
into its own database, and anything that can read the database can draw the
chart. A laptop, a Hetzner box and a Cloud tenant keep the same history from
the same code. One row per series per minute, five series, swept to a
fourteen-day window at boot, beside the job and cron stores and for their
reason: it is the moment the schema is reachable and nobody is mid-request. -
rebase cloud resources set --replicasand--autoscale-max. Autoscaling
had columns and no flags, so the console form was the only way to reach it.
Two flags on the command that already writes every other dial, rather than a
rebase scaleverb — a second CLI surface writing the same row, whose
--size mediumform would have had to carry a t-shirt→cpu/memory mapping
client-side, which is exactly what substrate differences (Autopilot's
250m/512Mi floor and 1:1–6.5:1 band do not exist on Hetzner or EKS) make
wrong.--replicasis the floor and the spend a project is guaranteed to
incur;--autoscale-maxis the ceiling and the worst case it may be billed.
There is deliberately no--autoscale on|off, which would admit the
incoherent state where autoscaling is on and the range is a single point. -
A Terraform module for Hetzner, and a Hetzner page that is true. The old
page described a Rebase that no longer exists — Docker building a Node.js
backend from a local Dockerfile, and boot creating only auth tables so
collections 404 until someone runsdb push. Both were wrong, in all six
locales, and that page is where a reader lands from/docs/deployment. It is
rewritten against the contract the self-host compose file implements, and
points at that file rather than carrying a copy that can drift again. The
module provisions the host — server, firewall, a primary IP that survives a
rebuild, and a volume holding Postgres data, Caddy's certificates and the
bundle cache. The volume is the reason it exists: replacing the host must not
destroy the database, which the shell recipe cannot promise. -
Live schema editing works on MongoDB.
isSchemaEditingAdminis a
structural check — a driver either offersplanSchemaChangeor it does not —
and the Mongo driver did not, so a Mongo project fell back to the source-only
editor, which is off in production. A schemaless database is the one place
where changing a collection against a running backend cannot fail, and it was
the one place it did not work.planMongoSchemaChangeis short by the whole of
its difficulty: no table to alter, so every change is applicable, nothing is
refused, and there are no statements. What each change still carries is what
happens to the data, because that is where a reader imports the wrong
intuition — removing a property on Postgres is refused because it would drop a
column, while on MongoDB the field stays in every document that has it and the
API stops serving it. Saying so is the difference between knowing the data is
there and assuming it is gone.
Changed
-
JWT verification and signing are asynchronous.
verifyAccessToken,generateAccessToken,verifyDownloadToken,generateDownloadToken,hashRefreshTokenandextractUserFromTokenreturn promises. Nothing about their behaviour moved; the signatures did, and on purpose, before anything forced it.Every portable JWT implementation is asynchronous, because
crypto.subtleis. So a later swap ofjsonwebtoken— forjose, or for WebCrypto directly — is not the expensive part: the expensive part is going from synchronous to asynchronous verification, which touches every caller of every function that reads a token. That was 22 call sites insrcand about 190 in the suite. Paying it now, with the tests green and nothing else moving, costs a day; paying it as a line item inside a runtime port, on top of everything else changing at once, is how a port stalls.jsonwebtokenis now confined to one module,auth/jwt-crypto.ts, which is what makes the eventual swap a one-file change with no caller affected. The one trap in that swap is written down where the swap will happen:jsonwebtokenstampsiaton every token it signs andjosedoes not, andiatis what the revocation watermark is compared against — tokens minted without it would verify perfectly and simply stop being revocable.RateLimiterOptions.keyGeneratorandresolveLimitaccept a promise as well as a value, since a limiter that buckets by user has to verify a token to find one. Passing a synchronous function is unchanged. -
EmailService.send()reports what the provider said, and carries headers. It returnedPromise<void>, which meant an application that sent a message could not learn the id the server assigned it — so threading a reply back to the message that prompted it was impossible through this interface, and any app that needed it had to bypass the service and hold its own transport. It now resolves with{ messageId, accepted, rejected }, every field optional because not every backend reports them: an absentmessageIdmeans "not reported", never "not sent", which is still signalled by a throw.messageIdcomes back without angle brackets, since it is a value to store and compare against a reply'sIn-Reply-To, and one that sometimes carries brackets is a bug waiting in every comparison.EmailSendOptionsgainsheaders. Several things a real sender must do are only expressible as headers and had no route through this interface at all:List-UnsubscribeandList-Unsubscribe-Post, which give a mail client its own one-click opt-out and which the large providers weigh when deciding whether bulk mail reaches an inbox;In-Reply-ToandReferences, without which a reply starts a new thread. Values are validated, not escaped — a value containing CR or LF is rejected, because a newline ends the header and begins another one, so any field built from data the sender did not write is a way to add aBcc:. Stripping the newline instead would deliver a message the caller did not write and tell nobody. Header names are checked against RFC 5322 too, and both checks run before a customsendEmailprovider is reached, so the custom path is not a way around them.Breaking only for code that implements
EmailService: asendreturningPromise<void>no longer satisfies it. Callers are unaffected — they may ignore the result — and theauth.email.sendEmailhook stays permissive (Promise<EmailSendResult | void>), so an existingasync () => {}provider still works and simply reports nothing. The development mail sink now reports a synthetic id, so a flow that stores one and later matches a reply against it takes the same path in development as in production. -
A vendored tree too large to upload is not vendored. The control plane refuses a bundle over 100 MB, and vendoring is the one thing that can push a bundle near it — so a build that crossed the line shipped a bundle whose deploy would be rejected, with the remedy (
--no-vendor) only discoverable by knowing that had happened. Past 200 MB on disk the tree is now thrown away and the bundle ships unvendored: 40–60s of cold start, and a deploy that works.--vendorkeeps it regardless, for a deploy that builds from source and never uploads the tree at all.The ceiling assumes a pessimistic 2× floor on compression, because the limit is on the compressed upload while this measures the tree on disk. The warning below it now says which quantity is which — "201 MB, close to the 100 MB upload limit" was two different numbers described as one, and read as nonsense.
-
GET /api/auth/configanswers one question once, from one handler. Two handlers claimed that path:init.tsregisters it directly and only afterwards mounts the auth router, so the router's copy never ran — and the two returned different payloads, one reportingemailServiceEnabledandmagicLinkEnabledwhere the live one reportedpasswordResetandmagicLink. A fix aimed at the wrong copy therefore changed nothing, which had already happened once. The router's copy is gone, the payload is assembled in one function, and the surviving route is rate-limited like the rest of the unauthenticated auth surface — it counts users on every call.In the payload itself,
registrationandregistrationEnabledwere the same boolean under two names, both advertised.registrationEnabledis the only one now, and it is required rather than optional: it says whether self-registration is open right now, first-user bootstrap window included.anonymousLoginis required for the same reason.AuthConfigin@rebasepro/clientandAuthConfigResponsein@rebasepro/appare aliases ofAuthAdapterCapabilitiesinstead of near-copies of it — the SDK's copy listed anemailServiceEnabledflag no backend has ever sent, and marked as optional fields every backend always sends. A test pins the exact key set, because the drift that started this was a field name, and no per-field assertion can see one. -
PATCHis the update verb for the data API.PUTwas mounted on the same handler and the generated OpenAPI spec described the operation twice — once aspatch, once asputmarkeddeprecated— so a client generated from the spec had to choose, and the verb it chose meant "replace" for a handler that merges. The SDK'supdate()now sendsPATCH, which is what the spec has advertised since 0.14;updateManywas already there.PUTstill answers, on the same handler, carryingDeprecation: true(RFC 8594). It was removed during this cycle and put back: every published SDK up to and including 0.16.0 sendsPUT, so removing it broke clients that had no fixed version to upgrade to — seePUTon a collection answers again under Fixed. There is noSunsetdate, because the removal is gated on which SDKs are in the field rather than on a calendar.
Removed
Nothing below was deprecated in the usual sense of "still works, please stop". Each was a second name for something that already had one, and every one of them is gone. There is no compatibility mode.
-
admin.titleProperty→admin.display.title. The same string works there, anddisplay.titlealso takes a resolver. The old key had grown seven readers that disagreed about the fallback; a collection still carrying it is now rejected at boot, by name, with the replacement in the message — silence here would mean a title that reverts to the derived one with nothing to explain why. -
ctx.clientin a cron handler →ctx.rebase. Its type re-exposedclient.data, the aliasRebaseServerClientomits on purpose so that the privileged plane is spelleddataAsAdminon every server surface. A reader who learnedclient.datain a cron carried it into a collection callback, wherecontext.datais the user-scoped plane: same spelling, opposite privilege. -
userIdas an identity spelling.AuthResultadvertiseduidoruserIdfrom a custom validator, and the middleware had already half-removed it — the normalisation read("uid" in r ? r.uid : undefined) || ("uid" in r ? r.uid : undefined), the same clause twice — so the documenteduserIdhad stopped working there whilegetUser()and the JWT verifier still honoured it.uideverywhere. -
RENAMED_SLOTS, the rewrite that quietly redirected the retiredcollection.insightsandhome.card.insightslot names, and the console warning beside it. -
WhereValue<T>, superseded by the operator-correlatedWhereValueFor. -
tooltipsOpenandadminMenuOpenon both drawer components,errorandpaddingonRelationSelector, and the ignored second parameter ofgetEntityTitlePropertyKey— all declared, all documented, none of them read. -
isBootstrapCompleted/setBootstrapCompletedon the auth route module and the admin users route. No caller ever supplied them; the bootstrap gate is "does this backend already have an admin", asked of the rows. -
The websocket client's
subscriptionsmap and the "legacy subscription handling" branch that read it. Nothing ever wrote to it. -
Three modules that only forwarded exports:
@rebasepro/types/controllers/database_admin(already exported fromtypes/backend),server-postgres/utils/table-classification(from@rebasepro/common), and theunflattenObjectre-export in the admin'sfile_to_json. -
rebase build --legacyandrebase start --legacyare now--workspace. The mode is supported, not retired, and the name said otherwise. -
UploadFileResult.storageUrlis required. Every controller returns one — S3, GCS and local alike — so the??fallback behind it was dead code. -
dataSourcesandstorageSourcesonRebaseBackendConfig, and the
storageblock inrebase.json. All three were ways to declare a resource
somewhere other than a declaration. Each is refused at boot, by name, with the
replacement in the message — not ignored, because a key that still parses and
no longer does anything is the failure this replaced.<Rebase dataSources>and<Rebase storageSources>are unaffected: those are
props on the React provider, a different surface. Hand them
declaredDataSources()anddeclaredStorageSources()so the list is not
written twice.
Fixed
-
PUTon a collection answers again, because every published SDK still sends it. PATCH became the update verb and the PUT alias went with it. That reached a control plane before it reached any client:collection.update()sends PUT in every release up to and including 0.16.0, which was tagged three days before the change landed, so upgrading tolatestdid not help either. Three CLI commands are oneupdate()—rebase cloud stop,startandrestart, all throughsetStatus— and all three answered404 No PUT route on collection 'projects' at this path, which reads as a fault in your own data model rather than a verb that was withdrawn. Worst onrestart, the thing you reach for when a deploy has gone wrong.PUT is mounted on the same handler and carries
Deprecation: true(RFC 8594). It is deliberately not in the OpenAPI document: PATCH remains the single update operation, so anything generated from the spec still sends the verb the server means, and a spec-validating gateway still sees one operation. There is noSunsetdate, because the removal is gated on which SDKs are in the field rather than on a calendar — it goes one release after the first published client whoseupdate()sends PATCH. -
executeSql({ role })answered a refused role switch with owner rows. The option exists so a statement can run as a database role, which is the only way to see what a table looks like with RLS binding. WhenSET LOCAL ROLEcame back42501— the connection user not being a member of the role — the driver logged a warning and ran the statement on the unswitched connection anyway, then latched a process-wide flag so every later call skipped the switch too, in silence.Owner output is not a degraded answer to that question, it is a confident wrong one: a policy spot-check reads a protected table as exposed. The WebSocket audit line recorded
roleas the role that had been asked for, so the trail agreed with the mistake rather than catching it. The same subsystem already fails closed twice over —applyAuthContextaborts the transaction when the switch errors,scopeDataDriverrefuses the request rather than proceed unscoped — so this was the one door left open, and the only one whose fallback changed which rows came back.It now throws
RoleSwitchUnavailableError, naming the role and both ways out.DISABLE_DB_ROLE_SWITCHING=trueis unchanged and remains the sanctioned way to run SQL Editor queries as the connection owner: that is an operator's decision, not a failure.effectiveSqlRolereports which of the two actually applied, so the audit line no longer restates the request as the outcome. Asking for the role the session already holds needs no switch and still runs — that is the Studio role picker's default, and it never went near the failing path.Not an escalation, and worth saying so: every caller that can pass
rolealready holds owner —rebase.sqlis trusted server code whose default is the owner connection, and theEXECUTE_SQLWebSocket verb is admin-gated. This is a correctness and assurance fix, not a patched hole. -
rebase cloud deployread its own command word as the app name. The command parsedprocess.argv.slice(2)permissively and took the first positional as the app to deploy — but that slice removes onlynodeandrebase.js, so the first positional is always the stringcloud. Every documented invocation therefore refused itself:rebase cloud deploy --bundleanswered This repository declares no app named "cloud". It declares: backend, web. — on any project that did not happen to declare an app calledcloud, which is all of them.rebase cloud deploy <app>was unreachable for the same reason: the app argument landed at_[2]and was never read.The failure pointed away from itself, which is what made it expensive. The refusal comes from
selectDeployAppand names the apps the manifest really declares, so it reads as a fault in the user'srebase.json— andrebase apps listcalls the same manifest valid and eligible. The only route through was--bundle-dir, which skips app selection by skipping the build and the static fold with it, so it uploads whatever is already on disk: correct immediately after arebase buildand a stale site at any other moment.deploynow parses throughparseCloudArgslike the rest of the family, withcommandWords: 2, so the command words are dropped from the parsed positionals — a flag written before the group no longer shifts the app either. Being a strict parse, it also refuses a flag nobody declared (--bundelno longer deploys) and a second positional, rather than treating either as the app name.--urljoinsGLOBAL_CLOUD_FLAGS:resolveCloudUrlhonours it on every line in this family, so a strict parse had to accept it. The tests assert the resolved app name directly rather than through a fixture manifest — a fixture that happened to declare an app namedcloudwould have passed against the broken parse. -
The published types were
anyfor anyone using modern Node module resolution. Every package here is"type": "module", andtscwrites relative specifiers into.d.tsexactly as the source wrote them — extensionless, because the source is compiled by a bundler. UndermoduleResolution: "nodenext"(or"node16") an extensionless relative specifier inside an ESM declaration file is an error, and TypeScript's response is the part that matters: it does not fail at the consumer's import. It resolves the package, discards every declaration it could not follow, and types the whole importany.So there was no diagnostic anywhere near the cause. The first thing a consumer saw was an implicit-any error in their own file, pointing at their code, in a project that had done nothing wrong. Measured on
@rebasepro/server:bundlerresolution saw 170 value exports,nodenextsaw zero. It had been that way for the entire life of the packages and was never reported, which is what a silent failure looks like from the outside.Fixed by appending the extension the declarations always needed —
./init→./init.js, and./auth→./auth/index.jswhere the target is a directory, resolved against the filesystem rather than guessed. This is not a trade: TypeScript maps a./x.jsspecifier onto./x.d.tsundernode10,bundlerandnodenextalike, so nothing that worked before stops working. The rewrite runs as a build step in all twenty-one published packages.Nothing in this repository could have caught it, and that is the more interesting half.
pnpm typecheck, the docs verifier and the template checks all map@rebasepro/*onto source; the API-surface gate reads a single.d.tsin isolation. Every gate looked at something other than the artifact a stranger installs.pnpm check:dtsnow looks at that: it installs each built package into a throwaway directory by symlink, imports it, and asks the type checker whether the result isany— a question that needs no knowledge of any package's API, and so keeps working as they change. It runs in CI after the build. -
bundle.mode: urlhad never worked, and three independent things blocked it. The runtime's fetch looked for arebase-bundle.jsonthat nothing has ever written — the CLI writesmanifest.json— so no unpacked directory was ever recognised as a bundle; the entrypoint exited 1 before@rebasepro/serverwas imported; and the chart rendered a pod missing what the working path expects. Removing any one of them changed nothing, which is how the mode stayed dead while being documented, validated by the gate, and offered in the values file. -
The runtime image stripped four packages it never supplied.
packages/cli/src/bundle.tsremoves five@rebasepro/*packages from a bundle's declared dependencies on the grounds that the image supplies them;docker/entrypoint.mjssupplied one. Custom functions and cron jobs therefore failed to load withCannot find package, the routes 404'd, and the container reported itself healthy — only a boot-log warning separated a deployment whose code ran from one where none of it did. The entrypoint's dedupe step also only repaired a duplicate and never provided a missing copy, which is the common case.The same gap was then live on the fetch path, which does its own stitch after the download and carried a one-package list of its own. All three lists are now checked against each other.
-
The published image could not load its own Postgres driver. The driver's barrel eagerly imported a file watcher used by exactly one
--watchbranch of a CLI, and the image's hand-maintained dependency list does not include it — so@rebasepro/server-postgresfailed to load entirely and every/api/data/*route 500'd behind a green container. Found by a new acceptance run that builds the image from source, brings the documented compose file up, and asserts from outside the container. -
A static app dropped requests on every rollout, and a killed bundle install left a tree the next boot mistook for a finished one — at a 128Mi limit npm is OOMKilled holding 124 of 156 packages, which is indistinguishable from success unless something records completion.
-
The chart's probes contradicted the runtime, and the api counted every caller as one caller.
TRUSTED_PROXY_HOPSwas set on the functions unit and never on the api, so a default install ignoredX-Forwarded-Forand keyed every rate limit to the ingress. The chart also stopped offeringmigrationJob.mode: push, which the image refuses outright. -
REBASE_RLS_AUDITwas a topology variable the pod contract did not claim. The runtime reads it to decide which process owns the RLS audit scan, besideREBASE_CRON_SCHEDULERandREBASE_JOB_WORKERS, but it was never added to the list a deployer owns — so a tenant could set it tofalseand stop their own audit with no error anywhere. -
Two auth gaps on the WebSocket path.
ADMIN_ONLY_TYPESheld nine strings while the handler answers ten privileged verbs; the tenth ranSELECT DISTINCT unnest(roles)over the users table ungated. -
A storage key containing
#,%or an encoded slash addressed the wrong object. Every storage URL interpolated the key raw and the server decodes what it receives. -
Three ways a legal database name generated a file that will not parse. A hyphenated collection slug, a search column with a hyphen, and a table name legal in Postgres each produced a JavaScript identifier that is not one. The same file already defined
quote,propKeyandmemberwith docblocks explaining exactly this; they were applied in some positions and not others. -
Seven presentation keys were accepted at boot and then ignored.
fixedFilter,includeId,includeEntityLink,widget,sortable,canAddElementsandpreviewPropertieswere still listed as top-level keys on the four property types they used to live on, so on exactly those types the key was accepted, the migration hint was never reached, and nothing read the value — while the identical key on any other type failed with a helpful message. -
The history prune could delete below
maxEntries. It decided how many rows to drop and which rows to drop in two separate reads, and the prune runs unawaited once per write — so two in flight both counted three rows, both decided to drop one, and the second re-read and took a row that was never surplus. Silent data loss, worst exactly where history matters: a record being written concurrently. -
Reading a UI preference could crash the whole render. Four call sites guarded
localStoragewithtypeof window !== "undefined"and then used the bare global, which answers "am I in a browser" rather than "can I read storage". Safari in private mode, a blocked cookie policy and a sandboxed iframe all throw on the property access, so a user in that state got a blank admin panel instead of the default theme. -
rebase cloud billingandresourcesnever printed a price. Both calledinvoke("pricing/quote", …), andinvokeURL-encodes the function name, so the slash became%2Fand the route 404'd — every time, since the commands shipped. -
pgwas imported at runtime and declared dev-only, sorebase db pull --anonymizewould fail in a published CLI under pnpm's isolated layout while resolving fine in this workspace. -
The realtime
vectorSearchrefusal existed and could not fire,clearFilterreset todefaultFilterso a collection defining one could never clear its filters, and the admin decided from whether an answer had arrived rather than from the answer — a save in the first round trip after mount silently took the unconfirmed branch. -
A dead local proxy is not a TLS problem.
rls-checktranslated everyECONNRESETinto advice aboutsslmode=require, which is right for a managed provider and actively misleading for a loopback proxy that has died. -
The agent-skills subpath could never reach a skill —
exportsdeclared a trailing-slash directory export that Node has deprecated and cannot resolve a file through — and a scaffolded project had no schema resource, because two helpers assumed this monorepo'sapp/layout. -
"Cancelled deployment null" was the fix reported as a bug, the auth bootstrap probe swallowed its own failure and answered "already set up" in silence, and
rebase devannounced the database twice during start-up. -
Storage delivery: a replaced image no longer serves its old rendition, private objects are no longer marked public,
Content-Lengthis declared so a player can work out what to seek to, and cacheable responses say so. -
The snapshot recorder produced snapshots that could not restore, which is why the upgrade gate had decayed to two hand-written files while 0.14, 0.15 and 0.16 shipped without one.
-
frameworkVersionmeant two different things — the framework the runtime image ships, and the framework a bundle installed — socloud statusandcloud deploymentsread as contradicting each other. -
The schema dialog is no longer downloaded before login, 14 kB of eager JavaScript for a dialog that only opens when somebody edits a collection.
-
Two documentation routes only non-English readers reach were dead, 124 landing strings whose English had moved on are resynced, and
--refresh-stalestopped reporting ten keys that were already correct.