Changelog
Esta página aún no está disponible en tu idioma.
Changelog
Section titled “Changelog”[Unreleased]
Section titled “[Unreleased]”Breaking
Section titled “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
RebaseAdmintoRebaseCMS. There is no alias and no deprecation period — a shim would keep both meanings of “admin” alive, which is the defect being fixed.@rebasepro/adminand@rebasepro/admin-typesstop at 0.16.0 on npm and receive nothing after it, so a range like^0.16.0keeps 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, theadminauth role, and/api/admin. Those name something other than the CMS product: theadmin:block feeds a nav drawer Studio shares, and/api/adminserves 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’sdataSourcesandstorageSourcesare gone; declare them inrebase.jsonand the config package instead. A bundle built before this will not boot on a current runtime — rebuild it withrebase build. The runtime contract stays at 1 deliberately; see the note under Removed. -
A collection still carrying
admin.titlePropertyis rejected at boot. Useadmin.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.clientin a cron handler is nowctx.rebase, anduserIdis no longer an accepted identity spelling anywhere —uideverywhere. Both under Removed, with the reason each alias was more dangerous than the rename. -
rebase eject infrais gone, along withrebase.infra.jsonand the{"$env": "..."}indirection. Resources bind from the environment on the<BASE>__<KEY>convention, which is the path every deployment already used. -
rebase build --legacyandrebase start --legacyare 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>(useWhereValueFor) andRENAMED_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.
checkBoardConfignow runs wherever collections load, so the runtime,rebase schema generate, the policy generator andrebase doctorall say it. AnorderPropertynaming a property that does not exist, or one that is not a string, is fatal;kanbanwith noorderPropertyonly 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-indexingkey in base36 ("i0","i1","i0i"), so anumbercan never hold one — but the documentation saidsortOrder: { type: "number" }in every locale, and five translated copies additionally nestedorderPropertyinsidekanban, where nothing reads it. All of that is corrected. If you followed it, change the property to a string: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 declaring
path: "/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
Section titled “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.tswent 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’sdataSourcesandstorageSourcesare 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 the
rebaserange in every manifest and template.
-
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 no
indexeskey: 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 pushis declarative, so an index on a managed table that is absent fromschema.sqlis drift and Atlas plansDROP INDEXfor it.DROP INDEXis not inDESTRUCTIVE_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 INDEXto 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 EXISTSmatches on the name. (That bug is shipped today one layer over:vector-index.tsleavesWITH (m, ef_construction, lists)out of its name, so retuning an HNSW index is a permanent silent no-op.)proptakes a property key, never a column name, because the two differ in exactly the case people index most: abelongsToresolves to itslocalKey, soauthorbecomesauthor_id.whereis 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. Andreasonis 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 pushon the ordinary Atlas path, and boot-time schema ensure withCREATE 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
CONCURRENTLYbuilder 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. -
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 resourcesis priced, with no plan left to name, andrebase cloud projects infoprints 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 settingCONNECTION 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:
// config/resources.tsexport 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.jsonwhile 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,queueorsearchnow need none of that. Each kind owns its engine list andcustom:<id>is always accepted, which fixesenginehaving been a free string:"s2"used to pass every check and fail far from the typo. -
rebase resourceslists what a project declares;--writeregeneratesrebase.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 amediabucket, 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 on
datname, 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/otpandPOST /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 metricsprints 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 arebase 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 runs
db 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
Section titled “Changed”-
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
Section titled “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 thestorageblock 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 themdeclaredDataSources()anddeclaredStorageSources()so the list is not written twice.
-
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.
[0.16.0] - 2026-08-20
Section titled “[0.16.0] - 2026-08-20”-
A relation picker can create the row it is looking for. The list ends in an Add … action that opens the target collection’s form in the side panel, over the form you are already filling in; saving it closes the panel and leaves the new row selected, with no second trip through the picker. Until now a relation could only point at something that already existed, so a company that was not in the list meant abandoning the form, going to that collection, creating the row and starting again — and on a record being created, everything typed so far was lost.
A search that matched nothing is the name you were looking for, so it seeds the new row: typing
EDU.MXinto the picker and choosing Add “EDU.MX” opens the form with that already in the title field. Only when the collection’s title lands on a plain string property — putting free text into an enum or a relation would be worse than not prefilling — and the action itself appears only when the user could actually insert into the target, the same permission the selection dialog’s own Add button checks.The create form does not take the URL. It is a detour inside the form you are in, and a record that does not exist yet has no address to restore; pushing one made closing it a pathname change, which is exactly what the unsaved-changes blocker watches — so a successful save raced the panel clearing its own dirty flag and often answered with “There are unsaved changes”, with the URL stranded on the target collection.
The dialog widget already worked this way — except for the URL, which it took too. Its Add button is fixed with it, so both create-in-place paths now leave the address bar where the form is.
-
A unit of a split deployment can be released on its own.
functions.image.tagin the Helm chart (and theapi/workerequivalents, orbundleUrlunderbundle.mode: url) holds one unit at a build of its own, so a fix to a custom function no longer restarts the API. Empty by default: every unit renders one image and one bundle, which is still the shape to prefer. Pinning only the tag inherits the repository, because the common case is one project and one image with one unit held back.Two units on different builds are two sets of collections against one database, and only one unit provisions it. So the rule, stated in the values file and in the docs: the unit that owns the schema rolls first, and a unit may lag but must never lead. A unit running ahead queries columns that do not exist yet and relies on RLS policies nobody applied — the first is a SQL error on one route, the second is an empty result with a 200. A unit running behind is the ordinary state of any rollout in progress. The migration Job renders the release-wide image, so it always leads the pinned units by construction.
-
The Helm chart is published.
helm install rebase oci://registry-1.docker.io/rebasepro/rebase— an OCI artifact beside the runtime image, pushed by the same release job under the same credentials and verified pullable from outside with no credentials, exactly as the image is. Until now the chart existed only inside the repository, so installing it meant cloning first: the same defect the runtime image had in 0.13.0, when the first command a new self-hoster ran answeredpull access denied. A guard asserts an automated workflow publishes it, so it cannot regress quietly the way its predecessor did.The chart carries the same version as the runtime rather than its own. It ships with the runtime and its default image tag was already held to the runtime’s version; two numbers would mean working out which pairs with which, and there is no useful answer to that question. Both are gated against
@rebasepro/server. -
The runtime records the collections schema version it applied, and every other process checks itself against it. The process that provisions writes a version into
rebase.schema_meta; every other process computes its own from the collections it loaded and compares. On a disagreement it names both versions, says which way is safe, and serves anyway — during a rollout that disagreement is correct, because the units that have not rolled yet are supposed to be behind.REBASE_REQUIRE_SCHEMA_MATCH=true(orsharedState.requireSchemaMatchin the chart) refuses the boot instead, for a deployment that would rather not serve at all than serve wrong.The stamp lives in the database rather than behind an HTTP call to the api, and the difference is not stylistic. Asking the api needs its address configured on every other process — a variable whose absence disables the check silently — and makes booting depend on another process already being up. It also asks the wrong question: two processes can agree with each other while both disagree with the database, and the database is what they are all about to query. It is additionally the only form that works for a
worker, which has no reason to know any URL, and for a singlealldeployment scaled to three, where there is no api to ask.Both sides of the comparison are computed from the collections in hand, never read from a bundle manifest. A version a build declares about itself is not evidence that the database agrees with it —
/api/meta/schema-versionreturns exactly that declared value, which is why comparing that endpoint to that manifest is a check that passes on a bundle whose declared version is nonsense.What it cannot do is tell you which side is ahead: a schema version is a hash, so it reports disagreement and never direction. That is why the rollout order is a documented rule rather than something the runtime enforces.
A driver older than the runtime has neither hook — the image supplies
@rebasepro/serverwhile the driver comes from the bundle — and that is treated as “this driver does not record a version” rather than as a boot failure. The check starts working when the project’s driver is next updated. -
An entity view can sit in front of the record’s own tab.
position: "start"on anEntityCustomViewmeant “first among the custom views”, which placed it after a tab it was already after — the record’s own tab is drawn unconditionally first, sostartandendonly ever ordered the custom views against each other and no collection could open on anything but its form. That contradicteddefaultSelectedView, which has always been able to name a custom view as the landing tab. A cover — a read-only summary of a record, the thing an operator opens a row to see — now renders before the form that edits it. A view that says nothing still lands after the record, so nothing moves for a collection that never asked. -
The record count sits in the collection toolbar. It lived in the breadcrumb trail, which the app bar owns, so a collection rendered without an app bar simply had no count and no way to see how many rows a filter resolved to. It now ends the toolbar’s leading group, after the filter, sort and preset controls — the count is what those resolve to, and it reads as one sentence with them. Hidden wherever the toolbar goes icon-only, because a passive readout is the first thing that should give up its room on a strip that scrolls.
-
rebase cloud resourcesshows what a project is given, and changes it. The dials print with “plan default” where one is unset, so chosen and inherited are visible rather than inferred, andresources setsends only the dials named — a patch carrying every field at its default would overwrite dials set from another client. Nothing here validates a value on purpose: the rules belong to the target cluster (Autopilot bills a 250m/512Mi floor and rewrites anything outside a 1:1–6.5:1 memory:CPU band; a Hetzner or EKS node has neither), so a CLI carrying those numbers would be wrong for two of three providers the day it shipped. -
A cluster can be registered and verified from the command line.
rebase cloud clusterswas list-only, so registering one meant inserting a row by hand and finding out whether it worked when a customer’s first deploy failed inside provisioning.clusters addregisters from a kubeconfig and points straight atclusters verify, which reports what the control plane found — reachable, what the identity may do, what is installed, and a verdict — and exits non-zero onunusableso it works as a gate in a runbook. Registration stays admin-only, matching the collection’s RLS: a cluster record carries a credential that can create namespaces and read every secret in them. -
rebase doctorreports a connection string libpq cannot parse. Fixing the generator does nothing for the projects it already generated, and this defect is invisible day to day — node-postgres accepts the string, sorebase devandrebase db pushwork whilerebase db backuphas never once succeeded. Doctor now scans.env,.env.localand the compose files forDATABASE_URLandADMIN_CONNECTION_STRINGand prints the corrected string to paste back. The compose files are checked on their own account: a deployed stack’s scheduled backup cron reads its connection string from there, so it stays broken after.envis repaired. -
The default auth emails carry your logo. All five built-in templates — password reset, verification, invitation, welcome and magic link — render
email.logoUrlabove the card, and the six auth call sites that each carried their ownappName || "Rebase"line now resolve branding in one place. The fallback is asymmetric on purpose:appNamefalls back to “Rebase” because an unconfigured app has no better name to show, and the logo does not follow it, because the alternative is mailing Acme’s users a Rebase mark from Acme’s domain. It must be a PNG on an http(s) URL — mail clients do not render SVG and blockdata:URIs, so a non-http(s)logoUrlrenders no logo rather than a broken image.
Changed
Section titled “Changed”-
Discarding an edit is undoable. Discard and Clear sit beside Save, throw away everything typed since the record was opened, and until now did it permanently: the reset replaced the form’s undo history with a single entry, so the ⌘Z that would have brought the edit back had nothing to step into. The identity bar’s version does not even stop to ask — one click, one lost form. Both now go through the history rather than around it, and both raise a confirmation carrying an Undo, which is the only place the way back can be offered: the form has no undo button, only a shortcut nobody has a reason to guess at.
What is stepped back into is the edit, not just its values. The entry the reset leaves behind carries the touched map as well, because the draft backup is extracted through that map — restore the values alone and the record comes back looking pristine to everything that asks, including the backup that is supposed to survive a reload. The step also re-publishes the form’s version, so a field holding state of its own — a markdown editor, which re-seeds only when that moves — comes back with the rest instead of staying cleared over a value that has already returned.
Ordinary undo is untouched: stepping back over a keystroke deliberately does not re-seed every field, which is why the two are distinguished at all. A reset the form performs on its own — after a save, on a new record — still clears the history, since there is nothing behind it worth returning to.
-
The form’s metadata rail widens a little where there is room for it. 304px to 336px past
@7xlof form width — the same container signal the content column already widens on, so a side panel inside a large window keeps the narrow rail rather than taking a viewport breakpoint’s word for it. 304 was picked against the narrow end, where the extra 32px went to the gap beside a chip; on a full-screen form it comes out of the gutters instead, and the status select and date picker in there are the same controls as in the column. -
Realtime is a runtime surface now, and the roles that serve no websockets no longer pay for it. It was neither a surface nor role-aware, so every role ran it — including
functionsandworker, whose entire claim is that they touch nothing. Both mounted a websocket server no client could reach, both held a dedicatedLISTENconnection outside the pool for the life of the process, and both installed the change-capture machinery at boot: a schema, a trigger function, and aDROP/CREATE TRIGGERpair per collection table.That last part contradicted the invariant the runtime otherwise refuses to boot without.
REBASE_ROLE=functionsandREBASE_ROLE=workerare rejected unlessREBASE_MIGRATE_ON_BOOT=none, on the grounds that exactly one process owns schema DDL — and then the driver ran schema DDL from all of them anyway, from a code path that never asked the role. Nothing was corrupted (each statement is idempotent, and the multi-statement string is atomic), but every rollout took anACCESS EXCLUSIVElock per table per pod for no reason.Writes made by those processes are still heard. Capture is database triggers, so a change is published by the database rather than by whichever process made it: a function that writes a row still wakes every subscriber on the
api.The driver is told two things separately — whether this process consumes change events, and whether it owns the DDL — because they genuinely come apart. An
apibehind an external migration Job subscribes without provisioning. -
A bundle’s dependencies are installed once, by
rebase build, not on every pod start. A managed pod’s bundle lives on an emptyDir, so it was re-fetched and re-installed on every start — an eviction, a node failure, an OOM, a runtime rollout — and that install is 35–55 seconds of a 40–60 second cold start. It is therefore the price of every unplanned restart a tenant suffers, not a startup detail. The pod side needed no change: the init container already skips installing whennode_modulesis present. Native code is never vendored (a compiled binary is only valid for the platform it was built for), and a failed install is never fatal — an unvendored bundle is what every project shipped before this existed. Nor is an incomplete one accepted: if the installed tree does not contain the database driver — which happens when the project declares it at a version no registry can serve, aworkspace:range in a monorepo — the tree is thrown away and nothing is vendored, because the init container skips installing whennode_modulesis present, so a partial tree does not start slowly, it does not start at all.--os=linux --cpu=x64is the load-bearing flag, and not because of native modules: the dangerous case is a pure-JS package whose real work lives in a platform-specific optional dependency, esbuild being the one everybody meets. -
A new logo and mark, everywhere the panel draws one.
RebaseLogo, the favicon it sets, the docs header, the site’s own icons and the example apps’. -
Semibold is the ceiling of the type ladder — no weight above 600.
h1/h2had already been walked back tofont-mediumwhen the site and the panel were reconciled, leaving the stat variant as the lastfont-boldand a comment announcing a “display tier” rule nothing implemented any more. The display end separates itself by size and tracking, not by weight: a 30px 700 beside a 30px 500 elsewhere in the same product reads as two type systems rather than one ladder, which is exactly what shipped — the marketing site at 500, the panel’s stat tiles at 700. Swept, because a ceiling nothing enforces is a preference: every hand-writtenfont-boldis nowfont-semibold, and thefont-black/font-extraboldabove it came down with it. -
A driver ahead of the runtime is reported too. Version skew was one-directional: a driver behind the runtime was named at boot, a driver ahead by a minor was silent — and that is the pairing a floating runtime range produces when the image lags the packages a project builds against, with half a feature present in the bundle and the other half missing from the harness, in a process reporting itself healthy. Patch leads stay silent on purpose: pinning one fix forward is deliberate, and warning about it trains people to ignore the line.
-
A select in a side panel opens where you can see it. The panel hands its descendants a portal host — itself — so their popups open inside the modal, where the focus and scroll locks let them be used at all. It also carried
will-change: transformfor the slide-in, and that (liketransform,filterorperspective) makes an element a containing block for itsposition: fixeddescendants. A select dropdown is fixed and positioned in viewport coordinates, so inside the panel those coordinates resolved against the panel instead and every list came out displaced by the panel’s own left offset — far enough, on a right-hand panel, to open past the edge of the screen. Open, correctly stacked, and nowhere anyone could see it, which is indistinguishable from a select that ignores clicks.Popovers, menus and date pickers in the same panel were unaffected: their positioning measures the offset parent and subtracts it. Only the select’s item-aligned placement does the arithmetic against the viewport itself, which is why one control looked broken while its neighbours did not.
-
A collection’s default sort survives its own mount.
admin.sorthad no effect on any collection view: rows arrived in the table’s natural order however the sort was written, while REST and the realtime socket both ordered correctly when asked directly — which is what made it look like a transport bug. It was not. The table controller subscribed twice, once with the sort read off the collection and then immediately again with noorderByat all, and the second answer replaced the first. The URL-sync effect mirrors the sort withhistory.replaceState, which react-router does not observe, souseLocation()kept reporting the search string the view mounted with — the empty one — and a re-render caused by nothing more exotic than a caller passingfixedFilteras an object literal parsed it and cleared a default no user had touched. An explicit?__sort=in the URL still outranks the collection’s default, and back/forward still syncs. -
A card no longer prints the row id above its own title. Every card in the grid led with a truncated uuid sitting over the product name;
isId: "uuid"is the default for a Rebase collection, so that line was noise on most of them, and at a card’s width it is too short to copy — which is the only thing an id on screen is good for. The card was the odd one out: a list row and a board card show an id only when nothing else names the record. That fallback is untouched, so a record with no readable name still gets its id.hideIdFromCollectionis not the lever for this and stays exactly what it was: the table reads the same flag for its ID column, where an id is genuinely useful. -
One signed-URL request per file, not one per thumbnail. A collection view draws one thumbnail per row and rows share images far more than not — 200 blog posts illustrated by 20 hero files. Each thumbnail minted its own download token on mount, and the URL cache is only written when a response lands, so it deduped nothing during the burst: 100+ requests for 20 distinct files, which spent the whole rate-limit budget on one page view and made every image on the screen fail together with a 429. The in-flight promise is now shared per cache key — 20 requests, all 200. Deliberately not a longer-lived cache: a signed URL is temporal, so a later mount refetches exactly as before and only the concurrent duplicates are removed.
-
The storage limiter counts a signed-in caller as signed in. A request to
/api/storage/*carrying a valid admin JWT came backx-ratelimit-limit: 300and shared anip:bucket with unauthenticated traffic — everyone behind one NAT together — where a signed-in caller should have had 1000 keyed by uid. The same token on/api/data/*reported 1000 correctly, which is what made it look like a quirk of the demo. The limiter reads the user off the context; on the storage router it is registered before the routes, and the JWT middlewares live inside them, so both the key and the limit fell through to their anonymous arms. It now derives the uid from the bearer token itself when the context has none, and uses it for bucketing only — pre-resolving the user into the context instead would change authorization, not just accounting, letting a Rebase-signed JWT satisfy a deployment that delegates auth to Firebase or Clerk. An unverifiable token buckets by IP exactly as before. -
rebase db backupworks on a generated scaffold.rebase initwrote aDATABASE_URLwhoseoptionsvalue carried a literal=(?options=-c%20search_path=public&sslmode=disable). libpq splits a URI query parameter on the first=and rejects any further one, so every libpq caller failed on a fresh project —pg_dump/pg_restorebehinddb backup|restore, and a plainpsql "$DATABASE_URL"copied out of the generated.env. It shipped because node-postgres parses URLs itself and accepts the literal form, sorebase devandrebase db pushworked and nothing exercised the URL; the--database-urlbranch had always encoded it, and no test compared the two. Fixed ininit,.env.example, both compose templates and the deployment skill — the compose files on their own account, since a self-hosted stack’s backup cron failed the same way. A failedpg_dumpalso no longer leaves a 0-byte artifact behind, whichbackups listshowed as an ordinary backup and pruning ranked by timestamp alone, so the corpse held a protected slot while a real backup aged out under it.
Testing & CI
Section titled “Testing & CI”-
Type names claimed in prose are checked, not just the ones in code fences. Every doc verifier so far read fenced code —
check-api-namesgreps imports,typecheck-snippetscompiles the fences outright. A markdown table is neither, and a reference table is the shape nobody runs: it is where the agent skills had drifted furthest.check-prose-types.mjsreads backticked*Props/*Config/*Options/*Hooks/*Context/*Callbacksnames outside fences and requires that something inpackages/*/srcdeclares them.It found, and the sweep removed:
BackendHooks,UserHooks,DataHooksandBackendHookContext, taught across two skills together with ahooks.dataconfig block — none of the four types exists andRebaseBackendConfighas nohookskey at all, so an agent following it wrote configuration that type-errored or, in plain JavaScript, was silently ignored;AdminCollectionConfig, deleted on purpose and still the annotation one skill told agents to write;EntityOverrides, for a collection option no config type has; and six*Propsnames in the component-override table, in all six locales, for an override map that is not typed per key at all.The suffix filter is the whole design. A bare capitalised word in backticks is as likely to be a product name, an HTTP verb or a column type as an identifier;
SomethingConfigis a claim about this repository’s types nearly every time. That is what makes it precise enough to be blocking rather than a backlog. -
The documented CLI is checked against the CLI.
check-doc-commandshad globs for the agent skills, the example READMEs and the repository’s own agent instructions — and never forwebsite/src/content/docs/, the published documentation. Two commands lived in that gap for as long as the pages have existed:rebase db studiohad a section of its own in both the CLI reference and the schema page, andrebase auth create-userwas the first line of the auth example. Six locales each, because the translations are generated from English and inherit whatever it says. Neither command has ever existed; both exit 1. Pointing the existing check at the docs needed no new parser, only the glob nobody had added.CHANGELOG.mdis exempt — a changelog records what was true. -
A first-party GitHub URL must name the repository the package declares.
rebase-agent-skills/README.mdoffered six ways to install and five routed throughgithub.com/rebaseco/agent-skills, a standalone mirror that does not exist:npx skills add,gemini extensions install,claude plugin marketplace addand agit cloneall answered 404, and both plugin manifests advertised the same address as theirhomepageandrepository. The one path that worked,rebase skills install, was Option 1 and the only one needing no repository at all. Checking that a URL resolves would need the network, which a gate must not; checking that it names the repositorypackage.jsondeclares needs nothing, and is what actually went wrong. Only first-party-looking URLs are checked — an owner within an edit or two of ours, or a repo named after this bundle — so a skill linkingnvm-sh/nvmis untouched. -
The release stamps the Helm chart along with the packages. Neither
scripts/release.shnor the stable publish workflow touchedcharts/rebase/Chart.yaml, so every release moved@rebasepro/serverand left the chart on the previous number — andappVersionis the default image tag, sohelm installwith noimage.tagrendered a version behind the one just published.check:runtime-imagecaught it, but only after the fact, on the next run. Both paths bump it now, beside the package bump, and refuse the release if neither field matched. -
The Helm chart is checked. It shipped with no coverage of any kind: no lint, no
helm template, nothing in CI — and its failure mode is a cluster that comes up looking right.pnpm run check:chartlints it, renders the five topologies it documents, and reads the decisions back out of the manifests: the roles, who provisions, that the worker gets no Service, that/api/functionsreaches the functions unit in one hop through the ingress rather than two through the api’s proxy, that a static app takes its own image and carries no Secret. It then extracts everyfailfrom_validate.tpland requires a case that reaches it, so a refusal added later fails the check until it is covered. -
The chart’s default image tag is held to the runtime’s version.
appVersionis the default tag —helm installwith noimage.tagrenders it — so the chart’s own documented minimum viable install is an image reference made to a user. It had drifted to0.15.0against a0.14.1runtime, which renders a tag nothing has built and lands inImagePullBackOff.check:runtime-imagenow treats the chart as the user-facing reference it is, hermetically against@rebasepro/server’s version and, under--live, against the registry. -
A third adapter wrapper is held to the capability list.
createPostgresAdapterrebuilds the bootstrapper field by field, exactly as the two wrappers in the runtime do, and nothing was holding it to anything. It silently dropped both new schema-stamp hooks: every layer type-checked, nothing threw, and the runtime did what it does with any missing optional capability — skipped — so the stamp was never written on any real boot. A check that never runs is indistinguishable from a check that passes.packages/server-postgres/test/adapter-forwarding.test.tscompares the adapter against the bootstrapper’s own key set, so the next capability is covered without anyone remembering to list it.
[0.15.0] - 2026-08-17
Section titled “[0.15.0] - 2026-08-17”-
A filter can reach through a relation to a column of the related row.
where: { "applications.status": ["in", ["applied", "reviewing"]] }— “has a related row whose column satisfies this”, which is the form every queue screen is written in and which previously could not be said at all. Relation filters compared the related row’s id and nothing else, so the only way to ask the question was to fetch every row and filter in the browser: a filter the client applies after paging is not a filter, because the page was already chosen without it.Compiled to the correlated
EXISTSthe question already was, with the predicate moved off the target’s id and onto one of its columns. A many-to-many reaches one table further than the id filter does — that one stops at the junction, which already holds the value it compares — so its subquery joins the target to the junction inside theEXISTS, where it cannot multiply the outer rows.belongsTois included:author.nameis a column of another table either way.Every operator works, because the compared value is an ordinary column:
>=on a date andilikeon a name mean here what they mean anywhere else. The negative operators keep the rule the id filter already had, for the same reason —!=isNOT EXISTSof the positive predicate, neverEXISTSof a negated one.EXISTS (… AND status != 'hired')asks “does some application differ from hired”, which is true of nearly every candidate with more than one application and answers nothing anybody asked;NOT EXISTS (… AND status = 'hired')asks “is there no hired application”, and makes==and!=partition the rows the way a filter implies they do.is-nullandis-not-nullare deliberately not a complementary pair on a relation column. They mean “has a related row whose column is unset” and “has one where it is set” — both true of a candidate with two applications, one of each. Making the second the negation of the first would make it “no application has an unset status”, which is true of a candidate with no applications at all: the very rows a queue exists to exclude.A relation that does not exist, or a column the target does not have, is a 400 naming the target’s real columns — never a dropped condition, which would widen the read to every row.
-
A sort key can be an aggregate over a to-many relation.
orderBy: [[{ relation: "applications", field: "created_at", agg: "min" }, "asc"]]— candidates, longest-waiting first.countalone answers the other half of the queue family: clients, busiest first.min,max,count,sum,avg.This is the half that could not be worked around. A relation filter can be approximated by denormalising a flag onto the row — a trigger, a backfill, and a promise to keep it correct on every write to the related table. An ordering cannot be approximated at all once the result set is paged, because the client only ever holds one page and the page was chosen by the wrong order. It is why a project ends up with a 600-line custom view beside the collection it is about: not because the rendering needed customising, but because the query could not be expressed.
Compiled to a correlated scalar subquery in
ORDER BYrather than aLEFT JOIN LATERAL, because the same expression has to serve the keyset comparison behind cursor paging — and if the two are not the same expression, paging and ordering disagree and rows are skipped. Cursor paging works: there is no aggregate stored on the cursor row to compare against, so the driver recomputes the cursor row’s value in SQL from the id it does have, as a subquery pinned to that id. Pinned rather than correlated, so Postgres evaluates it once for the statement rather than per row.Rows the relation reaches nothing from land at a defined end —
NULLS LASTascending,NULLS FIRSTdescending. That was already Postgres’s default and is now written out in theORDER BY, becausebuildKeysetComparisonencodes the same placement and an invariant two functions depend on should be stated in both rather than assumed in one.countof nothing is0, not null, so those rows sort as zero. The id stays the last key, so the order is total and paging over it neither repeats nor skips.The object form is the authoring surface; on the wire the key is a single string,
min(applications.created_at).OrderByTupleis[string, direction], the REST parameter is?orderBy=key:direction, the driver contract takesorderBy?: string | OrderByTuple[], and a cursor names its keys by string —_scoreestablished the same pattern, and this reuses it rather than widening five signatures to carry an object that would be flattened at the end anyway.normalizeOrderByis where the two spellings collapse into one.Both features are declared as capabilities —
supportsRelationFieldFiltersandrelationAggregateSorts— and both default to false for an unclaimed driver. Firestore and MongoDB declare neither. A wrongly assumed filter capability widens a read to every row; a wrongly assumed sort capability answers 200 with rows in whatever order the database pleased, which reads as a sorted list.The offline overlay refuses both rather than answering them wrongly. A dotted filter key resolves to
undefinedon every cached row, which would exclude all of them — a 200 with an empty list, indistinguishable from “nothing matched”. An aggregate is not a field on the row either, so every cached row readsundefinedfor it, which the sortability check would have read as a column of nulls and called reproducible before handing back rows in id order.
Changed
Section titled “Changed”-
The
collection.insightsslot is nowcollection.widgets, andhome.card.insightishome.card.widget. The old names described one plugin’s use of the slot rather than the slot, which is any widget strip above the table or on a home card. The prop types follow:CollectionInsightsSlotProps→CollectionWidgetsSlotProps,HomeCardInsightSlotProps→HomeCardWidgetSlotProps.A contribution registered under an old name is redirected to the new one and still renders, with a one-time console warning naming the replacement. Slot names are matched by string equality, so a plain rename would have left every plugin still on the old name compiling, registering, and rendering nothing — the same silent nothing
UNRENDERED_SLOTSexists to warn about. The old names are retired, not removed, and will go in a future major version. -
AdditionalFieldDelegatesays that it is display-only.value()is async and receives the wholeRebaseContext, so it can read another collection and its result is cached per record — which makes it read like a computed column when it is not. It runs in the browser, once per row, after the page has already been fetched and ordered, so its result can never take part in choosing which rows came back or in what order. The doc comment now says so, and points at the two things that can: an aggregate sort or a relation filter for a value derived from a relation, and a real column for anything else. -
The type ladder spans three weights, and a card’s edge is a hairline. Two visual changes to
@rebasepro/uithat every app built on it inherits.h1andh2go to 700. The ladder capped at 600, so a page title and the section heading inside it were the same voice at two sizes and a screen had no clear first thing to read. UI chrome — nav, labels, buttons, table headers — stays at 600, andh4steps up to semibold because at 20px medium sits close enough to body copy that a long page reads as one undifferentiated column. This costs nothing on the wire: both faces already load as variable fonts, so the whole weight axis ships whether or not it is used. The rule it replaces was written when static weights meant every step was another download.cardMixindrawssurface-700/60instead of a solid edge, and rounds torounded-xl. The softer border is not a new opinion — it is the onedefaultBorderMixinhas always carried, and the SaaS console alone was overriding the card’s own border to reach it at 53 call sites. The component was wrong and every caller knew.paperMixinis deliberately unchanged: a menu, dialog or popover sits over unknown content and needs a definite edge, where a card in the document flow does not. Page surfaces get a hairline; floating surfaces keep theirs. -
Three type tiers the product kept improvising, and an inset surface for code.
typography-leadis the sentence under a page title, which had been borrowing 12pxbody2— so the one line explaining what a page is for was smaller than that page’s own table rows.typography-microis the uppercase field label above a value: the single sanctioned tier belowtext-xs, and it earns the exception by never carrying a sentence.typography-monocarriestabular-nums, because proportional digits make a column of measurements ragged and a live counter jitter as its glyphs change width. All three areTypographyvariants, not new components.codeSurfaceMixinfixes a surface that had been inverted: code blocks sat onsurface-800(#111) insidesurface-900(#0a0a0a) cards — lighter than the thing containing them, so every inset well read as raised. It issurface-950now, which is what “recessed into the card” actually looks like.
-
Chip ink was written down instead of measured, and 63 of 120 hue/tone/mode pairs were below WCAG AA. The worst was white on
teal.solidat 1.76:1, which is not a near miss — it is unreadable. Two causes, and the larger hid behind the smaller:"#fff"was hardcoded as the ink on everysolidbackground, and this palette is Airtable-shaped, so its bright mid stops want dark ink — 14 of 15 hues were wrong. The other 8 came fromonDeep(defaulting topale) on everydeepbackground.The ink is derived now. It walks a hue-tinted starting colour toward black or white only as far as it must to clear the floor on the background it will actually sit on, and takes whichever direction has more headroom. No palette stop moved, so chips keep their colours; only the ink did. Starting from each hue’s own tints rather than flat
#000/#fffkeeps the family looking related —blue.solidgets a dark navy, not black.The part that made this more than a colour tweak: an
outlinedchip drops its fill and sits on the page, but the component reused the filled ink for it. One value was being asked to be legible against two different surfaces, and for most hues it cannot be — so flipping the filled ink to dark would have made every outlined chip in dark mode invisible.outlineText/darkOutlineTextare separate now, measured against the real page backgrounds.Because it is derived rather than tabulated, a hue added later cannot land below AA: there is no per-tone ink left to forget to check. Asserted for every scheme, filled and outlined, in both modes.
-
The accent was below AA as text on a dark card.
#0070F4is tuned as a fill — white on it, it on white. Read as type on asurface-900card it measures 4.36:1, and every accent link in the product sits on exactly that surface. Dark mode usesprimary-lightfor accent text now (7.34:1), which is the same hue lifted in lightness and indistinguishable as “the blue”. Fills are untouched; there the contrast question runs the other way and#0070F4was already right. -
A refused Google sign-in left the login screen silent. Every provider button failed into nothing: a popup the visitor closed, a redirect whose
statedid not match, a Google script that never loaded — none of that reaches the auth controller, which can only record what it is handed, so the screen rendered no error and the button simply appeared dead.LoginViewkeeps its own error for the half of the flow that happens before a code reaches the controller, and renders the controller’s for the half after — cleared once a user is present, so a stale failure cannot sit over a screen that has since succeeded.Backing out is not a failure.
access_denied, a closed popup andimmediate_failedare answers, and showing them in red reads as a broken login, so they are swallowed rather than reported.Server side, “Registration is disabled” was a non-sequitur on this path — nobody pressed Create account, they pressed Sign in with Google, and there is no account behind that identity. All three rejection points now say both halves, since the visitor can see neither. The public demo was doing exactly this to every visitor:
--set-env-varsreplaces the whole env block on each deploy and the server defaultsALLOW_REGISTRATIONto false when it is absent, so the demo advertised “Sign in with Google” and then 403’d the account behind it. -
Every relation in a project whose collections import each other was reported as broken, by the two commands that load collections from source.
rebase generate-sdkandrebase buildreadconfig/collectionsthrough jiti, which transpiles ES modules to CommonJS. A CommonJS cycle hands the module entered second the namespace object —{ __esModule: true, default: … }— and never replaces it with a live binding, so atarget: () => otherCollectionthunk returned the namespace rather than the collection. Resolution saw an object with noslugand refused it.The measured cost, on a 63-collection project introspected from an existing database: 58 relations rejected, one warning each, and a generated SDK in which every relation field and every derived foreign-key column was silently missing.
customerIdandcustomerwere simply absent from the row type. Nothing failed — the command exited 0 and wrote a file that looked complete, which is the failure mode you find out about from the compiler months later.The value was never lost. The thunk is lazy, so by the time it runs the exporting module has finished and the collection is sitting one level down in
default. Resolution now takes it — and only when the inner value is itself a collection, because adefaultthat is not one is a genuinely wrong thunk and has to keep reaching the error.ResolvedRelation.targetis normalised rather than passed through as written, which is the half that decides whether this is a fix or a patch over one symptom. Resolution reads the target once, to derivetargetSlugand a join table; the forty-odd callers that matter read it later —PostgresBackendDriverbuilding a join, the Drizzle and DDL generators,RelationWriteService, the doctor, the admin’s relation fields and table cells. Handing those the thunk as authored would have left every one of them holding the namespace, so the generated SDK would have come out right while the server that serves it stayed broken. Measured on the same project: 134 resolved relations, 0 still answering with a namespace.What made this hard to place is that the warning blamed the author — “make sure the target is
() => otherCollectionand not evaluated at module load” — for something the rejected code already did. Cycles between collection files are not an authoring mistake to be designed out: two collections that point at each other must import each other, and the lazy thunk is this framework’s own answer to that. Native ESM resolves those thunks correctly, which is why the same collections load, relate and serve perfectly under the dev server while the CLI called them broken.A thunk that returns a promise —
target: () => import("./other"), one keystroke away and the mistake the namespace shape resembles — now says so, rather than reporting “not a collection”. -
A write refused by a row-level-security policy answered 500, not 403. The client could not tell “you may not do this” from “the server is broken” — and a 500’s message is sanitized on the way out, so the reason went with it. An operator got paged for access control working correctly.
Only
INSERTwas affected, and for a mechanical reason: a refusedUPDATEorDELETEsimply matches no rows, which was already classified as403 WRITE_DENIED, while a refusedINSERTraises42501from a failedWITH CHECKand fell through to the unclassified path. All four spellings of the denial now answer the same status and the same code.42501carries two opposite problems and only the message separates them, so the driver now does too: a policy refusing the caller is a 403, while the connecting role lacking aGRANTstays a 500 — telling an operator “forbidden” for a missing privilege would send them hunting for a policy bug that does not exist. The message used to name both causes because it could not tell them apart; it now names whichever happened.
Changed
Section titled “Changed”-
The admin panel’s Logs view streams, instead of polling every three seconds. The old view re-fetched the whole window on a timer, which was wrong in three ways at once: an entry could sit up to three seconds before appearing, each client cost a request every three seconds to be told nothing had happened, and — because that request passed through the same middleware that fills the log buffer — the view’s own polling became the loudest thing in its own output. On a quiet server it was also what evicted real entries out of the ring.
GET /api/logs/streamis server-sent events, admin-only like the query beside it. The backlog and the live entries arrive on one connection: a client that fetched its history separately would race its own subscription, and entries logged between the two calls would belong to neither. Appends are batched over a 250ms window rather than sent per line, because a busy server logs faster than a browser can render and one frame per entry would cost a re-render per request served — worse than the poll it replaces, precisely when the logs are worth watching.The view says which it is doing. “Live” and “Polling” are not cosmetic: an empty log is ambiguous — quiet server, or a tail that died — and the studio and the server are versioned separately, so a frontend that knows this route will meet servers that do not. A 404 there is an older backend, not an error, and it degrades to the three-second poll rather than showing an empty view.
A connection holds a bounded number of entries between flushes, so a burst past roughly eight thousand a second leaves a gap — and says so, with a count, rather than presenting a tail with a hole in it as complete.
Fixed in the same work: a client that disconnected during the opening write — a fast navigation, or a reconnect storm against a restarting server — leaked its subscriber and a repeating timer, per attempt, for the life of the process. A listener added to an already-aborted
AbortSignalis never called, so the handler had no way to learn the reader had gone.
[0.14.1] - 2026-08-16
Section titled “[0.14.1] - 2026-08-16”-
Access tokens can be signed asymmetrically, and the public keys are published. A shared secret cannot do the one thing verification most needs to be: cheap to delegate. Handing a gateway, an edge worker or a neighbouring service the means to check a session also hands it the means to mint one, so in practice the check moves back to the server that owns the secret — and because rotating that secret invalidates every token at once, it is never rotated.
auth.signingKeystakes PEM private keys. Access tokens are signed by the active one, carry itskid, and verify against the matching public half, which is served unauthenticated at/.well-known/jwks.json— the URL every verifier already looks for.Additive by construction: without keys nothing changes, tokens stay HS256, and the JWKS answers an empty key set rather than a 404, because “this issuer publishes no public keys” is a fact a verifier can act on where a 404 is indistinguishable from a wrong URL. Turning it on signs nobody out, and neither does rotation — list the new key first, keep the old one until the tokens it signed expire, then drop it. Only private keys are configured, so a mismatched pair cannot be expressed; malformed keys, a duplicate
kid, an algorithm the key cannot sign and an EC curve other than P-256 all fail at boot rather than at the first login.JWT_SECRETstays required regardless: download, MFA-pending and password-reset tokens are read only by the server that minted them. -
A durable job queue, and webhook deliveries that survive a restart.
webhook-service.tssaid it plainly in its own docblock — the queue was in-process and in-memory, so a crash or a deploy between the enqueue and the delivery dropped the event. Nothing recorded that the event had existed, which makes the failure mode silence: the receiver simply never hears about a row that was definitely written.A job is now a row in
rebase.jobs. Workers claim withSELECT … FOR UPDATE SKIP LOCKED, so each job goes to exactly one worker and N instances divide the work with nothing elected leader.rebase.jobs.enqueue(task, payload)is the public door;jobs: { enabled: true, tasks: { … } }registers the handlers.The decisions worth knowing:
attemptsincrements on claim, not on failure, so a job that kills the process cannot retry forever, once per restart. A worker that dies cannot release its own claim, so jobs held pastvisibilityTimeoutMsreturn topendingor dead-letter with an error that says which. An unknown task is returned to the queue rather than failed, because during a rolling deploy the old instance is handed jobs belonging to the new one. Failed jobs are kept for 30 days — a queue that silently drops what it could not deliver looks exactly like one with nothing to do.idempotencyKeyis unique over unfinished work only, or “the nightly digest for user 7” would be sendable once, ever. -
Aggregates:
count,sum,avg,min,max, optionally grouped. The query API could return rows and a total and nothing else, so every dashboard question — revenue by status, orders per day — meant hand-written SQL in a custom function, or fetching the rows and reducing them in the client. The second is wrong at any size that matters and silently wrong under alimit: the numbers look plausible and describe the first page.GET /data/:slug/aggregate?select=count(),sum(total)&groupBy=status, taking the same filters,or/andgroups andsearchStringas the listing beside it.RLS applies to the rows being aggregated — an aggregate is an efficient way to learn about rows you cannot select, so it runs through the request-scoped driver like every other read, and a caller whose policies return nothing counts nothing. Aliases are derived rather than accepted (
sum(total)issum_total), because a caller-chosen alias would have to be checked against thegroupByfields.count/sum/avgare parsed to numbers here, since Postgres returns bigint and numeric as strings. A driver without aggregate support answers 501, not an empty list: “no matches” is the wrong thing for a dashboard to conclude from “not supported”. -
Filter inside a jsonb column by path. Rebase has had jsonb columns for as long as it has had columns and no way to ask a question about what is in one — a filter could compare the whole document and nothing else, so “orders whose metadata says the country is US” meant a custom function or reading the table into the application.
metadata->>countryandmetadata->address->>citynow compile to the extraction they look like. The syntax is Postgres’s own and PostgREST’s, so the filter reads the same as the SQL it becomes.The path is bound, never interpolated — it arrives from a query string, and
->>takes a text parameter perfectly well. The filter value picks the comparison, because both obvious readings are wrong on their own: comparing as text puts"9"above"100", while casting unconditionally turns any row holding a string intoinvalid input syntax for type numeric— a 500 caused by one row’s data on a request that is not wrong. An ordering operator given a number casts, guarded so non-numeric rows are excluded rather than fatal; everything else compares as text. A path into a column that is not json is a 400 rather than SQL Postgres rejects at execution time. -
One bundle can run as several cooperating processes.
REBASE_ROLE=api|functions|worker|alldecides what a runtime process serves and what it owns, so a custom function that pins the event loop can be given its own replica count, restarts and blast radius without its code moving anywhere. Same image, same bundle, same database — only the environment differs.allis the default and is byte-identical to the process this server has always booted, so no existing deployment changes.REBASE_FUNCTIONS_UPSTREAMlets theapirole forward/api/functions/*to the functions process, so a split deployment presents the identical URL surface and no client, SDK or API key notices.REBASE_FUNCTIONS_ONLY/REBASE_FUNCTIONS_EXCLUDEnarrow a process to named functions; a name the bundle does not contain fails the boot and the error lists the names it does have.Two combinations refuse to start rather than misbehave quietly: a non-
apirole left on the defaultREBASE_MIGRATE_ON_BOOT(several processes would race to provision one schema), and a variable set on a process that does not read it (it would do nothing at all, leaving a deployment that looks configured and is not). See Split processes for the compose topology, and for what splitting does not give you — shared rate limits, cross-instance channels and scale-to-zero are each called out. -
A sort is a list of keys, not one key.
orderByaccepts[["category", "asc"], ["created_at", "desc"]]wherever it accepted["created_at", "desc"], over the SDK, the REST parameter (?orderBy=[{"field":"category"},{"field":"created_at","direction":"desc"}]), a WebSocket subscription, and every driver. The second key decides between rows the first calls equal.On the fluent builder,
.orderBy()called twice now adds a tie-breaker instead of replacing the first key — the previous behaviour discarded the earlier call, which made a multi-column sort unexpressible. If you were relying on the second call to win, pass the one key you want.A bare field name with no direction reads as ascending everywhere. It used to mean DESC on Postgres and ASC on Mongo, so one call described two different queries depending on the database underneath.
-
The admin panel orders by more than one column. Shift-click a table header to add a column under the sort already there; the header shows each key’s rank so a two-arrow header says which one wins. The toolbar’s sort menu — now in the table view as well as list and cards — is where a key is re-ranked or removed without rebuilding the sort, and a multi-key sort survives a reload and a shared link.
Changed
Section titled “Changed”-
Collection tables are created on every boot path, not only the managed one.
ensureCollectionSchemaandensureCollectionPolicieswere called from exactly one place — the managed bundle boot. An app shipping its own image boots by callinginitializeRebaseBackenddirectly, never entered that path, and so had its collection tables created by nothing. It came up serving sign-in — auth bootstraps its own tables, which is what made this read as a data bug rather than a boot bug — and 500’d every/api/dataroute, with a green deploy and a healthy/health. Found on a tenant that had been in that state for weeks.Provisioning now lives in
initializeRebaseBackend, the one function both paths go through. If you run a custom image against a database whose tables you manage yourself, setREBASE_MIGRATE_ON_BOOT=none— the additive ensure will otherwise create anything the collections declare and the database lacks. It never drops, narrows or rewrites. -
A write over the WebSocket now meets the same validation as a write over HTTP.
assertKnownWriteFieldsandassertWriteValuesValidwere called from the REST generator and nowhere else, so the socketSAVEhandler took the client’s payload straight todriver.save:PATCH /api/data/users/1 { age: 999 } → 400, naming the rulews SAVE { path: "users", values: { age: 999 } } → writtenEverything else on the socket path was enforced — it authenticates, it scopes the delegate so RLS binds, and the driver still refuses a column the table does not have. What it skipped is the collection’s own
validationblock:min,max,matches,required, and the unknown-field check behindstrictWrites. A realtime write that has been storing values your rules reject will now be refused.
-
A policy compiler that quoted values and no identifiers.
policyToPostgresquoted the value side of every comparison and the identifier side of nothing, so a column whose name Postgres does not read back unchanged reachedCREATE POLICYas a bare word — and there are three ways that goes."createdAt"folds tocreatedat, the statement errors, and the collection keeps RLS on with no policy, which denies every row;columnNameis used verbatim andrebase schema introspectpopulates it from the live database, so this is what any camelCase table adopted from an existing project did.order,defaultandendare syntax errors mid-clause. Worst,user,current_user,session_userandcurrent_dateare valid bare expressions, so the policy compiled, applied, and was logged as applied — while comparing against the connected role or the wall clock instead of the column. -
A backslash in a policy clause was eaten before Postgres saw it. The Drizzle generator writes each compiled clause into a
.tsfile insidesql`…`with no escaping, and Drizzle’ssqltag reads the cooked template strings rather than.raw— so JavaScript consumed the escapes first. A rule writtenusing: "email ~ '^admin\\.user@corp\\.com$'"reached the database as^admin.user@corp.com$, where each\.matches any character. The DDL generator writes the same rule into a.sqlfile, where a backslash is just a backslash, so the two generators produced different policies from one rule — and the difference was always in the permissive direction.\d,\sand\wwent the same way. -
A vector search on a subcollection route was served as a plain listing. The subcollection routes parse
?vector_search=/?vector=through the sameparseQuerythe root list uses and then built their options without it, soGET /authors/1/posts?vector_search=embedding&vector=[…]came back 200 with rows ordered byid DESC, no_distance, and the threshold ignored — a silent downgrade the caller reads as “these are the nearest neighbours”. -
A vector-search threshold narrowed the rows but not the count.
countRawEntitiesforwardedfilter,logicalandsearchStringtodriver.countand droppedvectorSearch. Athresholdis a WHERE clause, not a hint, so a similarity-filtered listing was served narrowed rows beside the count of the unfiltered set — three rows withmeta.total: 25— and paging forward then handed back empty pages whilehasMorestayed true, until the offset walked past the inflated total. The standalone/countroute answered the same inflated number. -
?offset=became a cursor value on every non-Postgres driver. Two paths serveGET /api/data/<collection>:restFetchServicewhen the driver has one, andfetchRawCollectionfor everything else — mongo, firebase, anything a developer registers. The second passedString(offset)asstartAfter, which is a cursor row, and never passedoffsetat all. The caller got page one every time, with ametablock reporting the offset it had asked for. -
A subscription is a query, and two of its fields never arrived.
logicalandoffsetwere accepted at every type-checked boundary and then dropped, becauseCollectionSubscriptionConfigdid not declare them — the client sends both, the type has no slot for either, and the subscription re-fetches a different query than the one that was asked for. Anor(...)subscription ran with the group gone and was pushed every row the caller’s policies allow, rather than the rows it asked for. -
A subscription’s re-fetches raced, and the loser was delivered last. Every update a subscription delivers is a full re-fetch, and several things start one for the same subscription without coordinating: the initial fetch at subscribe time, the change stream or
NOTIFY, and the debounced refetch after a mutation. A fetch that started earlier could finish later, and the delivery replaces the subscriber’s whole list — so the subscriber went back to the state before the change and stayed there, silently, until something else touched the collection. Fixed on both the Postgres and the MongoDB paths. -
A LISTEN connection that failed after connecting was never closed. Both LISTEN clients build a client, connect it, issue
LISTEN, and only then assign the field the rest of the class cleans up. Anything before that assignment can throw, and when it did nothing knew the connection existed —stop()closed the field, the reconnect timer closed the field, and the field was still undefined. A persistent failure (a revoked LISTEN privilege, a pooler that refuses session state) leaked a backend every three seconds. -
A declared-but-empty
REBASE_FUNCTIONS_TIMEOUT_MSread as “no timeout”.Number("")is0, and zero is meaningful on this setting — it disables the ceiling on purpose. Both ways of producing an empty value are ordinary: a compose file withREBASE_FUNCTIONS_TIMEOUT_MS=${SOMETHING}and noSOMETHINGin the environment, and a.envline carrying the name and no value. Neither reads like a configuration change, nothing logged, and what it switched off is the only bound on how long code the framework did not write can hold a socket. -
PORT=0announcedhttp://localhost:0. The listen helper resolved with the port it asked for rather than the one it bound, so the ordinary “any free port” request wrote0into the boot banner, the dev port file and.rebase/state.json— pointing the CLI, MCP discovery and any health check at a port nothing listens on. -
Simultaneous boots abandoned their schema plan.
CREATE … IF NOT EXISTSreads the catalog and then writes to it as two steps, so instances starting together do collide — measured at 8 losses in 10 with five peers. The losing statement threw, and the throw abandoned every remaining action in the plan, so a replica that lost one race came up missing tables it had never attempted, with the boot log blaming the one statement that was harmless. The channel-presence and channel-history bootstraps had the same shape with aREVOKEas their tail: losing a create race there left the presence roster and every retained broadcast readable by any signed-in user. -
A double-clicked signup answered 500 instead of “email already registered”.
POST /auth/registerreads before it inserts, and both engines back that check with a unique index, so no deployment ever ends up with two accounts on one address. What was missing is what the loser is told: neithercreateUsermapped its driver’s duplicate-key error, so the second request raised a bare23505orE11000, reached the central handler as an unclassified failure, and came back as a sanitized 500. -
Enabling MFA on a Mongo backend answered a sanitized 500. The auth router mounts the MFA routes for every backend, so
POST /auth/mfa/enrollis live on MongoDB and landed in the repository’s stubs — six of which threw a bareError, which the central handler classifies as unhandled. The person turning on two-factor authentication was told “Internal Server Error” while the actual reason sat in the server log, and the operator got a support ticket about a fault that does not exist. -
POST /storage/folderdocumented abucketfield and never read it. The handler readpathandstorageIdand derived the bucket from the path prefix, soPOST /storage/folder { path: "reports", bucket: "media" }answered 201 and created the folder in the default bucket — the parameter accepted, ignored, and the call reported as success. -
The newsletter opt-in was offered only to people who typed a password. It sat on the credentials form, under the password field — the screen you reach after choosing email — so a visitor who signed in with Google was never shown the checkbox at all. It moves to the provider screen, beside the buttons that choose how to sign in and directly under the consent block a host passes as
topComponent; the two ticks are now spaced as one block of conditions rather than two unrelated asks. -
The OpenAPI spec never mentioned the count endpoint.
GET /data/{slug}/countis registered for every collection and appeared in no generated document — the word “count” was not in the generator at all. The spec is what a client generator can see, so an endpoint missing from it is an endpoint that client does not have, and this is the one a paginating UI needs to know how many pages there are. -
A local-first read disagreed with the server about tied rows. Every server-side sort ends on
id DESC, which is what makes the ordering total; the offline overlay re-sorted with an ascending id tiebreak, so two rows sharing a sort value came back from the cache in the opposite order to the network — andisLocallySortablereported that page as exactly reproducible while it was not. -
The Mongo driver’s sort was not a total order, and could not name the id at all. It emitted only the keys the caller gave, so two rows sharing a value were returned in whatever order the engine pleased — free to differ between two runs of the same query, which is what makes
offsetpaging repeat and skip rows. It now closes on_iddescending, as the Postgres driver has always done.orderBy: ["id", …]also named a field no document carries — rows leave the driver with_idrenamed toid, soidis the only name a caller has — and Mongo answered by ignoring the sort. It maps to_idnow. -
Six labels in the admin’s sort menu rendered as their own key names.
sort_then_by,sort_move_up,sort_move_down,sort_remove_key,sort_ascendingandsort_descendingwere referenced by the control and declared by none of the seven locale files, and i18next answers an unknown key with the key. All seven locales carry them now, along withsave_entity_before_subcollections, which had the same defect behind a?? "…"fallback that could never fire. A test now checks every literal key the panel renders against the catalogue. -
A list-view column header said “Sort by
” whichever state it was in , so on a descending column it promised a sort where the click removed one — and it said it in English regardless of the panel’s language. It now names the next action, the key’s rank, and the shift-click that adds a column rather than replacing the sort.
[0.14.0] - 2026-08-12
Section titled “[0.14.0] - 2026-08-12”Breaking
Section titled “Breaking”-
A
validation.matchespattern that will not compile is now fatal at boot, instead of silently deleting the rule.toPatternrebuilds theRegExpper request and answersundefinedwhen the pattern is malformed, and its caller readsif (pattern && !pattern.test(value))— so an unclosed bracket did not reject writes, it removed the constraint. Every value passed, for the lifetime of the deployment, while the author went on believing something guarded that column.The lenient runtime branch stays: refusing every write over a config typo blames the wrong party. What was missing was anyone telling the author.
validate-config.tsnow compiles everyvalidation.matchesat boot and refuses to start on one that does not, naming the pattern, the engine’s reason, and what it would have cost — the same way this repo already refuses to start on a relation that cannot resolve.This can stop a project that boots today. If you are carrying a malformed pattern, it has not been validating anything, and the error names it. A
RegExpliteral is unaffected — the engine compiled it where it was written. -
BREAKING: the API is camelCase throughout.
author_idis nowauthorId. The wire carried two naming conventions at once, and which one a field landed in was not inferable from outside.GET /api/data/usersanswereddisplayName,photoURL,createdAt;GET /api/data/posts, next to it, answeredauthor_id. Both were “the wire names”. These are also thewhereandorderBykeys and the keys the generated SDK types, so a developer moving between two collections had to know, per collection, which convention it had happened to land in.The rule was never stated because there wasn’t one. A field’s wire name is its property key, and
columnNamerenames only the column — that part is right and does not change. But two of the four sources of keys never had a property key to use, and both fell back to the column name:- a foreign key derived from a relation had no property of its own, so
belongsToonauthorserved theauthor_idcolumn under its own name; - introspection wrote the raw column name as the property key, with
columnNamerestating it beside it.
Both now derive a camelCase name and keep the column exactly as it was. The database does not change. Columns stay snake_case, because an unquoted Postgres identifier folds to lower case and a camelCase column is reachable only as
"authorId"forever — in hand-written SQL, in psql, in an RLS policy body, in a dump, and in every third-party tool that touches the database.\d postsstill showsauthor_id, no migration runs, andrebase doctorreports no drift.GET /api/data/posts → { "id": 1, "title": "Hello", "author_id": 3 }GET /api/data/posts → { "id": 1, "title": "Hello", "authorId": 3 }?where={"author_id":["==",3]} 400 UNKNOWN_FILTER_FIELD?where={"authorId":["==",3]}Who this breaks, and what to do:
- Everyone using the generated SDK: re-run
rebase generate-sdk.row.author_idstops compiling androw.authorIdstarts. This is the good case — the compiler names every call site for you. - Hand-written
whereandorderBykeys. A filter key that no longer resolves is a 400 withUNKNOWN_FILTER_FIELD, and the error lists the valid names. It fails closed on purpose: a dropped condition widens a result set, which is the one failure you do not want to be silent. - Raw
fetchconsumers, and anything reading a row by key.row.author_idis nowundefined. There is no compiler to find these; grep for the column names your relations derive. rebase schema introspectover an existing database no longer echoes column names on the wire. Acustomer_idcolumn is generated as acustomerIdproperty carryingcolumnName: "customer_id", and is served, filtered and sorted ascustomerId. This is the largest single change for a project that was introspected rather than authored, and re-running introspection is what produces the new collections. The column, the constraints and the policies are untouched.
No dual-key emission and no compatibility flag: serving both spellings would leave the two conventions in place permanently, which is the defect. The one thing that is not camel-cased is a name someone already chose — a property key you wrote is your key, whatever its shape, and a
columnNameyou set still names the column. - a foreign key derived from a relation had no property of its own, so
-
BREAKING: anonymous sign-in is opt-in.
POST /auth/anonymousanswers 403 until you setauth.allowAnonymous: true. Anonymous sign-in is registration that never asked: it inserts ausersrow and assignsdefaultRoleexactly asPOST /auth/registerdoes. But both anonymous routes were mounted unconditionally and consulted none of the registration gates, and no config key existed to turn them off.So a backend that had closed the door still handed out permanent accounts. With
allowRegistration: falseanddisableSelfRegistration: true— whose own docstring calls it a “hard kill switch: block self-registration outright” —POST /auth/registercorrectly answered 403, and two unauthenticated requests produced an email/password account anyway:POST /auth/anonymousfor the row and the session, thenPOST /auth/anonymous/linkto put credentials on it. The second was authenticated only by the token the first had just issued, and carried no rate limiter at all.auth: {allowRegistration: false,disableSelfRegistration: true,// Anonymous sessions are now a thing you ask for.allowAnonymous: true}Opt-in rather than opt-out, and this is the part that will cost an upgrade some downtime: a project relying on anonymous sign-in today stops working until it sets the key. Defaulting it to
truewould have preserved that at the cost of leaving the hole open for everyone who never learns the key exists, and the key did not exist before, so no deployment had yet made a choice. The 403 names the key it needs (ANONYMOUS_AUTH_DISABLED);ALLOW_ANONYMOUSis the env spelling.disableSelfRegistrationoverrides it — an account created without credentials is still an account created by the public.allowRegistrationdeliberately does not gate it: a public read-mostly app that wants anonymous sessions and no sign-up form is a real deployment, andallowAnonymous: truesays exactly that./auth/anonymous/linkis gated on the same predicate, so a session minted before the switch cannot finish the upgrade, and it gains the limiter it never had.GET /auth/configandgetCapabilities()now reportanonymousLogin, so a client can discover the state instead of finding out by calling.Still open, and not addressed here: nothing downstream reads
isAnonymous, so an anonymous user holds the samedefaultRoleas a registered one and no policy can say otherwise. That needsis_anonymousin the RLS-visible identity.
-
admin.display— one block for how a record presents itself. A record shows up as a heading, a card, a row, a board tile and a reference chip, and each of those needs to know which property is the title, which is the image, which is the status. That wasadmin.titlePropertyand a great deal of per-surface guessing: the detail view had grown its own copy of the title logic and the two had already drifted, so the same record could be headed one way in the list and another way when you opened it.displaynames the roles instead —title,subtitle,image,status,date,tags— and one resolver (entity-display.ts,useEntityDisplay, cached) answers for every surface: the table, list, board and card bindings, the preview slots, the form, the entity views anduseColumnsIds. The property paths are checked against your own properties the way the rest of theadminblock is, so a renamed field is a compile error rather than a column that quietly stops appearing.import { defineCollection } from "@rebasepro/cms-types";export default defineCollection({name: "Posts",slug: "posts",table: "posts",properties: { title: { name: "Title", type: "string" } },admin: { titleProperty: "title" }admin: { display: { title: "title" } }});admin.titlePropertystill works. It is deprecated, not removed: it shipped in 0.13.0 and is still read at runtime, withdisplay.titlewinning when both are set. Postgres introspection codegen emits the new block, and the collections docs and skill are updated in all six locales. -
The self-host runtime image is published by the release, not by remembering to. The scaffolded
docker-compose.ymlpresentsrebase build+docker compose upas the way to self-host and pinsREBASE_VERSIONto the released version, but nothing publishedrebasepro/serveron a release —cloudbuild-runtime.yamlhas had a Docker Hub push for months and runs only when someone typesgcloud builds submit. So the first command in the file a new project is handed ended atpull access denied for rebasepro/server, repository does not exist. The release workflow now builds and pushes it (amd64 + arm64) after npm and the tag, then verifies the tag is pullable from outside with no credentials.scripts/check-runtime-image.mjskeeps it honest: every image reference in a shipped compose file must have an automatically-triggered publisher, and a build config only a human can run does not count.verify-selfhost.mtscould never have caught this — its own header says what it leaves out, “a container and an image tag”. -
rebase skills install --agent all, for scripted and CI use. Without a TTY the command has to be told which agents to install for, because a scaffolded project ships a marker file for every one of them (.cursorrules,CLAUDE.md,.windsurfrules,AGENTS.md) and detection therefore has no signal — guessing would install four agents’ skills unasked. -
updateManyanddeleteMany, the counterpartscreateManynever had. An ETL job could insert 1000 rows in one transaction and then had to delete them one HTTP request at a time. The asymmetry was not a gap in one layer but in all of them — contract, driver, REST, SDK, offline queue and generated spec.Both shapes are the conservative reading rather than the inherited one.
updateManytakes{ id, data }entries, not flat rows carrying their own key: on a table keyed on askuor a composite key a flat row cannot say whether a column is the address or a value to write, so naming the address separately mirrors single-rowupdate(id, data)and leaves nothing to infer.deleteManytakes ids, not a filter — a filter-shaped bulk delete is a different and far more dangerous operation, whose failure mode is an omitted or mistyped condition emptying a table, and unlike an explicit list it cannot be reviewed at the call site. Read first, pass the ids you meant.The delete is served at
POST /<collection>/bulk/deleterather thanDELETE /<collection>/bulk. A DELETE body is the honest verb and the one request shape the HTTP ecosystem handles unreliably: bodies on DELETE are permitted but widely dropped by proxies, and several OpenAPI generators ignorerequestBodyon a DELETE operation, so a generated client would send the request with no ids at all. “Deletes nothing” is the good outcome of that bet.await client.data.products.updateMany([{ id: "sku-1", data: { price: 1200 } },{ id: "sku-2", data: { price: 900 } }]);const stale = await client.data.sessions.findAll({ where: { expires_at: ["<", cutoff] } });await client.data.sessions.deleteMany(stale.map(s => s.id as string));
Removed
Section titled “Removed”-
@rebasepro/client-postgresis gone. It was published on every release since theclient-postgresqlrename — 137 versions,lateston npm — and imported by nothing: no workspace package depended on it, no example, template, doc page or skill used it, and its own README’s Quick Start did not compile (<Rebase driver={…}>, a prop that does not exist). Its description was wrong too: not a direct PostgreSQL client and not PostgREST, but a WebSocket passthrough to the Rebase backend, which@rebasepro/clientalready is.It was also quietly broken.
fetchCollectionre-listed seven ofFetchCollectionProps’ twelve fields by hand and droppedoffsetandlogical, sofind()returned page one beside a correctly-narrowed total,hasMorenever went false, andfindAll()/iterate()returned page one N times, terminated cleanly, and reported a plausible row count — silent duplicate data. Four sibling methods had the same shape.Use
@rebasepro/clientwith adataSourcesentry; that is what the admin panel does and whatdocs/data-sources.mdhas always described. The published versions stay on npm and will be deprecated there — nothing is unpublished, so an existing lockfile keeps resolving.
-
A 200 the SDK could not parse was returned as an empty object.
request()keptbody = {}whenJSON.parsethrew. On an error status that is harmless, because the status is the answer; on a success it was the whole answer —find()resolved to{}rather than an array andgetOne()to an empty object, with nothing thrown. To a caller that reads as “no data”, not as “you are not talking to the API”.The case is ordinary: point
VITE_API_URLat the frontend’s own host and/api/data/postslands on the single-page-app fallback, which answers 200 withindex.html. So the misconfiguration the 404 branch spends four lines explaining reached callers in its most common form as an empty success, because an SPA fallback returns 200, not 404. A proxy error page does the same.A success whose body this client cannot read is now a
RebaseApiErrorwithINVALID_JSON_RESPONSE, quoting the first 120 characters —<!doctype html>identifies the sender faster than any wording could. A 200 with no body at all still resolves to{}: some endpoints legitimately answer that, and it is a different thing from an unreadable one. -
Introspected collections opted out of key checking.
rebase schema introspectopened every generated file withconst ordersCollection: PostgresCollectionConfig = {, and that annotation widenspropertiestoRecord<string, …>. Every key-shaped field in theadminblock is derived from those keys —propertiesOrder,listProperties,sort,display.title,fixedFilter— so annotated, they accept any string. Introspection was emitting apropertiesOrderarray that nothing checked: rename a column, re-introspect, and the stale key compiled silently and reordered nothing. Introspected keys are precisely the ones nobody typed and nobody remembers, so this was backwards.Generated collections now use
defineCollection, whichrebase inithas always written, and which keeps the keys literal. ApropertiesOrderentry naming no property is a compile error, and the compiler names the column the rename left behind.Which
defineCollectionis detected per run, from the package manifests above the output directory — the same path Node resolves a bare specifier along:Your project declares Generated collections use adminblock@rebasepro/cms-typesdefineCollectionfrom@rebasepro/cms-typesyes @rebasepro/common(a--headlessproject)defineCollectionfrom@rebasepro/commonno neither a PostgresCollectionConfigannotation, with a warningno The headless flavours emit no
adminblock, on the collection or on any property. That is a fix rather than a downgrade:@rebasepro/typesdeclares noadminfield at all — the augmentation in@rebasepro/cms-typesis what adds it — so the block introspection used to emit was a type error in every headless project it was ever written into. What is dropped is presentation (icon,propertiesOrder,multiline,readOnly) for a project with no panel to present it; nothing about the schema, the API or your data depends on it.@rebasepro/commonis a dependency of the headless config package now, which is what makes that branch reachable. A project scaffolded before this keeps the old annotation and is told what it is missing.One thing changed in the generated relations to make inference survive a real schema: the
targetthunk’s return type is written out,target: (): AnyCollectionConfig => authorsCollection. Without an explicit type on the const, a relation cycle —postsbelongs toauthors,authorshas manyposts, or a self-referencingemployees.reports_to— makes the inference circular and every file in the cycle fails to compile. -
Storage was the one router with no rate limiter, and the one where a request costs money.
createDataRateLimiterwas mounted on the data router and the functions router and nowhere else. Upload, download and the whole tus sequence were unbounded — and storage is the surface where a single HTTP request buys a metered third-party operation:PutObject,GetObjectand its egress bytes,ListObjectsV2.The download path was the worst of it. With
storagePublicRead: true— a documented, ordinary setting —readAuthMiddlewareresolves to a no-op, soGET /file/*was anonymous, unauthenticated and unlimited. One machine looping over a large public object is a fullGetObjectand a full egress charge per request, with no ceiling and no per-caller accounting; the bill arrives a month later.Storage now shares the same limiter and the same store as data and functions, so a caller has one budget across the product rather than one per router. It is registered after the API-key guard, so a key’s identity is on the context and requests bucket by caller rather than by IP. The request limiter is the floor, not the whole answer: storage’s cost profile is bytes rather than requests, and a bytes-per-window bound per bucket needs accounting this layer does not have yet.
-
rebase doctor --policiesreported a clean database with row level security switched off.ALTER TABLE posts DISABLE ROW LEVEL SECURITYleaves every row inpg_policiesuntouched, andpg_policieswas all the drift checker read. So every expected policy still matched on name, roles, command and clause presence, and doctor printed✓ RLS policies match your collectionsfor a table Postgres was applying no filter to at all. Requests run asrebase_user, which holds full DML — the table was wide open while the check certified it.Nothing else on the declared-collections path covered this either: the only reader of
relrowsecurityin the driver serves the introspection branch, i.e. only when there are no declared collections, and the re-enable runs only on the managed-runtime boot path. A self-hosted project’s nextdb pushwould have fixed it; until then, doctor said it was fine.Drift now reports
rlsDisabledfirst, because it subsumes every other finding on the same table — if RLS is off, the policies listed under it are not being applied. The same pass closes a second blind spot:mode: "restrictive"is a publicSecurityRulefield and the generator emitsAS RESTRICTIVE, but the DDL parser captured that group into a discarded slot andpg_policies.permissivewas never selected, so a restrictive rule stored as PERMISSIVE read clean — with its gate being ORed in rather than ANDed, which is the maximally permissive way for it to be wrong. Both are exact catalogue values, so neither can cry wolf; an unreadable value on either side is skipped rather than guessed. -
A client with generated types could not be passed to
<Rebase>.RebasePropswas generic overUSERand not over the database, so itsclientprop was pinned toRebaseClient<unknown>— andRebaseClient<unknown>is not a supertype ofRebaseClient<Database>. The untyped branch ofRebaseSdkDatais an index signature ([slug: string]: SDKCollectionClient), and no concrete instantiation satisfies it, becauseRebaseSdkData’s owncollectionmethod is not anSDKCollectionClient.So the typed SDK path — run codegen, get a
Database, build a typed client — ended at the provider that every panel is mounted inside, and reachingdata.productsthrough the prop handed backRecord<string, unknown>rather than the generated row.RebasePropsandRebasenow takeDB, inferred from the client and defaulting tounknown, so existing untyped callers are unaffected.wrapAsEntityDataasks forPick<RebaseSdkData, "collection">, which is all it ever used.Pinned by
packages/app/test/rebase_client_prop_types.type-test.ts— compile-time assertions, written as assignments rather than conditional types, because the first draft usedextendsand went on compiling with the bug restored. -
Retiring the pre-1.0
authschema could drop a helper still in use. The cleanup refuses to run while a policy callsauth.uid(), and that half is safe by construction — Postgres records a dependency for a policy that references a function, soDROP FUNCTIONrefuses on its own. The function half has none: aLANGUAGE sqlbody written as a string literal is never parsed at creation, so nothing is recorded,RESTRICThas nothing to refuse on, and the drop succeeds while callers still exist. They fail when a query reaches them rather than at boot.If you defined your own helper in the
authschema — anything callingauth.uid(),auth.roles()orauth.jwt()from its body — the schema is now kept, and the boot names the functions holding it so you can repoint them atrebase.uid(). Our own control plane is the case that found this: two org-membership helpers there, with eleven policies going through them, had nothing protecting them. -
A date in the future was described in the past tense. Seven hand-rolled relative-time formatters computed
now - thenand then tested only the positive side, so a timestamp ahead of now fell through to whichever branch came first: a post scheduled for next month read “Just now”, and one due this afternoon read “-1d ago” — a negative quantity, printed. These are dates a CMS holds constantly, and the two admin formatters render whatever property the collection points its date slot or date column at.formatRelativeTimein@rebasepro/utilsis now the one implementation: the distance isMath.abs, so no branch can see a negative number, and the tense comes from the sign rather than being assumed. It returnsnullpast a horizon the caller sets, so each site keeps its own absolute format and locale. The cloud CLI and studio’s cron and API-key views were already correct and are unchanged. -
Persisted UI state written by an older release bricked the view that read it.
JSON.parse(localStorage.getItem(key)!)has four ways to throw and, in auseStateinitializer, no way to recover from any of them: it throws during the first render, and the value that threw is still in storage on reload. The SQL editor read its open tabs and column widths exactly that way.The failure that matters is not corruption but age: storage holds whatever version last ran, nothing migrates it, and an older release that wrote an object where this one calls
.mapproduces valid JSON that survivesJSON.parseand fails one line later.readStoredJson/writeStoredJsoncover all four cases — storage that throws on access (Safari private browsing, SSR), text that is not JSON, JSON of a rejected shape, and asetItemover quota. Also fixed alongside: an empty stored tab list left no active tab, a stored tab with noidcould never be closed, and a non-numeric stored pane size laid the editor out atNaN. -
An
orderBywhose shape was wrong returned unsorted rows and a 200. The sort field has been schema-checked for a while, on the grounds that answering 200 with unsorted rows leaves the caller believing in an order that is not there. The parameter’s shape was not, and failed the same silent way one layer earlier: whateverJSON.parsereturned was assigned to an option the REST layer reads asorderBy[0].field. So?orderBy={"field":"name"}— an object rather than an array, and the most natural thing to reach for — dropped the ORDER BY and answered 200, as did5,true,nulland["name"]. A direction ofsidewayswas silently coerced to ascending.These are now a 400 with
INVALID_ORDER_BY, matching what the published OpenAPI parameter already documented and what?where=has always done with a malformed filter. Every shape that worked before still works. -
PORTwas parsed but never checked, soPORT=oopsstarted the dev server onNaN.resolveStartPortrange-checked the port file it writes itself, with a test naming every value it should refuse — and the environment variable one line above, the source a human or a platform actually sets, had neither the check nor a test. OneparsePortnow serves both; an unusablePORTwarns rather than being ignored in silence. -
A blank cell imported as the number zero. The importer mapped a string column to a number with a bare
Number(value).Number("")is0, so every empty cell in a number column arrived as a real zero — a price of nothing rather than a price nobody filled in — and anything unreadable arrived asNaN. Both are absent values and both are nownull, which is what the importer’s own validation exists to catch. -
An offline read ignored
orderByunless a locally-created row was in it. The overlay sorted only when it had just injected a local row; every other read handed back cache order, which is insertion order. So a caller that asked fororderBygot whatever the store happened to hold — in the panel, a collection’ssortsilently dropped on every list served from the offline overlay, while the query carried it and the server honoured it. Order is part of the query, not a detail of how the rows were obtained. -
A group icon took the icons off every row beneath it. Declaring
iconon aNavigationGroupMappingdid not just label the group header — it switched the whole group to a categorised treatment, stripping the entries of their own icons and indenting them, and stepping the header’s own size and contrast up to match. That made a per-app styling choice into framework behaviour: any project that labelled a group lost the icons on its rows, with no way to turn it off.The icon decorates the header and stops there.
indentedsurvives onDrawerNavigationItemfor an app that wants the categorised look, and nothing in the framework sets it.DrawerNavigationItemandDrawerNavigationGroupare exported now, with their props types, since overridingShell.DrawerNavigation*is how an app opts in — wrapping the stock row beats reimplementing one and drifting from the framework’s hover, active and tooltip behaviour on the next release. -
The add button kept its English verb in every locale. The label was built as
Add {name}in JSX, so only the collection name came from config: a Spanish panel read “Add Mensaje de contacto”. Anadd_specific: "Add {{name}}"key already existed in all seven locales and nothing used it; both add buttons go through it now. The Hindi entry had translated the key rather than the label. -
A save raised two panel navigations, and the second one decided where you ended up.
SidePanelBinding.onUpdatereached three navigation-capable calls for one save — the opener’sprops.onUpdate, then areplaceonto the saved record’s address orcloseEditView(), thencloseAfterSave(). Against a data router the last call wins, so which of them the user got was settled by statement order across two files that were not written as a pair.“Save and close” on an existing record worked, because the close happened to be last. The reference picker’s “add new” is the same three in the other order — the picker’s own
onUpdatecloses the panel and thestatus !== "existing"branch replaced it afterwards — so the close lost, the new entity’s panel stayed open, and thereplacelanded in the picker’s slot and destroyed it.It raises exactly one panel navigation now.
closeOnSaveis honoured (declared, documented, passed astrueby the picker, and read by nothing until now, which is the behaviour that flow was failing to get by hand); closing beats replacing, because moving a panel to an address it is about to leave only fights the close; andprops.onUpdateruns last so the opener’s intent is the final word. Reordering alone would have left the other half standing —close()pops the top panel and areplace()after it writes into the slot below — so the fix removes the pairing rather than sequencing it. Written up as class 28 indocs/bug-classes.md. -
One column the table could not describe took the whole table with it.
propertiesToColumnsruns inside the memo that builds a collection table’s columns, so anything it threw took the header, the rows and the empty state together: a blank pane with no error, no empty state and no data request to attribute it to. Three ways in, all reached by walking a property map with a hole in it —getColumnKeysForPropertyread.typeoff an undefined map child,getResolvedPropertyInPathread.typeoff a missing path root, andpropertiesToColumnsthrew outright when a key resolved to no property. A column that cannot be resolved is one column the table cannot offer, not a dead table, so it is named in a warning and the rest carry on — which is the choicegetSortablePropertyOptionshad already made twelve lines below, with a comment saying so. -
One live subscriber counted for every other subscriber sharing its query. A live subscription re-counts on every push so its reported total stays honest, and
listenCollectiondeliberately collapses identical queries onto a single socket subscription while keeping one callback per subscriber — each of which ran its own count. Onecollection_updatefanned out into one identical HTTP count per subscriber.Not hypothetical volume: every relation cell in a table mounts a selector that subscribes to the target collection before its dropdown is ever opened, so one message produced one count per visible cell — measured at 11 requests to
/api/data/customers/countfor an 11-row page of orders, and 66 on a wider table, all asking the same question. A count is a property of the query, not of the caller, so concurrent callers share the request; the entry is dropped as soon as it settles, which merges concurrent calls and never serves a cached total. Measured after: 11 requests to 1. -
A relation column arrived as an id and the preview called it the wrong type. The REST layer returns a relation column as the foreign key it is — a flat scalar — and only some fetch paths hydrate it into an object, so which form a preview saw depended on how the row was loaded. The preview accepted only one:
normalizeToEntityRelationreturned null for anything that was not an object andPropertyPreviewread that null as a type error rather than as “not fetched yet”, giving a red “Unexpected value” box per row wherever a relation sat inpreviewProperties, and a silently blank column elsewhere.ArrayOfRelationsPreviewdropped such elements without a word.The property already knows the answer — it declares the target it points at — so the id is resolved against it.
getRelationTargetPathreads the target from either form that carries one, the stampedresolvedRelationor the inlinerelation, which is all a preview can reach while holding a property and a value and no collection. The only lever an app had here was to keep relations out ofpreviewProperties, which also decides the row title in list view: a choice between an error box and rows that cannot say what they are about. -
Changing a record’s layout met the edit as a stale draft. The split’s “hide list”, full screen’s “show list” and the side panel’s “open full screen” all replace one mounted form with another showing the same record. Only the side panel handed its edit over; the other two left it to the local-changes backup — the channel for a draft left behind by a closed tab — so the record reopened clean under an “unsaved local changes” banner offering to apply changes the user had made a second earlier and never walked away from.
Every control that changes layout calls one
carryEditnow, and it carries only what was touched here; the side panel used to carry the whole record, which marked every field touched in the receiving form. The stale draft that banner exists to ask about was meanwhile being applied without it: the handoff map was hydrated fromsessionStorageat startup, so after a reload every persisted draft looked like a handoff and the first visit to the record opened silently dirty carrying it. The handoff is in-session only now, and consumed on pickup. -
The list row’s responsive columns measured a ref attached to nothing. The trimming logic existed, but
containerRefwas never put on an element — the component returned<ListView>directly — so the ResizeObserver never fired andcontainerWidthstayed at its 1200 seed forever. That seed resolves to exactly three extra columns at every width, which is why a split panel’s list showed the same row as a full-window one and truncated the title to make room for them.The ref sits on a wrapper and the width is seeded from
getBoundingClientRecton mount, so unmeasured means “title only” rather than “assume 1200”. The title takes a comfortable 320px before columns bid at all, and each column is charged its own rendered width rather than a flat 160 — a relation no longer costs what a date costs. The first column that cannot be afforded ends the row, so columns drop right to left and never reorder while dragging the splitter. -
The split view’s record panel had no close button. The only way out of an open record was Escape, or noticing that the collection name ahead of the title was a link. The bar ends in a ✕ now, behind a rule: a ✕ flush against Save reads as the next item in the action row, so the two adjacent controls are “commit this edit” and “abandon it”. It needs no confirmation logic of its own — the split closes by navigating, and
useNavigationBlockeralready stands between a navigation and an unsaved edit. “Save and close” reaches the split too, as a▾welded to Save rather than the separate filled button the overlays carry: there the list is beside you and j/k walk from record to record, so saving and staying is the common case. -
PATCH is served for updates, and the spec stopped describing a partial write as a replace. The update handler merges — it writes the columns in the body and leaves the rest, which is what the SDK’s
update(id, data: Partial<M>)means — and PUT was the only route it was mounted on. The generated OpenAPI spec inherited that and made it worse by reusing the create input schema for the update body, so everyvalidation.requiredproperty was marked required on a partial update: a published contract nobody implements, where a generated client sends more than it needs and a spec-validating gateway would reject partial updates the server happily accepts.PATCH is mounted on the same handler at both the collection and nested paths, and the spec describes it with a new
<Name>Updateschema derived from the input schema withrequireddropped — derived rather than rebuilt so the two cannot come to disagree about which columns exist. PUT stays, on the same handler, deprecated in the spec: changing its semantics to a true replace would silently start nulling columns callers have omitted for years, which is a data-loss change wearing the costume of a standards fix. The SDK also stays on PUT for now, deliberately and commented at the call site, because a 0.14 client sending PATCH to a 0.13 server gets a 404. -
An offline
createManywhose ACK was lost duplicated the whole batch.WriteOptions.idempotencyKeywas accepted oncreateand nowhere else, and the bulk route had no idempotency handling at all — so the one path where a replay costs the most was the one with no defence. A client that never sees a response cannot know whether the write committed, so it retries; without a key the server cannot tell that retry from a second genuine import. Throughcreatethat duplicates one row, throughcreateManyevery row in the batch up tomaxBulkRows(1000 by default) — and the offline queue replayscreateManyon exactly this path.upsert: truehid it for callers who set it, and upsert is documented for re-runnable imports rather than for crash recovery.POST /<collection>/bulkclaims the key before the write and replays the stored response, the same claim-before-write shape the single create uses and for the same reason: recall-then-write lets two concurrent replays of one key both through. A failed write releases the key, so one dropped connection does not leave a batch that can never be sent.createManyacceptsWriteOptionsacross the client and the contract, and the offline replay passesop.mutationId, which closes the loop. -
A dead public type, a closed error-code union, and docstrings that had drifted.
RebaseBrowserClientwas exported and documented as “the shape produced bycreateRebaseClient()”, and produced by nothing — that factory returnsCreateRebaseClientResult. It also hand-duplicated ~16 members ofRebaseClientrather than deriving from them, so it was a standing drift source as well as a false claim. Deleted.RebaseApiError.codewas a barestringwhile the server emits a known set, soe.code === "NOTFOUND"type-checked exactly as well as the spelling that works. It isRebaseErrorCodenow: the nine codes any route can answer with, unioned withstring & {}to stay open, since routes define their own (EMAIL_EXISTS,TOKEN_EXPIRED, a couple of dozen in auth) and closing it would be a lie that broke on the next one. Re-exported from@rebasepro/client, where callers catch. Also corrected:slug— required, and what the REST path, the SDK accessor, the admin URL and every reference property key on — was documented as “an alias that will be used internally”, describing an optional field that no longer exists. -
rebase cloud <group> --helpran the command instead of printing a page. Two bugs stacked:cli.tsrewrote the subcommand to the literal"--help"whenever the flag appeared anywhere, so the group never reached the dispatcher, and the dispatcher short-circuited on that value before dispatch. Seven cloud modules carry their own"--help"flag that could not run, leaving ~44 flags with no way to be listed.Routing
--helpthrough as the action would have fixed only the groups that switch on it; the rest takerawArgsand ignore the action, so the flag did nothing and the command ran anyway —cloud env --helpfailed on “No project specified”,cloud deploy --helpbegan resolving a project, andcloud link --helpopened an interactive project picker, a prompt from a flag whose whole job is to print text and exit.--helpis answered centrally now, before dispatch, from a group→page map, so a handler cannot be reached and prompting is structurally impossible rather than merely fixed.cloud-help.test.tsasserts that no handler ran, since a regression here does not look like wrong text — it looks like CI hanging on a prompt. -
One name per privilege level on the server, and the RLS claim the callbacks guide made was wrong.
RebaseServerClientomitsdataso the RLS-bypassing plane has exactly one name,dataAsAdmin— and cron undid it, typing the same singleton asRebaseClientand handing it back asclient. Its own docstring admitted it (“it is only namedclienthere”), and a reader who learnedclient.datathere carried it to a collection callback, wherecontext.datais a different trust level entirely. Cron exposesrebase: RebaseServerClientnow, matching the singleton import anddefineFunction, withclientkept as a deprecated alias typed to keep itsdatamember so existing cron files compile and run unchanged.The larger find is the documentation. The callbacks guide stated, in all six locales, that
context.databypasses RLS and has “full database access regardless of the triggering user’s permissions”. It does not: authenticated writes run throughwithTransaction, which builds a fresh base driver bound to the RLS-scoped transaction after the role has been downgraded, so a callback on a user request is user-scoped for reads and writes; only server-context work (dataAsAdmin, cron) bypasses. Wrong in the unsafe direction, and nothing tested it either way, which is how it stayed wrong. -
A
hasManywas declared once and rendered twice. A many-cardinality relation becomes an entity tab, which is the whole treatment for a list of child rows. It was also still a member ofproperties— the only place a relation can be declared — so the form rendered it a second time as a relation picker: a dropdown offering to select a collection’s own children, one card per child row. Nothing marked the relation as already consumed, so everyhasManyandmanyToManyproperty in every project grew a stray field.getChildViewRelationPropertyKeysis the missing half ofgetEntityChildViews: given a collection, it names the property keys the entity view has already taken, and the form’s field list (getFormFieldKeys, which both the editable form and the read-only entity view build from) drops them. The match is on the resolvedrelationNamerather than on the key, so a relation declared inrelationsand pointed at by a differently-named property is recognised too. A relation nested inside amapgets no tab, so it keeps its picker; abelongsTo/hasOneis a foreign key the author edits, and never had a tab to be redundant with.The collection table had the same duplication with the halves reversed. Every child view gets a 200px button column that opens its tab, and for a relation declared in
relationsthat button is its only presence in the table — but for one declared as a property it was the second: the property’s own column was already there, hydrated by the list fetch’sinclude: ["*"]and showing the child rows themselves, and the button carried the same heading, because a tab takes its name from the declaring property. Two columns called “Vacantes a las que postuló”, one of them a button. The property column wins there — it shows what the children are, and each chip in it opens one — so the button is dropped. Unless the author hid that column:hideFromCollection, or apropertiesOrderthat omits it, is a statement about the column and not about the relation, so the button comes back rather than the relation dropping out of the table altogether.getRedundantChildViewColumnIdsanswers this for both the column ids and the delegates that build them, so an id is never displayed without something to render it, and a column order saved before this change stops naming the button.Declaring a many-relation as a property is still worth doing — it names the tab and gives the relation a key that a table column and an
includecan address. If you want the inline picker anyway,admin: { renderInForm: true }asks for it back."talent-applications": {name: "Vacantes a las que postuló",type: "relation",relation: { kind: "hasMany", target: () => talentApplications, foreignKeyOnTarget: "talent_id" }// …and a second, unusable copy of this relation at the bottom of the form} -
conditions: { hidden: true }did not typecheck.hidden,readOnlyanddisabledtook aJsonLogicRule, which isRecord<string, any>, so the unconditional case — a field that is simply never shown, never editable — had to be spelled{ "==": [1, 1] }. That reads as a puzzle at the call site and as a mistake to the next person. They take a plain boolean now (ConditionRule), and because a literal needs no context to evaluate,isHidden/isReadOnly/isDisabledanswer it directly — so it is honoured everywhere a field is laid out, not only where a condition context happens to exist. A rule is still not evaluated in production; that gap is unchanged and is about needing an entity to evaluate against. -
A relation preview rendered the target’s whole document. An author card printed the entire Markdown biography — headings, bullet list and all — inside a box built for two lines of text, because three layers each assumed one of the others was keeping the value to a line.
Selection was positional.
getEntityPreviewKeystook the first few properties frompropertiesOrderand read that as a claim that they summarise a record;propertiesOrderstates the column order of a collection table, which is how the third column of an author ended up on the card. Properties are ranked now by whether the value has a one-line form at all (rankSummaryProperty): a map renders as a key/value table and an array of maps as a stack of them, so neither takes a slot from a value that fits; long text is an excerpt and sorts behind everything that does fit, but is still used when it is all there is, because an opening line beats a card with nothing under the title. A statedpreviewPropertiesis returned verbatim, ranking and limit included — asking for the biography still gets the biography. The two diverged copies of the picker are one implementation, in@rebasepro/app.Rendering was unconstrained:
PropertyPreviewrenderedadmin.markdownas a full document whatever size it was asked for. A preview inside an entity preview now renders a compact form — Markdown and multi-line text as their opening line, a map as its first labelled leaves, an array as a count. The signal is the nesting depth the card already publishes, so nothing between the card and the preview has to forward a prop.And the card could not defend itself.
truncateiswhite-space: nowrap; it clamps a line of text and does nothing to block children, so the box grew to the height of whatever was inside it. Rows are height-capped and the container clips, which also holds for a customadmin.Previewcomponent, which can render anything at all and cannot be reasoned about in advance.The card fills the same slots as every other surface that renders one record — image, title, subtitle, status — rather than its own list of “the first few properties”.
-
A relation in a card slot drew a card inside a card.
SlotValuetooktextOnlyas opt-in. The list view passed it; the card and board views did not, so a relation filling their title or subtitle slot rendered its own bordered preview, complete with an id line and a side-panel button, wrapped over three lines of a narrow grid card. It is the default now — a slot is one line of a row, and the one caller that would want a card in one is none of them. -
Editing
@rebasepro/cms-typesdid nothing in dev. The app’s Vite config resolves every workspace package to source, with a comment explaining that the one package left out came from its builtdistand so ignored edits until it was rebuilt.admin-typeswas added later and missed the list, and repeated the same bug. -
A monthly cron job spun at 112 iterations a second instead of waiting.
setTimeoutholds its delay in a 32-bit signed integer: past ~24.8 days Node does not wait and does not throw, it clamps the delay to 1 ms and fires immediately.scheduleNexthad a floor on the delay and no ceiling, so a job like0 4 3 * *— whose next slot sits about 30 days out for most of the month — woke at once, claimed its own future slot, lost the race against that claim on the next wake, logged claimed by another instance, and rescheduled into the same overflow. A tenant ran this way for a day and a half: 1.9 GB of logs, acron_claimsINSERT every 9 ms, and the job itself never running. Pod restarts did not clear it, because the claim that makes it skip is a persistent row.Three things were wrong and all three are fixed. Delays past the ceiling are now slept in hops and re-derived on waking, so the cron expression stays the source of truth. A fire that arrives before its slot no longer claims — an early wake is also what a backwards NTP step or a resumed VM looks like, and claiming on one is unrecoverable, because the claim is permanent and the real run is skipped when it comes due. And startup now releases claims on slots that have not happened yet, which can only come from an early fire, so a database already poisoned by this heals on the next deploy rather than silently skipping one run.
-
A collection whose slug contains slashes rendered a blank page.
getCollection("content/de-DE/podcasts")never tried an exact match. It split on/, read the pieces as collection/entityId/subcollection, looked for a root collection calledcontent, found none and threw — and the catch logged atconsole.debugand returned undefined, whichRebaseRouterenders asnull. The result was an empty content pane inside working chrome: the sidebar, the nav highlight and the breadcrumb all still resolved, because those read the collections array directly rather than the registry. One app had thirty-five collections unreachable this way, seven content types across five locales.A slug is allowed to contain slashes and some drivers need it to — a Firestore collection partitioned by locale is named
content/de-DE/podcasts, it is not a path to walk. An exact slug match now runs before the path walker, on the id-trimmed path, so a record inside such a collection still resolves to it. And an unresolved collection renders “Collection not found” naming the path, and warns atconsole.warn: returning barenullis what made a one-line lookup bug look like missing data, with nothing abovedebugto search for. -
optionalAuthreturned 500 on backends that do not issue JWTs. A backend authenticating through an adapter — Firebase, Clerk, anything with its own tokens — never callsconfigureJwt, so verifying a bearer token threw. That turned a route which had already decided anonymous callers were fine into a 500 for every request that happened to carry a token. The tolerate-the-absence-of-auth paths askisJwtConfigured()first now. The signing paths still throw when it is false, because asking a server that cannot mint a token to mint one is worth hearing about. -
The read-only record kept the layout the form left behind. Reading a record and editing it drew the same data two different ways. The form resolves sections, grid spans and a metadata rail from the collection; the read-only view was a flat two-column table of every value — 4/12 label against 8/12 value, no grouping, no second column. A boolean and a markdown body got the same room, an email wrapped over two lines while half the pane beside it stayed empty, and pressing Edit rearranged the record you were just looking at.
It resolves its layout with
resolveFormLayoutnow — the same call the form makes — and renders each field through the sameFieldBlockand grid span, with aPropertyPreviewwhere the form puts a control. So it gets the configured sections, the derived grouping when there are none, the per-type widths with row filling, and the rail with the sidebar fields and the id/created/updated block, folding into a trailing group on a pane too narrow for it. Two things fell out of sharing the resolver:additionalFieldsneed a form context the delete dialog has none of, so they are dropped before the layout resolves rather than skipped while rendering (skipping left a hole where a full row had been allocated); and previews takehideLabel, becauseBooleanPreviewprinted the property name beside its checkbox, which under a field label read as “VIP” above a checkbox saying “VIP”. -
The X that hid the list looked like it closed the record. The split view’s list-hiding control sat on the record’s own app bar wearing an X, which every convention reads as “close the thing I am attached to”. It is a double chevron now, pointing at what actually moves, matching the sidebar toggle. Hiding the list had also been a one-way trip —
#fullreplaced the collection and left browser Back as the only route back — so showing it again is the same single route (dropping the hash), offered only where the URL would genuinely resolve to a split.The detail view’s back arrow moves from the trailing edge, where it sat among the record’s own actions, to the leading edge the edit view has always used; where the chevron renders it goes entirely, since both reach the same collection and the chevron keeps the record open. And the breadcrumb is a link now rather than inert text that looked like one, carrying the view mode so a collection reached from one of its own records comes back as you left it. Overlays keep plain text: they already sit on top of that collection, and navigating would dismiss the record as a side effect.
-
The drawer’s tooltips were on in the one place they were noise, and off everywhere they were needed. The collapse/expand toggle carried a tooltip saying “Collapse” while the row it was attached to already said Collapse in plain text beside the chevron — the tooltip only backed off while the drawer floated open under the pointer, which is the other state where the word is already on screen. It now shows up only on a bare rail, where the chevron stands alone.
The navigation entries had the opposite problem. Their tooltip’s
openwas controlled by a flag that was true only while the drawer was hovered-but-not-open — and in exactly that state the entries are told the drawer is open, which forced the same tooltip shut. The two conditions could never both hold, so no entry tooltip could ever appear in any state. That went unnoticed while hover-expansion was unconditional, because the floating panel’s labels covered for it; withautoOpenDrawer={false}now a real setting, it left a rail of unlabelled icons with nothing to identify them. Each row now owns its own tooltip state, so they follow the pointer one at a time rather than firing in unison, and they answer to keyboard focus as well.tooltipsOpenandadminMenuOpenare deprecated no-ops onDrawerNavigationGroupandDrawerNavigationItem.Both tooltips are masked where the label already says the same thing, rather than unmounted or switched to uncontrolled — either of those moves a Radix tooltip between controlled and uncontrolled mid-life, which strands whatever it was last told. The first attempt at this fix did exactly that and left a tooltip hanging beside the rail, naming a row the pointer had left seconds earlier. Masking has its own version of the trap: a hidden tooltip never hears the pointer leave, so the stale
trueis dropped on the way into the masked state rather than waiting for a close that will not come. -
An open dropdown left the drawer floating indefinitely. The collapse-on-mouseleave already declined to fire while a popover was up — its content is portalled outside the drawer, so reaching for it registers as leaving. But nothing fires a second
mouseleavewhen that popover finally closes, so the drawer just stayed expanded over the content until the pointer happened to cross it again. The collapse is owed now, not cancelled: the drawer watches for the popover to go and collapses then, unless the pointer came back in the meantime. -
Two admins on one origin shared a drawer, and the stored state broke server rendering. The persisted open/closed state used one flat
rebase-drawer-openkey, so a second admin on the same origin — a differentbasePath, its own navigation — silently overwrote the first one’s. The key is namespaced by base path now. Reading it also happened during the first render, which is a client-only fact and made the first client render disagree with server-rendered HTML; it is applied in a layout effect instead, before paint, so nothing flashes and nothing mismatches. The unreleased flat key is not migrated: a drawer starts collapsed once, and the next toggle sticks. -
The drawer’s collapse control was a
divpretending to be a button —role="button"plus a hand-rolled Enter/Space handler, where a<button>gets all of it from the platform. -
Resizing across the layout breakpoint dropped the navigation over the content. One piece of state drives two different things: the expanded rail on large layouts and the modal sheet on small ones. Narrowing the window with the rail expanded carried that
trueacross the breakpoint, so the sheet — overlay and all — appeared over the content unasked, which is the exact outcome the persistence rules were written to avoid. Crossing to a small layout now resets it, and widening again restores the stored choice. -
The navigation drawer remembers whether you collapsed it, and stops expanding on its own. Two separate reasons the drawer kept turning up open. First,
autoOpenDrawerwas destructured inScaffoldand then never read, so the hover handlers were attached unconditionally: an admin passingautoOpenDrawer={false}still got a rail that floated open whenever the pointer crossed it. It is honoured now. Hover expansion remains the default — it is what every admin has always had — andautoOpenDrawer={false}genuinely turns it off. Second, the open/closed state was plainuseState— every reload threw the choice away. It is persisted inlocalStorage, keyed by the admin’s base path, so the last toggle is what you get back.defaultDrawerOpenstill seeds the very first visit and is ignored after that. Small layouts are excluded from persistence on purpose: there the drawer is a modal sheet, and restoring it would drop an overlay over the content on load. -
Upgrading from 0.12 renamed the foreign-key column and then refused to boot, permanently. 0.13 derives
category_idwhere 0.12 derivedcategorie_id, and boot-ensure renames the database column to match — that half worked, data intact. Then relation validation read the project’s checked-inbackend/src/schema.generated.ts, which the previous release generated and which still sayscategorie_id, and killed the boot. Restarting could not help: the rename was already applied, so every boot failed the same way. The message made it worse by describing the wrong artifact — “through.targetColumn: "category_id"is not a column on the junction table”, about a column the database did have — and advisingthrough.targetColumn: "categorie_id", which by then existed nowhere. Following the fix instructions broke the relation for good.Three changes.
rebase devnow detects a generated schema that names foreign keys under the old rule and regenerates it before the backend starts, so the upgrade does what the 0.13 note said it did.rebase schema stalereports the same thing for a build or a CI step, and exits non-zero. And when a stale schema does reach the runtime, the boot error names the generated file as the stale artifact, says to runrebase schema generate, and no longer suggests pinning the migrated-away column.rebase buildwas never affected — it regenerates the schema from the collections already.Both halves of this had unit tests that passed. The ensure-plan test proved a RENAME is emitted, from a hand-written schema map; the relation-validation test proved a missing junction column is reported, from a registry built to agree with its collections. Neither could see the bug, because it only exists where the two disagree — and no test built a registry from a stale generated schema.
legacy-fk-rename-boot-seam.test.tsis that seam. -
rebase.dataAsAdmin.projects.find()did not typecheck — nor diddata.products.find()on the Entity accessor — for any project without a generatedDatabasetype. The untyped branch ofRebaseSdkDataandRebaseDatadeclared their index signature asSDKCollectionClient | ((slug: string) => SDKCollectionClient), unioning in thecollectionmethod’s own signature on the theory that a named property must satisfy the index signature it sits beside. It does not here:collectionis declared in a separate member of an intersection. The union bought nothing and cost property-style access — the form the type’s own@exampleshows, the scaffolded function template uses, and the 0.13rebase.datamigration note tells you to write.collection("projects")was the only spelling that compiled.The migration note shipped uncompiled because it is a
diff fence, and the docs verifier only ever typechecked `ts`/`js` blocks. Language-tagged diffs (diff ts) are compiled now — the added lines, with removed lines blanked so diagnostics keep pointing at the right line of the doc. -
A scaffolded project could not build its own
configworkspace.config/tsconfig.jsonpinstypes: ["node"]— deliberately, to stop tsc sweeping pnpm’s virtual store — butconfig/package.jsonnever depended on@types/node, and under pnpm’s isolated layout there is none reachable from that directory.pnpm -r build, and the workspace’s ownbuildscript, failed withTS2688: Cannot find type definition file for 'node'one minute afterrebase init.check:templatescould not catch it: it compiles the collection files with its owntypeRootspointed at the repo, which is right for what it checks and is exactly why the omission survived. It now also asserts that every ambient type a template tsconfig pins is a declared dependency of the workspace pinning it. -
A project scaffolded by a prerelease CLI pinned a runtime image tag that cannot exist.
.envpinsREBASE_VERSIONto the version of the CLI that scaffolded, which is right for a stable release and wrong for every canary: only stable publishesrebasepro/server, sodocker compose updied onmanifest unknown— the same dead end as the missing-repository bug the pinning was added to prevent. A prerelease falls back tolatestnow, with a comment in the file saying that it floats and to pin an exact version before deploying. That fallback is correct rather than merely available: a bundle declares the runtime range it needs (^1), the image supplies only@rebasepro/server, and the framework a bundle runs is installed from its owndeps.declaredat boot, so the current stable runtime boots a canary bundle by design. -
/api/healthanswered 404. Health lives at/health, outsidebasePath, because that is what an orchestrator probes — but every other route a developer touches is under/api, so the first place anyone looks returned “not found” and read as a broken server. It is served at both paths now. -
rebase init --headless --introspectcontradicted itself, announcing “collections generated!” and then “There are no collection files” in the next paragraph. The closing note now depends on whether introspection actually produced them. -
A long table and column name made boot re-issue the same
ADD CONSTRAINTforever. Postgres truncates an identifier to 63 bytes silently, so a foreign key on a long table plus a long column was stored under a name the generator never derived. Boot-ensure compares its planned constraints against the catalogue, and that comparison could therefore never match: every boot planned the constraint again, and got “already exists” again. Non-fatal — a foreign key is the one action allowed to fail — and so permanent, an error in the log on every restart for the lifetime of the project.The name is truncated at construction now, in bytes rather than characters, so the code agrees with what the database already stored. Five places derived that name independently — one in the ensure planner and four written out by hand in the DDL generator — and all five go through one helper, which is how the halves came to disagree in the first place. This changes what Rebase derives; it changes nothing about what any deployed database contains, which is the test that makes it a legitimate exception to the freeze below.
Testing & CI
Section titled “Testing & CI”-
Derived database identifiers are frozen, and there is a gate that says so. A column name is an API — the kind that is written into a customer’s database on the day they deploy and cannot be changed afterwards by anything this repository ships. 0.13 improved the foreign-key derivation, and every aged database with an irregular plural disagreed with the code the moment it upgraded: the column was migrated, the project’s checked-in generated schema was not, and the boot died. Three commits to recover from a nicer-looking column name nobody had asked about.
contracts/derived-names.txtnow pins every identifier the framework derives rather than is told — foreign key columns and their constraints, junction tables and their key columns, enum types, policy names,camelCase→snake_casecolumns — rendered from a fixture built to make every naming rule fire at once.pnpm check:derived-namesclassifies a difference rather than just reporting it: a moved name fails as a contract break naming the old and new spelling, and even a purely additive change fails with “regenerate”, so the baseline cannot drift underneath anyone. The rule it enforces, and the one legitimate exception to it, are contract 6 indocs/compatibility.md.It pins a second contract hiding inside the first: that
rebase db pushand the managed runtime’s boot-ensure derive the same names. They compile the same collections through different code, and a project pushed once and booted later must not end up with two schemas. That check is what found the truncation bug above — the two producers had been disagreeing about one constraint name. -
The upgrade corpus records aged projects, not just aged databases.
schema-snapshots/records a database and is why several one-way auth migrations are safe. It could not have caught the 0.13 boot failure, and neither could any database-only corpus, because that bug lived in the disagreement between a migrated database and the un-migrated artifact beside it — a state no hand-written fixture produces, since a fixture author writes both sides and writes them agreeing. That is exactly why the unit tests on either side of it both passed.project-snapshots/records both halves from the same release: the database with its rows, the tables and columns that release’s codegen declared, and the collections that produced them.project-upgrade-e2e.test.tsreplays each through the current code and asserts the upgrade converges, the rows survive, any renamed relation column brought its data (compared across the rename, since a renamed column is supposed to have a different name), the tables are still locked, and a stale generated schema is diagnosed rather than followed.scripts/release.shrecords one per release, from a Postgres it starts itself — no live database of the right vintage required. That is the point: “record one per release” was already a documented step, and it had been skipped three releases running, so the only version worth building is the one nobody has to remember. A release that cannot record one says so loudly and continues. -
The gates that would have caught this release’s bugs. Each of the fixes above had passing unit tests on both sides of it and none in between, because every fixture built its own input and so could never let the two sources of truth disagree. Five gates close that shape:
legacy-fk-rename-boot-seam.test.tsbuilds a registry from a stale generated schema while the database has been migrated;generated-schema-staleness.test.tspins the detector including its no-false-positive cases;check:runtime-imagerefuses a shipped compose file naming an image no automatically-triggered workflow publishes;check:templatesadditionally asserts every ambient type a template tsconfig pins is a declared dependency of the workspace pinning it; and the docs verifier compiles language-tagged ```diff fences, so migration guidance is checked rather than just written. -
The driver floor is measured rather than discovered. Two capabilities, not one: serving tables that already exist is the fleet-rollout case and every driver back to 0.10.0 manages it, which is what the skew pass asserts. Creating them at boot is separate, and drivers before 0.13.0 do not expose it — the runtime logs “Collection tables will NOT be created” and every
/api/dataroute 500s on a missing relation the moment a project adds a collection or deploys fresh. CI now measures both. -
The bundle corpus boots against every driver a project may still carry.
stage()lent the whole donornode_modules, so both halves came from this checkout and every run booted current-driver against current-server — a pairing that exists on no tenant anywhere. Production is the opposite:docker/entrypoint.mjssymlinks only@rebasepro/serverfrom the image over a bundle’s own copy, so a managed project runs today’s server against whatever driver it was built with. -
@rebasepro/server’s API surface is frozen. It is the one package the entrypoint substitutes into an already-built bundle, so its exports are the only ones that change underneath tenant code on a schedule nobody rebuilding chose. Changes to it now have to be declared. -
Two dead paths deleted, and what they knew kept.
FetchService.fetchWithDrizzleQuerywas private with no callers, kept alive only by a test reaching in through(service as any)— the worst arrangement available, since the guarantee read as covered while the path that actually serves it had none. That guarantee (a null belongsTo must not inline as a row) moved torow-pipeline-null-relation.test.tsagainsttoRestRow, which is what production runs, and was checked by deleting the guard and watching it fail.resetConsolewent too: it snapshotted “the originals” afterconfigureLogLevelhad already replaced them, so it captured the no-ops and restored them over themselves. Neither was public —api-surface/server.api.txtis unchanged. Corpus fixtures are renamed after thebundleFormatthey carry, sincev2collided with the runtime contract major, which decides something else entirely.
[0.13.0] - 2026-08-03
Section titled “[0.13.0] - 2026-08-03”Breaking
Section titled “Breaking”-
Rebase creates exactly one schema in your database, and it is called
rebase. The RLS helpers move fromauth.uid()/auth.roles()/auth.jwt()torebase.uid()/rebase.roles()/rebase.jwt(), and theauthschema is removed.authwas Supabase’s name, borrowed so that a developer who had written Supabase RLS would recogniseauth.uid(). The familiarity was real; the name was not ours to take. Pointing Rebase at a database that already had anauthschema meant applyingCREATE OR REPLACE FUNCTION auth.uid() RETURNS textover Supabase’sRETURNS uuid, which Postgres refuses outright — and at boot that landed in a catch-all, leaving a database with auth tables, no helper functions, and policies calling functions that did not exist.Migrating is meant to be uneventful. Structured rules (
policy.authUid(),policy.rolesOverlap()) never spelled a schema and need no change at all. Raw policy SQL written againstauth.uid()is rewritten on compile, and the boot names the collections carrying it rather than rewriting in silence. Policies already in a database are recompiled by the next push or boot, and keep their names, because policy names hash the rule’s semantics rather than its SQL. Theauthschema is then dropped — each function matched on its own result type and body first, and the schema byRESTRICT, neverCASCADE, so anything else living there keeps it. -
The scaffolded database role is
rebase_app, notrebase. Postgres resolves unqualified names through"$user", public, so a role namedrebaseput the newrebaseschema ahead ofpublicfor any tool that does not pinsearch_path— psql, pg_dump, drizzle-kit, a hand-written migration — and statements silently landed in the wrong schema.Existing projects are unaffected and stay covered by
pinSearchPath, and a boot-time check reports the collision for any project that picks a colliding name of its own. New projects getrebase_appin the generateddocker-compose.ymland.env. If you are following a deployment guide you had already copied, the connection string is the thing to update — a stalepostgresql://rebase:...against a freshly generated compose file fails withpassword authentication failed for user "rebase". -
rebase.datais gone — userebase.dataAsAdmin. The server singleton had two names for one accessor, and the shorter one gave no hint of what it does:rebase.dataandrebase.dataAsAdminwere the same admin-scoped, RLS-bypassing driver.datais the name a browser client uses for its user-scoped accessor, so the same expression meant “whatever this user may read” on the client and “everything, no policies” on the server. That is a bad thing to have to remember at a call site that reads fine either way.import { rebase } from "@rebasepro/server";const { data: rows } = await rebase.data.projects.find();const { data: rows } = await rebase.dataAsAdmin.projects.find();RebaseServerClientnow extendsOmit<RebaseClient, "data">, so this is a compile error rather than a silent privilege. The property still exists at runtime, aliasingdataAsAdmin, so an untyped JavaScript caller keeps working instead of failing onundefinedmid-upgrade — the type is the contract, and it is the type that changed.Unaffected, because their accessor is genuinely user-scoped and was never deprecated:
context.client.datain entity callbacks, andclient.datain a cron handler — both areRebaseClient. Also unaffected:rebase.datain a generated SDK or browser app, which is a different object entirely.For user-scoped queries inside a request handler, neither name is right: use the request-scoped driver (
c.var.driver), which carries the caller’s identity so RLS applies. -
Every other deprecated export is gone too. Ten more symbols carrying
@deprecated, removed rather than carried across the 1.0 line. After 1.0 a deprecated export costs a major to remove, so the choice was to drop them now or keep them until 2.0 — and each one was an alias for something already exported under a better name, so keeping them only bought a second way to write the same line.Removed From Use instead buildCollection@rebasepro/commondefineCollectionbuildProperty@rebasepro/commona plain property object RebaseUser@rebasepro/clientUserfrom@rebasepro/typesRebaseTokens@rebasepro/clientAuthTokensfrom@rebasepro/typesUserInfo@rebasepro/appUserfrom@rebasepro/typesSession@rebasepro/appDeviceSessionfrom@rebasepro/typesAuthApiError@rebasepro/appRebaseApiErrorfrom@rebasepro/typesDatabaseConnection@rebasepro/serverDriverConnectioncreateApiKeyRateLimiter@rebasepro/servercreateDataRateLimiterresolveChannelBusConfig@rebasepro/server-postgresresolveChannelBusSettingEvery one is a rename at the import site. The three that are not purely cosmetic:
createApiKeyRateLimiterskipped every request that was not API-key-authenticated, which on a normal deployment is nearly all of them — a limiter that reads as protection and passed the traffic you would want limited.createDataRateLimitercovers signed-in users and anonymous callers too, and has been the wired default since it landed.buildCollection/buildPropertywere announced as removed in 0.11 and were not — the note went into the changelog and into the collections docs, and both functions kept shipping from@rebasepro/commonfor two more minors. Anyone who read the note migrated; anyone who did not kept a working build. Now the code matches what was published, and the collections docs no longer name a version the removal did not happen in.DatabaseConnectionis still a name you can import from@rebasepro/server— that is the point of removing it. Two different shapes answered to it: a local alias forDriverConnection, and the canonicalDatabaseConnectionfrom@rebasepro/typesthat the package re-exports. Deleting the alias leaves one. If your import resolved to the alias, it was the driver connection and wantsDriverConnection; if it type-checks unchanged, it was already the canonical one. -
Default foreign-key column names were mangled for irregular plurals, and are fixed.
generateForeignKeyNamesingularized by chopping a trailingsoff the snake-cased name, which producedcategorie_idforcategories,addres_idforaddresses, neverchild_id(it gavechildren_id), and — becausetoSnakeCasesplits on every capital before the chop —ur_l_idforURLs. It singularizes first now, with the package’s realsingular(), then snake-cases. Two guards: a double-sending is never a plural marker, and a name that singularizes to nothing keeps its original.This changes the default column name for affected relations, so an existing database has the old name. Boot-ensure migrates it: when a table carries the relation column under its pre-singularization name and not its current one, it emits
ALTER TABLE … RENAME COLUMN "categorie_id" TO "category_id"rather thanADD COLUMN. In Postgres a rename is metadata-only — the values stay put and the column’s indexes and constraints travel with it. Adding was the actual bug: it created the new column empty beside the populated old one, every statement succeeded, and the relation then read the empty one.If you named the column explicitly, nothing changes — this is only the default.
-
firestoreToCMSModelandcmsToFirestoreModelare renamed tofirestoreToRebaseModelandrebaseToFirestoreModelin@rebasepro/firebase. They reached consumers through the package barrel’sexport *, so this is a breaking rename with no alias — a shim would keep the word in the API it is being removed from. (toCmsRow→toFlatRowmoves with them, but is internal toserver-postgres.) -
MongoDB search matched no field.
buildSearchConditionsselected searchable columns withprop?.dataType === "string". No property in@rebasepro/typeshas ever had adataTypefield — a real collection carriestype— so the loop matched nothing for every collection a user could declare,orConditionscame back empty, and the fallback turned every search into a$textquery, which needs a text index and throwsIndexNotFoundwithout one. The suite passed because its fixtures were written with the same wrong key. -
admin.widthPercentageis gone — useadmin.span. Field width is a span over a shared four-column grid now, so two fields line up whatever order they were declared in. A raw percentage could not line up with anything:33and35produced different widths that looked like a mistake, and nothing snapped to a common edge.admin: { widthPercentage: 50 }admin: { span: 2 }If you are migrating:
≤30 → 1,≤55 → 2,≤80 → 3, otherwise4. Spans are ignored where the form is too narrow for two columns — the side panel, the split pane, a phone — which was also true of percentages. -
RebaseAuthConfigis gone from@rebasepro/cms-types— useRebaseAuthViewConfig. It was a compatibility alias for a name that collides head-on withRebaseAuthConfigin@rebasepro/server, which configures the backend auth: JWT secrets, OAuth providers, password hooks. Two unrelated shapes under one name, exported from two packages whose whole job is to be imported together. -
react-router8, andreact-router-domis gone — react-router 8 deletes thereact-router-dompackage outright. It was only ever a v6-compatibility shim: everything DOM-specific had already collapsed intoreact-routeritself in v7.@rebasepro/cms,app,studioandplugin-ainow peerreact-router ^8.3.0. Two imports move, and only one of them is a rename:import { createBrowserRouter, RouterProvider } from "react-router-dom";import { createBrowserRouter } from "react-router";import { RouterProvider } from "react-router/dom";Everything else —
useNavigate,useLocation,useSearchParams,useParams,Link,NavLink,Outlet,Navigate,Route,Routes,MemoryRouter,useBlocker— is the same name fromreact-router.RouterProvideris the exception: it lives inreact-router/dom.The floors underneath move with it, because react-router 8 requires them:
reactandreact-dompeers go to>=19.2.7(were>=19.0.0), andengines.nodeon@rebasepro/cmsandappto>=22.22.0(was>=20). Declaring>=20while a mandatory peer needs 22.22 is a promise the package cannot keep.This closes GHSA-qwww-vcr4-c8h2, which has no fix on the 7.x line. That advisory is an RSC-mode CSRF bypass and nothing here uses RSC mode, so the vulnerable path was unreachable — but 8.3.0 is the only patched release, and the alternative was staying on a package that no longer exists.
If you test with Jest, budget for this: react-router 8 is ESM-only, and it breaks ts-jest’s CommonJS output in two unrelated ways. react-router guards a Vite HMR hook with
import.meta.hot, which is a syntax error in CJS — and ts-jest cannot fix it, because TypeScript emitsimport.metaverbatim undermodule: commonjs. Separately, react-router depends oncookie-es3, which ships.mjsonly, and TypeScript keys module format off the file extension, so it will not emit CJS for a.mjsinput whatevermodulesays. Every affected suite dies at module load with zero tests run, which reads as a broken config rather than a dependency-format problem.scripts/jest/react-router-esm-transform.cjsin this repo handles both and is a reasonable thing to copy. Vitest is unaffected. -
rebase cloud deploy --sourceon a managed project now needs--force. It ejects the project to a custom container image, and until now it did that on the strength of--sourcealone — read as self-evidently a deliberate eject. It is not.--sourceanswers which source gets built — this directory, rather than the months-old archive the control plane is holding — and the eject is a side effect of that answer, not something the caller named. Someone reaching for--source .because they want their working tree deployed has the right instinct and no reason to expect a runtime change.That is how a live project got flipped from
runtime.mode: managedtocustom, discovered afterwards fromrebase cloud statusshowingframeworkVersion: null. The bare form had been refused for the identical reason since the release below;--sourcewas the hole left in it. Both forms are now the same rule: a container-image build of a project the platform runs as managed happens only when--forcesays to.rebase cloud deploy --source . # ejected, with a warningrebase cloud deploy --bundle # stay on managed — almost always what was meantrebase cloud deploy --source . --force # eject on purposeThe refusal carries
code: "managed_project", which is what it already used, so a caller already branching on that code needs no change. -
rebase db branchkeeps the name you give it. Branch names were stripped of everything outside[a-zA-Z0-9_], sorebase db branch create my-featureanswered✓ Branch "myfeature" created— a different name than the one asked for, and the only onelistwould ever show.$ rebase db branch create my-feature✓ Branch "myfeature" created successfully.$ rebase db branch create my-feature✓ Branch "my-feature" created successfully.Nothing needed the stripping: every identifier the branch service builds is double-quoted, which is what makes a hyphen safe, and the validator used for
--fromhad always accepted hyphens — the two disagreed about the same character class. A name that cannot be represented (a space, a dot, a slash) is now refused withInvalid branch name: only letters, digits, underscores, and hyphens are allowed.rather than quietly turned into a different one. Names are also capped at 60 characters, because Postgres truncates identifiers past 63 bytes silently, which is the same rename by another route.Branches created before this keep the name they were stored under.
my-featurefrom an older release is recorded asmyfeature, and that is whatlistshows and whatdeletetakes.deleteandinfonow read the database name from the metadata row instead of re-deriving it, so those older branches drop the database they actually own — re-deriving would have aimed atrb_my-feature, which is either nothing or somebody else’s database.
Security
Section titled “Security”-
realtime.requireAuth: trueopened the socket instead of closing it. The connection handler seeds every session withauthenticated: !requireAuth, so arequireAuththat resolves false does not skip a later check — it marks each connecting client as already authenticated. Both sockets computed it asauthConfig.requireAuth !== false && !!authConfig.jwtSecretwhich ANDs the one setting whose entire purpose is to demand authentication together with the presence of a local secret. On a server that authenticates through an
AuthAdapter— or through anything other thanauth.jwtSecret— that expression is false, so asking for authentication was what granted it, silently, to everyone who connected. -
The socket answered the opposite of the HTTP routes. One product decision — “does this server require an authenticated caller?” — with two enforcement points that each computed it.
init.tshadresolveRequireAuth: no auth configured means auth is required, anAuthAdapteralways means required, and only an explicitrequireAuth: falseopens it. The socket carried its own copy, and the two disagreed on the case that matters most: with no auth configuration at all,/api/dataanswered 401 to every read while the socket admitted everyone and served the same rows. Not a weaker gate on the socket — the opposite answer.The socket’s expression is gone rather than corrected; both enforcement points call
resolveRequireAuth, and the tests pin that they agree rather than restating each answer separately. -
policy.authenticated()admitted anonymous visitors. There were two sentinels for “nobody is signed in”. The types, the policy compiler, the JavaScript evaluator and the anonymous-grant linter were all built onANONYMOUS_USER_ID('anonymous'); the request path scoped unauthenticated callers as'anon'. Sopolicy.authenticated()— the sanctioned, documented way to write “signed in”, the thing the linter tells you to use — compiled toauth.uid() <> 'anonymous'and was true for every signed-out caller.The linter had it exactly backwards, too: it flagged
auth.uid() <> 'anon'as a Supabase habit comparing against “a string no caller ever has”, when'anon'was the only spelling that worked.This is worse than a default that fails open, because it inverts a rule the author wrote deliberately. A policy that reads as a lockdown was a full grant, and nothing about it looked wrong at any layer — in one deployment it left
INSERTon companies, company memberships and jobs open to anonymous callers, and a membership row is a privilege boundary: every anonymous visitor shares one uid, so a single claim is a membership held by the internet.The request path now reports
ANONYMOUS_USER_IDeverywhere it scopes a caller — the JWT and adapter middlewares, the websocket handshake, the realtime service, and the rate limiter’s “is this a real user” check. New:ANONYMOUS_USER_IDS(every spelling, newest first) andisAnonymousUid().Existing databases are fixed by upgrading the server, without regenerating a single policy: a stored
auth.uid() <> 'anonymous'starts excluding anonymous callers the moment they report that id.policy.authenticated()now compiles toNOT IN ('anonymous', 'anon')rather than a single literal, because a policy is written into the database and outlives the server that generated it — one spelling is a hole in whichever direction the versions happen to skew.What breaks: a policy that grants to anonymous callers by comparing
auth.uid() = 'anon'stops matching. That fails closed, andpolicy.not(policy.authenticated())is the supported way to say it. -
auth.requireAuth: falseno longer un-gates cron, logs, backups and the schema editor. That flag answers a question about the data plane — must a caller present a token to read/api/data, or does RLS alone decide? — andfalseis the answer the server itself recommends at boot to anyone serving a public website from their own backend. It was also, silently, the switch that decided whether the admin surfaces were gated at all.So the documented configuration for a public job board or marketing site mounted
POST /api/cron/:id/trigger,GET /api/logsand/api/admin/backupsfor anyone who could reach the service. A singlewarnper surface at boot was the only notice, and on a--allow-unauthenticatedCloud Run deployment “anyone who can reach the service” means the internet. Anyone whose cron jobs spend a metered third-party quota was paying for that.Admin surfaces are now gated whenever there is authentication to gate them with — an
AuthAdapter, or ajwtSecret— independent ofrequireAuth. Whether anonymous callers may read your posts has no bearing on whether they may run your cron jobs.If you deploy with
requireAuth: false, calls to these routes that previously succeeded unauthenticated now answer 401. They accept what every other admin surface accepts: an admin JWT, the service key, or anrk_API key created withadmin: true— the API-key pre-auth runs ahead of the JWT check, so a scheduler holding an admin key keeps working. Point Cloud Scheduler (or whatever triggers your jobs) at an admin key before upgrading.One thing comes back:
/api/meta/contractis served again on these deployments. It is only mounted when it can be gated, so a public-data-plane project had been 404ing it, and with it typed client generation from another repository. -
A backend with no authentication at all now refuses its admin surfaces instead of serving them open. With no
AuthAdapterand noauth.jwtSecretthere is no credential this server could check a caller against, so it cannot tell an admin from the internet. It used to mount cron, logs, backups and the schema editor anyway, ungated, with onewarnper surface at boot as the entire defence.They now answer 501
ADMIN_SURFACE_UNAVAILABLE, with a message naming the missing switch. They stay mounted rather than disappearing on purpose: an unexplained 404 on/api/cronreads as a broken path or a failed deploy and gets debugged as one. A token does not change the answer — there is nothing to verify it against.This is unlikely to touch you: every scaffolded backend and the bundle runtime configure
auth.jwtSecret(the runtime requires it, and auto-generates one in development), so the affected shape is a hand-rolled entrypoint that passes noauth— or one whoseJWT_SECRETquietly failed to reach the container, which is precisely the deployment that should not be serving a cron trigger to anonymous callers.The data plane is unaffected and still answers 401 there: “show me a token” is a truthful thing to say about
/api/data, and a dishonest one about a surface no token can open. -
Every
overrides:entry is a bounded security floor now. An override replaces each transitive consumer’s own range, so a bare>=Xis not a floor — it is a floating pin that drags in the next major to publish, whatever asked for what.One of them had inverted completely:
js-yaml: ">=4.2.0 <5"pinned the tree at 4.2.0, which is precisely the version GHSA-52cp-r559-cp3m says to leave (patched in 4.3.0). The pin meant to protect was the thing holding the exposure.uuidhad meanwhile floated from its 11.x floor to 14 unnoticed.Closes 12 further advisories across
brace-expansion(three live majors, so its floors are keyed per-major rather than forcing one on every consumer),js-yaml,react-router,shell-quoteandprotobufjs. Re-resolving moved no package version, so the bounds themselves are hardening only. -
@hono/node-serverin the scaffolded backend goes from^1.19.12to^2.0.12, closing GHSA-frvp-7c67-39w9 (aserve-staticpath traversal on Windows via an encoded backslash). The 1.x line has no patch, and@rebasepro/serveralready peered^2.0.12— a new project was being handed an adapter two majors behind the server consuming it.
-
customPropsin the collection editor was marked deprecated by accident. It carried a@deprecated Superseded by spantag that belonged towidthPercentageand slid onto the next field along when that one was deleted.customPropsis live — it is how a customFieldorPreviewreceives its props, andPropertyFieldBindingreads it on every render. Nothing about the behaviour changed; the tag is gone, so editors stop striking through a supported field and suggesting a replacement that does something else entirely. -
The eject warning was suppressed exactly where it mattered. The warning above the refusal — the one that exists because ejecting “is not something to discover from a runtime version going blank” — was printed behind
!isJsonMode(). JSON mode latches on whenever stdout is not a TTY, so piping the command, or running it from CI or a coding agent, deleted the warning outright, and the deploy’s JSON payload carried no equivalent field. The one case with nobody watching the terminal was the one case that said nothing.Warnings now go to stderr in every output mode — stderr is not the JSON stream, so it cannot corrupt a parser — and only their formatting depends on the mode. Whether a warning is emitted at all no longer does. The deploy payload gains
warnings: [{code, message, hint}]and a denormalisedejectsManagedRuntimeboolean for CI to test directly; both fields are always present, sofalsenever has to be told from absent. -
deployprinted human progress to stdout in JSON mode, ahead of the result object, breaking any parser reading it — the🚀 Triggering deployment…banner on both the source and managed-bundle paths, the source upload’s size line, and on the bundle path the entire build transcript (Building bundle…, the compiler’s own log lines, frontend folding,Uploading bundle…). Progress goes through oneprogress()helper now, which drops it in JSON mode. The rule it settles: progress is not a result and disappears when stdout belongs to the JSON; a warning is not a result either, but goes to stderr and never disappears. -
A project that had never deployed reported
custom · your own image.projects.runtime_modeis a record of what the last deploy made a project, and it carriedDEFAULT 'custom'from the migration that added it — which was a true statement about the projects that existed then, and applied to every row created ever after. So a project created seconds ago, which had never built anything, named a container image nobody had built. Most visibly right after the console’s create wizard, whose runtime step defaults to Managed and says outright that the choice is intent and writes no mode.It also blunted the one signal that catches an accidental eject:
customwas equally the resting value of a project nothing had happened to, so it could not distinguish “a source build moved you off managed” from “nothing has happened here yet.”The column stops defaulting (control plane migration
0040_runtime_mode_undecided), making NULL the honest third state, andrebase cloud statusand the console’s overview, infrastructure and apps headers all read it as “not deployed yet” rather than inventing an image. Every non-display reader already coerced absent tocustombefore use, so nothing else changes. Existing rows are deliberately not backfilled — a row sayingcustomtoday may be a project that really did ship source, and there is no way to tell those apart from the ones the default flattened. -
Several concurrent realtime subscriptions hung on a cold page load. A view that opens more than one at once — a Kanban board opens one per column — reported
Subscription timed outfor all but one of them, thirty seconds in. The socket was healthy: probed directly, six concurrentsubscribe_collectionframes all answered inside 15ms. The frames were never sent.ensureAuthenticatedpublished its in-flight guard only after awaiting the token getter, so every caller arriving in that gap started an attempt of its own — and the message queue flushing on connect delivers exactly that. Each attempt then registered underauth_${Date.now()}, the one request id with no random suffix, so attempts in the same millisecond collided in aMapand only the last survived. One promise settled; the frames waiting behind the others never reached the socket. Client-side navigation skips the path (isAuthenticatedis already true), which is why the same view worked on every visit after the first. -
Kanban drag-and-drop put cards in the wrong place, and did not persist a column change at all.
handleDragOvermoves the card between columns while the pointer is still down, so looking it up by id at drop time finds it in its destination — the board reported every cross-column drop as a same-column reorder and never wrote the column property. Separately, the drop handler passed every card in every column toonItemsReorder, whose consumer reads it as the target column and takes the moved card’s neighbours from it to compute a sort key.Also: releasing over a column rather than a card no longer forces an append (which sent a card dropped mid-column to the bottom), dropping onto an empty column no longer aborts the save, and collision detection is
closestCorners— the default only reports a target while the dragged rect overlaps one, so a card held over a gap reported nothing. -
Board sort keys are
fractional-indexingkeys the database can sort. The library’s default base62 output only orders correctly under byte comparison, and the sort is done by Postgres, whose default collation is not byte comparison: underen_US.UTF-8,"aa"sorts before"aC". A board dragged around enough to reach the upper-case digits stopped agreeing with its own keys. Keys are base36 and single case now. Existing keys no longer validate, which is what surfaces the board’s Initialize bar — and that bar works now: it only ever looked for a null order value, so a column full of unusable-but-present values offered a button that updated nothing and never went away. -
Kanban columns could not be scrolled. A
flex-1item defaults tomin-height: auto, so the view holding the board grew to the board’s full content height — 1230px inside an 883px area — and the ancestor’soverflow-hiddencut off the rest. Each column had a working scroller that never reached its limit. -
A failed column subscription rendered as an empty column. Entities cleared, no error surfaced, “No items” under a header still counting eleven of them. It falls back to a one-shot read, reports a failure only if that fails too, and no longer waits out the client’s full 30-second watchdog before painting anything.
-
Date previews required a
Dateinstance, so every audit column in every revision-history entry rendered as a red “Unexpected value” box. History is raw API payload, where a timestamp is still the string Postgres sent. Any value that unambiguously names a date is accepted now. -
Chips lost three quarters of their palette. A cleanup flattened
CHIP_COLORSfrom four tones per hue to one, which left everycolorScheme="blueDark"resolving toundefined— a chip with a colour in its config rendering with no colour at all — and made seeded chips pick from ten schemes, so a five-value enum routinely drew the same background three times. The tones are generated from a per-hue table now, andChipColorKeyis a real union rather thankeyof Record<string, …>, which is why none of it was a type error. -
The Firebase example compiles again. It had not built since the property-options split, which made
urla statement about the data — it feedsformat: "uri"into the OpenAPI contract — and moved presentation toadmin.urlPreview. The example’sadmin: { url: "image" }had both halves in the wrong place, andexpandedlikewise belongs in theadminblock.
-
Useris exported from@rebasepro/clientand@rebasepro/app. The removals above tell a caller to importUserfrom@rebasepro/types, which was not an instruction a browser app could follow: it installs the client (or app) package alone, and@rebasepro/typesis that package’s dependency, not a specifier resolvable from its own project. So the deprecated aliases were removable in a monorepo and stranding anywhere else.Usernow sits besideRebaseSession,AuthTokensandDeviceSession, which were already re-exported for exactly this reason. -
The entity form has a layout. It had exactly one — a single centred column of full-width cards in declaration order — and one escape hatch,
formView.Builder, which replaces the whole form. Nothing in between.There is now a four-column grid, titled sections that collapse, and a metadata rail for the fields that describe a record rather than constitute it. All of it is derived by default: a collection that configures nothing gets a two-column form, its id and audit timestamps in the rail, long text and arrays full width, short enums and booleans narrow.
admin.formis for when the derived answer is wrong. See Form Layout.On the demo’s products form this is 2932px of scroll down to 1587px, and 219px of dead space above the first field down to 24px.
-
The record’s identity and its actions live in persistent chrome. The title, the id and the Save/Discard buttons used to sit inside the scrolling form, so the moment you touched the wheel nothing on screen said which record you were editing. They are in a bar above it now, which is also what let the 320px footer holding two buttons go away entirely.
-
JSON and revision history moved out of the tab strip and into a record inspector. They were the first two tabs — icon-only, unlabelled, ahead of the record you opened the page to edit. They are developer tools, so they sit behind the overflow (
⋮) menu and open in a panel beside the form; the tab strip is for destinations. Old#json/#historyURLs open the inspector on the pane they name. -
Two gates for things that were rotting silently.
pnpm check:examplestypechecksexamples/*, which were in no pipeline and no root script —pnpm buildcovers./packages/*and./apponly — which is why the Firebase example above stayed broken for weeks. They resolve@rebasepro/*to built output the way an installing user does, rather than to source the waypnpm typecheckdoes, so they catch a class of drift the source-resolving gate structurally cannot see.pnpm check:generatedregenerates the committed website artifacts (llms.txt,sitemap.md, the changelog mirror) and fails on a diff.llms.txthad been sitting a commit behind the docs it summarises.
[0.12.0] - 2026-07-29
Section titled “[0.12.0] - 2026-07-29”Breaking
Section titled “Breaking”-
rebase.jsonis rebuilt around one authored runtime — the manifest had four unrelated fields namedmode, an app type (admin) with no mechanism behind it, and a managed-vs-custom distinction nobody had written down.A backend now declares
runtime: "managed" | "custom"— who owns the process, independent of where it runs. It used to be inferred from the presence ofbackend/src/index.ts, which every scaffolded project had, so every project predating the manifest silently landed on the custom runtime. App types reduce tobackendandstatic: the admin is an ordinary static app, becauseRebaseCMStakes its collections as a build-time prop, so a platform-hosted admin was precluded by the component’s interface rather than merely unimplemented. Top-levelruntimebecomesrebase, so the word means exactly one thing. In the bundle manifest,modebecomeskind: "backend" | "static",entry.staticbecomes a list, andentry.adminis gone — format-1 bundles still boot, and the format is 2.backend.mode(cms/baas) is deleted outright. Where collections come from was never an independent choice: it is whether<config>/collectionsexists.Static apps declare a
pathand several are served from one process — the API at/api, a site at/, the admin at/admin, one container. Three ways that could fail silently are now caught: an app built with Vite’s defaultbase: "/"but served at/admin(blank page, every asset 404, no server error) failsrebase build;serveSPAorders longest-path-first and excludes siblings, so a miss under/admincan no longer be answered with the site’s index.html; and folding appends toentry.staticrather than overwriting it, which used to let a second app silently replace the first in a bundle that still looked complete. -
mode: "cms" | "baas"is gone from the server as well — removingbackend.modefrom the manifest left the identical pair standing one layer down:RebaseBackendConfig.mode, authored by anyone who ejects and passed to every driver, plus a wire field, a dev env var and an init flag.It was never independent of the collections. The Postgres bootstrapper already guarded
mode === "baas" && collections.length === 0, so the flag could only agree with them or contradict them — and when it contradicted, the server warned and threw the declared collections away. Everything derives from one question now: did any collections resolve?RebaseBackendConfig.mode— deleted, and derived after the collections directory is loaded, so acollectionsDirpointing at nothing falls through to introspection instead of serving an empty API and never looking at the database.DriverInitConfig.mode→introspectCollections. A driver may contribute collections only when it was asked to describe the schema, so it can no longer inject whatever the database happens to contain into a project that declared its own.RebaseProjectContract.mode— removed from/api/meta/contractand/api/meta/schema-version. Nothing in the CLI, codegen, client or console ever read it.REBASE_DEV_MODE— deleted.rebase init --flavor cms|baas→--headless.
One behaviour change: declaring collections alongside what used to be
mode: "baas"now serves them instead of discarding them. -
The CMS-named exports are called what they are —
useCMSContext/CMSContext→useAdminContext/AdminContext,registerCMS/unregisterCMS→registerAdmin/unregisterAdmin,CMSBasePropertyNoName→AdminBasePropertyNoName,CMSNavigationContent→AdminNavigationContent. Smaller than it looks: outsidepackages/cmsandadmin-typesthese had no consumers.Seven locale files did say “CMS” in user-visible strings — “CMS Users”, “CMS View” and translated sentences in es/pt/de/fr/it/hi — and the two keys carrying it in the public
RebaseTranslationstype are renamed with them. One collision worth knowing about:studio_sql_adminalready existed as a different string, sostudio_sql_cmsbecamestudio_sql_collections_labelrather than being merged onto it.packages/firebaseis deliberately untouched:FireCMS,firestoreToCMSModeland the optionalDataDriver.delegateToCMSModelare heritage from a different product, and renaming an optional method on a public driver contract breaks a third-party driver silently — an unimplemented optional method is simply never called. That waits for a driver-contract major. -
A scaffolded project self-hosts the same artifact Rebase Cloud runs — the template declared
runtime: "managed"and shipped a compose file that built two custom images, one of which ended inCMD ["pnpm","start"]— running the entrypoint the managed runtime never loads, and which is no longer scaffolded.docker compose upon a freshrebase initwas not merely inconsistent with the project’s own manifest; it was broken, building an image around a file that did not exist.The scaffolded compose now runs the managed shape — Postgres, plus
rebasepro/serverwith./dist-bundlemounted — so one container serves the API at/apiand the admin at/, same origin, no CORS between them and no nginx. The frontend image and itsnginx.confare gone for the same reason the backend one is: the runtime serves those assets.Image-building moves into
rebase eject, which writes the Dockerfile and adocker-compose.custom.ymltogether and does not touch the scaffolded compose — so going back stays a one-line change inrebase.jsonrather than a restore from git. -
Nine presentation options move into a property’s
adminblock —fixedFilter,includeIdandincludeEntityLinkon a reference or a relation;widgeton a relation;sortableandcanAddElementson an array;previewPropertieson a map. The collection half of that split shipped in 0.11 and moved all 38 keys; the property half moved most of its options and left these behind, under a section marker inproperties.tsthat read─── UI configuration ───. A backend-only install went on shipping them with nothing to render them.tags: {name: "Tags",type: "relation",relation: { kind: "manyToMany", target: () => tagsCollection },widget: "dialog",includeId: false,admin: { widget: "dialog", includeId: false },}Writing one at the top level is now a config error naming the fix, the same way the 0.11 collection keys are —
validate-configreads them offADMIN_PROPERTY_KEYS, so nothing is silently ignored.widgetis the one to check first, because it was never working:AdminRelationOptionsalready declared it and the admin only ever read that one, so every top-levelwidget: "dialog"had been quietly rendering aselect. Moving it intoadminis what makes an existing declaration take effect.Two options that look like the same case stayed on the property, and deliberately:
propertiesOrder, becausesortPropertiesin@rebasepro/commonreads it recursively and a driver calls that — a core package cannot see theadminblock at all; andkeyValue, because it says the map has no declared shape, which is what the OpenAPI generator emitsadditionalPropertiesfrom. -
The SQL-only fields are rejected on a document-store collection —
table,relationsanddisableDefaultPoliciesare declared onPostgresCollectionConfigalone, andcolumnType/columnNameare omitted from the Firestore and MongoDB property maps. A MongoDB collection could be written with a table name and acolumnType: "bigserial", and nothing anywhere read either.DataSourceCapabilitieshad been reporting this all along —supportsRelationsandsupportsColumnTypesare bothfalsefor the document engines — and the engine-specific collection and property types existed too. The two were never joined, so call sites checked the capability at runtime and then read a field the base type had to declare for them. That is why the fields were on the base.Engine-agnostic code narrows with the new
isRelationalCollectionConfig, which is that capability check with the narrowing attached, so a custom SQL engine registered throughregisterDataSourceCapabilitiesis included rather than excluded by a hardcoded"postgres".securityRulesis not part of this and stays driver-agnostic. It is a contract about who may read and write which rows, and each engine keeps it its own way: Postgres compiles it toCREATE POLICY, MongoDB translates it into a filter AND-ed into every read and write.supportsRLSanswers whether an engine generates policies, which is a different question from whether it honours a rule.
-
rebase cloud deployneeds no flag on a managed project — a bare deploy used to be refused with “redeploy it withrebase cloud deploy --bundle”. The refusal existed because forgetting the flag meant the command built a container image and ejected the project — a plausible mistake with an expensive outcome. Now that the backend declaresruntime: "managed", the flag is redundant and the bare command builds and ships a bundle.--sourceand--bundle-dirare explicit acts and still win, and the refusal stays for the case it was written for: a manifest that sayscustomdeploying over a project the platform runs. -
rebase cloud statussays which runtime and which framework a project is running — it reported no runtime information at all, so “what is actually serving this project” had to be assembled by hand from a Docker tag, a manifest and a pod. Three numbers are in play and two of them look interchangeable: the runtime version is the contract line a bundle’s range resolves against, the framework version is the@rebaseprorelease the runtime image ships, and a project can legitimately run runtime 1.2.0 — whose image was built against framework 0.10.0 — while its own bundle installs 0.11.0 at boot. -
The login screens can offer a newsletter opt-in —
LoginViewtakes anonNewsletterOptInprop and renders a checkbox on the sign-in, register and bootstrap forms, translated in all seven locales. It fires only once the credentials are accepted: a ticked box on a failed attempt must not subscribe an address whose owner never proved they control it. The state lives inLoginViewrather than the form, so switching between login and register does not drop the tick. Entirely opt-in — a panel that passes no handler renders no checkbox. -
A drawer group can carry the icon, and its entries indent beneath it — a long navigation rendered as one flat column: every entry had an icon of its own, and the group headers organising them sat at 11px in
surface-400, below the contrast of the rows they label. The thing you scan to find anything else was the quietest element on screen, and thirty entries gave no visual sign of which belonged together.NavigationGroupMappingtakes aniconnow — a Lucide name, like every other icon in a collection. Declaring one moves the anchor from the rows to the group: the header takes the icon, and the entries below trade theirs for an indent of the same width, so labels stay on the original grid and the rail does not change size. The label steps up to 12pxsurface-600to match, since it is now what carries the hierarchy.Strictly opt-in, and per group. A group that names no icon renders exactly as it did — same classes, entries keep their icons — so an existing panel sees no change until it asks for one. Two cases stay flat regardless of configuration: a group with no header has nothing to indent under, and a drawer collapsed to a rail keeps its entry icons, because there they are the only thing left to click.
-
defaultDrawerOpen— open the navigation expanded — the drawer started collapsed to a rail with no way to change it.autoOpenDrawerlooks like the prop for this and is not: it expands on hover, and always has, thoughRebaseLayoutdocumented it as “auto-open the drawer on load” whileScaffolddocumented the same prop as “open the drawer on hover”. Both docs now say the same true thing.The new prop seeds the initial state and nothing more — no effect syncs it afterwards, so a user who collapses the drawer is not re-expanded underneath them on the next render. Ignored on small layouts, where an expanded drawer covers the content it exists to navigate.
-
The shell takes a
logo—Scaffoldaccepted one and rendered it in the drawer and top bar, but nothing passed it down, so the prop was unreachable fromRebaseShell— the component a scaffolded app actually mounts. Threaded throughRebaseShell→RebaseLayout→Scaffold. -
An entity action’s icon can be a Lucide name —
EntityAction.iconwasReact.ReactElement, alone among a collection’s icons;admin.iconandentityViews[].iconwere strings already. An element cannot be written in theconfigpackage at all: it is plain.ts, and a backend loads it for its schema, so importing the UI layer just to name an icon drags React into the server’s module graph. Both forms are accepted now and resolved throughgetIconat every render site. -
A collection’s
entityActionsmay name an app-level action by key —resolveEntityActionhas always acceptedstring | EntityAction, the collection editor stores exactly these keys, and the sibling fieldentityViewsis typed(string | EntityCustomView)[]. Only this field’s type disagreed, so the documented approach — register the action on<RebaseCMS entityActions={…}>, then name it from the collection — required a cast to write.It matters most where the action cannot be imported. An action carries an
onClickand usually opens a dialog, so a collection file that imports one pulls the admin bundle into any backend that loads it; naming it costs nothing there. -
A full-screen entity has a way back to its collection — every other layout can be dismissed: a side panel and a dialog close, a split keeps the list beside it. Full screen replaces the collection outright, leaving browser Back as the only route out — which the page never shows as an affordance, and which is wrong anyway once the reader has moved between tabs inside the entity.
-
A project declares its storage buckets in
rebase.json— storage had one destination and three ways in, and which buckets a project has was declared in compiled config code, so nothing outside the running container could learn it. The console could only ever configure the default bucket, and a named source was reachable only by hand-writingS3_BUCKET__MEDIA— and only on the managed runtime, because the ejected template parsedSTORAGE_TYPEitself and knew nothing about suffixes.Topology moves to
rebase.json, the one artifact a host can read before running a build. The CLI resolves it into the bundle manifest for managed runtimes; a custom runtime reads the same file out of the image it already ships. Both end at the same list, so the console and the tenant cannot describe different topologies. A declared bucket is a topology rather than a boot requirement — declaring one does not fail the boot if its credentials are not present yet. -
iterate()andfindAll(), so nobody hand-rolls the paging loop —find()with manuallimit/offsetwas the whole pagination API, so every consumer wrote the same loop and wrote it wrong in the same two ways: terminating onrows.length >= limit, which mistakes an exactly-full final page for a middle one and drops everything after it, and cappingfindAll-style helpers by silently truncating.iterate()is an async generator that fetches a page at a time and yields rows as they are consumed.findAll()is the same walk collected under a ceiling that throws when hit, because a short array that reads like a complete one is the bug this exists to prevent. Termination comes frommeta.hasMorealone. Offset paging is the default and drifts under concurrent writes —cursor: "id"switches to keyset seeking, built out of parametersfind()already takes, so it needs nothing new from the server and works on every transport. -
Filters on a relation, for every kind the driver can compile —
isFilterableRelationallowed onlybelongsTo, the one kind with a column on this row. The driver compilesmanyToMany,hasManyandhasOneinto a correlatedEXISTSnow, so the affordance returns for them;viastays out, its join path having no stated inverse. A to-many relation also answersarray-containsandarray-contains-any— a to-many is the list, so “contains X” is==and “contains any of” isin, the sameEXISTSunder a different name. Before, the admin rendered those controls and the driver returned a 400 behind them. -
supportsVectorson a data source’s capabilities —VectorPropertycarries adimensionsand is pgvector-shaped, and it was the one driver-specific property kind with no flag to gate it, so unlike every other field in that descriptor there was not even a runtime answer to appeal to: a Firestore collection could declare an embedding and no driver would do anything with it. Postgres claims it; the document stores do not, andvectoris now excluded from their property maps alongsiderelation. -
Every collection config is strict-parsed at boot — nothing checked these files. A config written against an older version loaded clean and whichever keys had moved were ignored: no warning, no log line, no failed boot. The collection still served rows, so the only signal was the feature quietly not being there — an icon that never appeared, a
readOnlyfield the panel let you edit, a relation that answered[]. The renames were never the problem; a rename with no runtime signal is.assertCollectionConfigsruns at the loader — the one definition of “the collections” — so the runtime, the drizzle generator, the policy generator and the doctor reach the same verdict. It is also what turns the property-block move above into an error naming the fix rather than a silently ignored key. -
The cron scheduler warns when in-process timers cannot fire — jobs are driven with
setTimeout, and on a platform that freezes or evicts the instance between requests (Cloud Run at--min-instances=0, Lambda, Vercel) those timers never fire. The failure was completely silent: the server booted, logged the jobs as registered, and ran nothing. Detected from documented runtime env vars and warned once at scheduler start. Kubernetes pods are excluded, so a GKE Deployment never warns. Nothing here can fail a boot.
-
The API docs disappeared from every project the runtime boots —
REBASE_ENABLE_SWAGGERdefaulted to a flat"false", which reads as a safe default and was not one: the runtime is how every scaffolded project boots, so/api/docsand/api/swagger404’d for projects that never asked for that.rebase initprints “docs are at /api/swagger” on completion, the headless README repeats it, and the console’s API Explorer fetches/api/docs— all three were broken against a project running the runtime.The variable is tri-state now and resolved against
NODE_ENV: unset means on in development and off in production, and an explicittrueorfalsewins in both. Unset in development resolves to undefined rather thantrue, which hands the decision to the server’s own policy — the one that already knows to serve the spec while withholding the Swagger UI. Two defaults that can disagree about the same route is the bug this replaces, so there is only one now. -
A backend with
allowRegistration: falsewas a dead end on a fresh database —GET /auth/configreportedregistrationEnabledwhileneedsSetup, the login UI showed the first-admin form on the strength of that, andPOST /auth/registerthen refused it.POST /admin/bootstrapcould not break the tie either, since it requires an authenticated caller and an empty database cannot produce one. Hit live on a deployed project.The register gate now admits the first registration when the user table is empty — a paginated count, not an unbounded list, since this path serves anonymous callers — and the existing auto-promote makes that user an admin. One user in and the flag binds again; a racer that slips past the empty check is deleted and refused, so the window can never mint a second account.
disableSelfRegistrationstays a hard kill switch above even bootstrap, and/auth/configstops advertising registration when it is set instead of pointing the UI at a form that can only 403. -
@rebasepro/serverloaded twice in one process left every custom function without a singleton — under the managed runtime this is the normal layout, not an edge case: the image ships the framework at/app/node_modules, while a bundle installs its own dependencies into/bundle/node_modules, where@rebasepro/serverarrives transitively. Every custom function importsdefineFunctionfrom@rebasepro/server, so functions held the bundle’s copy whileinitializeRebaseBackend()initialized the image’s. With the instance in a module-local variable,rebase.data,rebase.dataAsAdminandrebase.storagethrew “server not initialized yet” on every request to every custom function — in a process that booted cleanly, served/api/data/*fine and reported itself healthy. Observed in production as 100% of one tenant’s document routes 500ing while the rest of the app worked. -
The documented
wherequery parameter was never read — the OpenAPI document publisheswhereon everyGET /api/data/{slug}and the relations docs use it to narrow a subcollection list, butparseQueryOptionsnever looked at it. It was also missing fromreservedQueryKeys, so it fell through to the per-field?field=op.valueloop and compiled as a filter on a column literally named “where”, which no table has — meaning the documented way to filter a list returned the entire table, bounded only by whatever RLS allowed, until unresolvable fields started failing closed and it became a hard 400 instead. It is parsed as JSON and normalized through the samedeserializeFilterthe querystring dialect uses, so{"status":["==","active"]},{"status":"eq.active"}and{"status":"active"}compile to one condition — and unlike the querystring, JSON carries types, so[">=", 18]stays a number. -
serveSPA404’d routes that merely shared a prefix — exclusion was astartsWith, so/apiexcluded/apidocsand/adminexcluded/administrators. Both are ordinary client-side routes of an app rooted at/, and both 404’d: the SPA fallback declined them and nothing else claimed the path.apiBasePathis always in the exclusion list, so this was never limited to the multi-app setups the list was added for — a single SPA with a route under/api<something>hit it too. Matching is by path segment now. -
A deliberate 400 was reported as a database failure —
sanitizeErrorForClientonly knew how to unwrap Postgres errors, so a thrownApiErrorlost its message, its code and its status on the way to the client and took alogger.errorline with it. That is the whole diagnosis for a realtime subscription: the admin list prefersaccessor.listen, so an unknown filter field arrived as an opaque failure and every notify-triggered refetch logged at error as if the database had gone down. A 4xx short-circuits ahead of the Postgres extraction now and passes its message and code through untouched, logging at debug or warn per the error’sexpectedflag. 5xx is unchanged: still a generic message, still logged at error, so internals stay server-side. -
rebase schema generateemitted a schema that does not compile —rel.localKeyis a column name and the generated Drizzle object is keyed by property. They coincide until a property is camelCase —userIdstored inuser_id— and then the emitted relation references a key that is not there:Property 'user_id' does not exist on type … Did you mean 'userId'?. Three of the four relation-emission sites already normalised throughresolvePropertyKeyForColumn; thebelongsTobranch did not. It hid because the existing test’s collection declares no property matching the FK column, so the resolver fell through and returned the column unchanged — identical output either way. -
The runtime image did not ship the S3 and SMTP drivers it loads — the runtime implements S3 object storage and SMTP email and pulls their drivers in with
await import(...), but the image never installed them, and the import resolves relative to the runtime’s own location: a project declaring@aws-sdk/client-s3in its bundle does not satisfy it, because that copy lands off the resolution path. The failure is nasty precisely because it is so narrow — the tenant boots clean, passes every health probe, serves every other route, and fails only on storage writes. -
The runtime deduped every
@rebasepropackage, not just the one that needs it — the first cut of the singleton fix redirected every@rebasepropackage the image ships, which took tenants down: the image installs only the narrow dependency set the runtime itself needs, while a bundle’s own install resolves each package’s full tree, so redirecting@rebasepro/server-postgrespointed the database driver at a copy with nochokidarand the pod crash-looped.@rebasepro/serveris the only package that both needs the redirect and is provably safe to redirect. -
Two published packages imported dependencies they never declared —
@rebasepro/firebasedeclaresfirebaseas a peer dependency, but every source file imported the scoped subpackages —@firebase/app,@firebase/auth,@firebase/firestoreand four more — which appeared only in devDependencies. Rollup externalises every bare specifier, so those imports survived into the publisheddistverbatim. They resolve by accident under npm and yarn, whose hoisting puts them at the top level, and fail under pnpm’s isolated layout — so the package type-checked, built and tested green, then broke on first import for an installing user.@rebasepro/inferenceshipped the same way with two packages. -
A scaffolded project could not run
rebase build—backend/tsconfig.jsonandconfig/tsconfig.jsonlefttypesunset, so TypeScript swept every reachablenode_modules/@typesand treated each folder as an implicit type library. Under pnpm that reaches the virtual store, where packages hoisted for peer resolution live —dompurifyamong them, pulled in transitively by the admin editor — and every scaffolded project failed withTS2688: Cannot find type definition file for 'dompurify'. -
A custom runtime was built a bundle it never deploys —
rebase buildhad noruntimecheck, so an ejected project — whose artifact is an image built from its own Dockerfile and entrypoint — still got adist-bundle/produced for it. That is worse than doing nothing, because the bundle looks like the thing that ships. A custom backend is skipped now, naming the two commands that actually build it; static apps in the same repo still build, since an ejected entrypoint serves them itself viaserveSPA. -
The headless scaffold’s backend had nothing to compile — moving
storage.tsinto the config package leftbackend/src/empty in the headless flavour: it declares no collections, so there is no generated schema, and the entrypoint moved behindrebase eject. The tsconfig still saidinclude: ["src/**/*"], and tsc reports an include matching nothing asTS18003— an error, not a no-op — so the backend workspace failed to build on every headless scaffold. -
The client SDK could not create an admin API key —
admin: trueis what grants a key theadminrole: the admin-gated routes, and the RLSdefault_adminpolicies.@rebasepro/clientdeclared its ownCreateApiKeyRequestwithout the field, under a comment saying these types lived in the server package rather than in@rebasepro/types— which had stopped being true, and the copy had drifted. Passingadmin: truewas an excess-property error, so the one privileged thing about a key was unreachable. There is one declaration now, in@rebasepro/types; the client and the server both re-export it. -
A history entry’s
updated_atwas astringfrom Postgres and aDatefrom MongoDB — the same interface name in two driver packages, plus a third spelling in the admin’suseHistoryhook, so nothing could read history without first choosing a driver.EntityHistoryEntryin@rebasepro/typesis the wire shape and carries astring. MongoDB’sDatewas never the contract, only its storage: the driver keeps that for its own document and converts on the way out. -
The collection editor dropped fields on save — it round-trips a collection through a hand-written serializable mirror whose whitelist had fallen behind the core types by six fields. Editing a collection in the panel silently unset whichever of them it had.
Two mattered.
excludeFromApiis the server-side guarantee that a column — a password hash, a verification token — never reaches an API response; opening such a collection in the editor and saving published it. And collection-levelrelationshad no serializer at all, so importing an existing table detected its foreign keys and junction tables, showed them on the form, and discarded every one on save. The other four werestrictWrites,disableDefaultPolicies,filterOperatorsandurlPreview;urlwas being dropped too, and it feeds the generated OpenAPI contract. -
Importing a table wrote a relation shape the framework no longer accepts —
pgColumnToPropertyexisted in two copies. The one the collection editor called emitted the pre-union flat relation (cardinality/direction, replaced by thekindtagged union) and CRUD verbs whereSecurityOperationtakes SQL ones, typedany[]at both sites so neither showed up. The correct copy was the one in@rebasepro/studio, which had the tests and was called from nowhere. There is one now, in@rebasepro/common. -
The Studio JS editor autocompleted a query shape the server rejects — its ambient SDK declarations are hand-mirrored and had drifted: ten Firestore-era filter operators with no
like/ilike/is-nullfamily,whereasRecord<string, string>where a filter is an[operator, value]tuple, andorderByas a bare string rather than a[field, direction]pair. A bare string reaches PostgREST and builds a malformed query. The operator union is now interpolated fromALL_WHERE_FILTER_OPS, so that part cannot fall behind again. -
A many-to-many child listing failed on a column that does not exist — the junction’s columns were passed into the
EXISTSsubquery as bare Drizzle columns. A column object carries no table qualifier of its own; it renders against whatever table the surrounding builder believes is current. Insidedb.query.findMany, which aliases the root table, that producedEXISTS (SELECT 1 FROM "body_area_podcast"WHERE "podcast"."podcast_id" = "podcast"."id" AND "podcast"."body_area_id" = $1)— the junction’s columns wearing the target’s alias. Postgres aborts the transaction on the unknown column, and the fallback read then fails on
25P02 current transaction is aborted, three frames away from anything to do with the relation.The junction is aliased and referenced by identifier now, exactly as the
joinPathbranch beside it already did; only the correlation stays a column object, because that one has to bind to the outer row. The alias also disambiguates a self-referential many-to-many, where the junction and the target are the same table. The old form rendered correctly in isolation and only corrupted inside the query builder, which is why unit tests asserting on result counts never saw it — the new ones assert the emitted SQL. -
An auth collection that named
reset_passwordgot two Reset Password buttons — the injector skips its action when the collection already has one, but it read.keyoff every entry, and an entry may be the key itself. A collection that named the action rather than importing it was therefore never recognised as already having it, and the injection ran on top. -
An empty
inlist returned every row —filter: { id: ["in", teamIds] }with no teams is how a caller asks for nothing, and it answered with the whole table: an empty list built no condition, and an absent condition is not a restriction. It needs no typo to reach, because an empty array is exactly what a correct program produces when the set it derived came out empty.in []is FALSE now andnot-in []is TRUE, which is what excluding nothing means;array-contains-any []overlaps nothing. A non-array operand was dropped too, and that one arrives over the wire —?filter=id.in.5parses to the string"5", since the REST dialect only builds an array when the value is parenthesised, so an ordinary REST query ran unfiltered. A scalar is now the one-element list it means. -
An unresolvable filter field widened the read — a filter key matching no column was logged and dropped. Dropping a condition can only widen a result set, so a typo’d or renamed key ran the query without it and returned everything RLS happened to allow. Inside
or(...)it was worse: the leaf vanished from the disjunction, so the surviving branches matched on their own and the widening was not bounded by the condition that went missing.Both sites resolve through one helper now, which throws a 400
UNKNOWN_FILTER_FIELDnaming the field, the collection and the table’s real columns.unknownFilterFields: "warn"on the driver config restores the old drop-and-continue behaviour verbatim. -
An owning relation’s key is its
localKey, not<field>_id— the filter resolver guessed the column name. The real one is the relation’slocalKey, whose default is snake-cased and singularised, souserProfileisuser_profile_idandusersisuser_id— and an explicitlocalKeyis anything at all. With unresolvable fields now failing closed rather than widening, that guess turned an ordinarybelongsTofilter into a 400. It resolves through the collection’s relations, keeping the two derivable shapes as a last resort. -
The SDK answered in two different relation shapes —
data.jobs.find()anddata.jobs.find({ include: […] })disagreed. Withincludethe accessor ran the REST pipeline, which inlines a relation as the target’s own columns; without it, the driver eagerly loaded every relation and put a{ __type: "relation" }envelope where the foreign key was.findByIdwas always the second. The generated types described only the envelope, so a column the schema calls a foreign key was typedstringand arrived as an object — twice, in production, before anyone traced it here. Every SDK read goes through the REST pipeline now, which is what the HTTP API already serves for the same query. -
“Posts with no tags” returned no posts — the null checkbox was hidden on a to-many relation, and asking which rows have no link is the question a filter on a link is most often for. Showing it was not enough: the design is that the operator carries the sense and the checkbox supplies the value, and on a to-many the multi-select can only produce
in/not-in, neither of which carried a null — the relation path read["in", null]as membership of an empty list. -
The filter UI asserted Postgres on every engine’s behalf —
isFilterableRelationhardcoded the four kinds the Postgres driver compiles. Only Postgres declaressupportsRelationstoday, so the claim happened to be true, but it was a fact about a driver stated where no driver could see it. It moves toDataSourceCapabilitiesbesidefilterOperators, where the same question is already answered for operators. The field is optional, so a third-party driver registered before it existed still compiles. Two related fixes: the operator now decides how many values a relation filter takes, and a relation with no column to filter on is no longer offered one. -
A relation declared inline on the property rendered an error instead of a field —
RelationFieldBindingdemanded a top-levelrelationsarray before it would render, and the inline form —relation: { kind, target }on the property, which is what the docs show — produces no such array. Every collection declaring a relation that way threw and rendered the error boundary where the field should be. The guard was redundant as well as wrong:resolveRelationPropertyhandles all three forms and reports a real error naming the property and the collection when it genuinely cannot resolve. -
A server-side client with no credential now says so — a
createRebaseClientbuilt with no token off-browser is silently anonymous, and RLS answers it with whatever is public: usually nothing, occasionally the wrong thing. Warned once per client on the first request rather than at construction, sincesetToken,setAuthTokenGetterand a server-side sign-in all land after the constructor. Deliberately narrow: anonymous is an ordinary state in a browser, and warning there is noise that teaches people to ignore the warning.
[0.11.0] - 2026-07-27
Section titled “[0.11.0] - 2026-07-27”Breaking
Section titled “Breaking”-
buildCollectionandbuildPropertyare removed — not deprecated, removed. Both were FireCMS-migration shims that had been superseded bydefineCollection, and keeping a deprecated alias around in a framework that has not shipped 1.0 only buys two ways to write the same thing.buildCollectionwas a plain identity function whose generic had to be supplied by hand, so it gave up the property inference that is the entire reason to wrap a collection literal at all.defineCollectionuses aconsttype parameter to capture the literal, which is what puts your property keys into completion foradmin.titleProperty,admin.sortandadmin.propertiesOrder.buildPropertywrapped a single property in a conditional type that resolved to the type the property already had — a no-op once the surrounding collection is inferred.import { buildCollection, buildProperty } from "@rebasepro/common";import { defineCollection } from "@rebasepro/cms-types";export default buildCollection({export default defineCollection({name: "Posts",slug: "posts",table: "posts",properties: { title: buildProperty({ name: "Title", type: "string" }) }properties: { title: { name: "Title", type: "string" } }});A plain
const posts: CollectionConfig = { … }annotation still works and is still typechecked — it just infers nothing, so preferdefineCollectionin new code. The scaffold templates and every docs example now use it. -
whereandorderByare now checked against the row type —FindParamswas not generic, so itswherewasFilterValues<string>and itsorderByan untypedOrderByTuple. Passing a generatedDatabasetocreateRebaseClienttyped the rows correctly but not the query:find({ where: { nonexistent_column: ["==", 1] } })compiled, then came back as a 400 from the API — or matched nothing at all, which is worse.FindParams<M>now carries the row type, and a column that does not exist is a compile error.A dotted path (
"meta.tag") still works for reaching into amap/jsonb column; its root must be a real column.includeis unchanged — relation names come fromrelations, not from the row type, so nothing inDatabasecan check them.Mdefaults toRecord<string, unknown>all the way through, so an untypedcreateRebaseClient()behaves exactly as before. The chain that has to stay intact iscreateRebaseClient<DB>→SDKCollectionClient<M>→FindParams<M>→FilterValues<FieldPath<M>>; a non-generic alias anywhere along it silently flattensMback to the default, which is precisely how the re-export inclient/src/transport.ts(export type FindParams = TypesFindParams) hid this.e2e/baas-typecheck/src/sdk.tsnow pins it with@ts-expect-error, sopnpm check:baas-typesfails if the check ever comes back off.The fluent builder is unaffected:
.where("status", "==", "draft")was already typed on its parameters. Its internal accumulator stays keyed bystring, because aPartial<Record<FieldPath<M>, …>>is read-only under a genericM(TS2862) and cannot be built up in place. -
The
adminblock’s key fields are now checked against the collection’s properties —titleProperty,sort,propertiesOrderandlistPropertiesreject a name that is not one of your properties. Previously they accepted any string, so a removed or misspelled field was found by noticing a column had quietly vanished from the panel.The cause was one line.
augment.tsmerged the block on asadmin?: AdminCollectionOptionswith no type arguments, soMfell back to its defaultRecord<string, unknown>,Extract<keyof M, string>widened tostring, and every key-shaped field accepted anything.defineCollectioncomputed the property-key inference correctly the whole time; it was dropped at that seam, one line short of the field that needed it. The completion those fields’ docs promised had therefore never worked.Three non-property forms are still accepted: a dotted path into a
mapproperty ("profile.displayName"— the root is checked, the path below it is not), a child-collection column ("subcollection:orders"), and anadditionalFieldskey. That last one needs an explicit cast, becauseAdditionalFieldDelegate.keyis a plainstringand nothing carries those keys into the type:import type { AdditionalFieldKey } from "@rebasepro/cms-types";propertiesOrder: ["title", "score"]propertiesOrder: ["title", "score" as AdditionalFieldKey]Only
defineCollectionturns the check on — it is what suppliesM. A plainconst x: PostgresCollectionConfig = { … }annotation infers nothing, so these fields stay permissive there, exactly as before. A type-level test inpackages/cms-types/test/admin_collection.test.tsnow pins all four fields with@ts-expect-error, so the seam cannot reopen without a build failure. -
CollectionConfigreports Postgres in its type errors —CollectionConfigis a union discriminated onengine, and Postgres collections omitenginebecause it defaults to"postgres". An incomplete Postgres literal therefore matched no member, and TypeScript elaborated the failure against the last constituent — MongoDB. Leaving outname, the most common mistake there is, told a Postgres user of a Postgres-first framework that they were missingengineon aMongoDBCollectionConfig. Postgres is now last in the union, so the same mistake namesPostgresCollectionConfigand only the field actually missing. No runtime or assignability change; error text only. -
Admin-panel presentation moved into an
adminblock — a collection carried two unrelated concerns in one flat object: what the data is (table, schema, properties, relations, validation, security rules, callbacks) and how an admin panel should draw it (icon,group,listProperties,kanban, entity views, selection controllers, …). Ninety-five fields of the second kind sat beside the first, and twelve React view-model types were exported fromcollections.ts— so a backend that never renders anything still pulled the React layer into its type graph, and@rebasepro/typescould not be a backend contract while it depended on React.@rebasepro/typesis now the React-free BaaS contract; the presentation layer lives in a new@rebasepro/cms-typesthat depends on it, and nothing in core depends back.pnpm check:baas-typestypechecks a full BaaS project — backend, driver, collection file, SDK reads and writes — withreactmapped to a stub, which is the invariant that keeps it that way.What to change. Move presentation fields into
admin:export default {slug: "posts",table: "posts",icon: "FileText",group: "Content",propertiesOrder: ["id", "title"],sort: ["updatedAt", "desc"],properties: { /* … */ },admin: {icon: "FileText",group: "Content",propertiesOrder: ["id", "title"],sort: ["updatedAt", "desc"]}};The backend loads the block and never reads inside it, so a project with no admin panel can drop these fields entirely. For completion and checking inside
admin, author withdefineCollectionfrom@rebasepro/cms-types— it captures the property literals, soadmin.titleProperty,admin.sortandadmin.propertiesOrdercomplete over your own property keys instead ofstring. -
A relation declares a
kind, and carries only the fields that kind uses — a relation was one open interface with every join field optional at once:cardinality,direction,localKey,foreignKeyOnTarget,through,joinPath,inverseRelationName. Nothing stopped you combining fields that cannot coexist, so the type accepted several relations that could not work — and two of them corrupted data rather than erroring.cardinality: "many"with alocalKeywrote the foreign key onto the parent row, because a to-many has no single row to point at; a many-to-many carryingforeignKeyOnTargetclaimed a column on the target that the junction table owns. Both compiled, and both were shipped.Relationis now a closed union discriminated onkind, and the link moves under arelationfield on the property:author: {name: "Author",type: "relation",target: () => usersCollection,cardinality: "one",direction: "owning",localKey: "author_id"relation: {kind: "belongsTo",target: () => usersCollection,localKey: "author_id"}}The five kinds, and where each keeps its key:
belongsTo(one row, key on this table,localKey),hasOne/hasMany(one or many rows, key on the target,foreignKeyOnTarget),manyToMany(many rows through a junction,through), andvia(reached by joining across several tables,joinPath). Offering a field its kind does not own is now a compile error, so the two corrupting shapes above are unrepresentable rather than merely discouraged.viais the only kind that still states acardinality, because a join chain cannot imply one, and it is read-only — Rebase will not guess which hop of a chain a write belongs to.directionis gone: which side holds the key is what the kind says.inverseRelationNameis gone with it; the schema generator finds the counterpart by scanning the target’s relations.scripts/codemod/relations-tagged-union.mjsmigrates a codebase — it rewrote 232 declarations across 46 files here. It refuses to guess: anything it cannot decide is markedkind: "AMBIGUOUS"for you to resolve, rather than being given a plausible default.Internally this splits the authored surface from a resolved form. Every consumer now reads a
ResolvedRelationwith defaults already filled in andwritable/shareddecided once, instead of each site re-deriving them from optional fields — which is how the write guard and the admin had drifted into disagreeing about whether aviacould be written through. -
A relation whose names do not exist now fails at boot instead of returning nothing — the union settles a relation’s shape; it cannot know whether
posts_tagsis a table, whetherauthor_idis a column, or whether ajoinPathactually connects the tables it names. Those are facts about the database. Nothing checked them until a query ran, and the failures were the quiet kind: a missing junction table logged a warning and returned no rows, soposts/1/tagsanswered[]— the same answer a correct relation gives for a post with no tags. The tab rendered, the tab was empty, and nothing said why.The registry now validates every resolved relation against the schema it will run on and refuses to start if any of them cannot resolve, listing all of them at once with the columns actually available and the edit that fixes each. Fatal rather than a warning deliberately: a server that will not boot costs a minute, and a relation that quietly answers “nothing” costs however long it takes someone to notice their data is missing.
It fails open wherever it cannot see enough to be sure — a collection with no registered table, a target belonging to another backend — because blocking boot on a working project is worse than missing one bad relation. The sharpest case it catches is the junction default:
through.tableis derived from the two table names sorted and joined, so renaming a table silently re-points the relation at a name that was never created.
-
rebase-rls-check— audit row-level security on any Postgres — a standalone, read-only CLI that reads a database’s catalog and reports what is actually exposed. It runs against any Postgres — Supabase, Neon, RDS, a self-managed server — and needs no Rebase project, which is the point: it has to be worth running for someone who will never adopt the framework.Fourteen checks, three of them taken straight from bugs this codebase shipped and debugged: a bare column inside an
EXISTSsubquery binding to the inner table, junction tables left open while both endpoints were locked, and RLS enabled with no policies serving an empty collection for weeks.Two constraints the design treats as non-negotiable. False positives are worse than misses — checks that cannot see intent are marked heuristic, rendered separately and phrased as questions, and severity is calibrated per platform (
policy-anonymous-tautologyis critical on Rebase and PostgREST but only low on Supabase, whereauth.uid()genuinely returns NULL for anonymous callers, so flagging it there would fire on nearly every Supabase database alive). And credentials never surface — the connection string is redacted everywhere including the auth-failure path, and the redactor refuses to guess when an unencoded@or/makes the authority boundary ambiguous rather than printing part of a password as a host.See RLS Check.
-
Existing rows can be attached to a many-to-many tab — a junction-backed relation reads as set membership on write:
PUT parent/:id/child/:childIdlinks a row idempotently. Previously the junction row was written only alongside an insert, so a linked tab could create new rows and never attach one that already existed. Unlike an owning foreign key this takes the row from nobody — its other parents keep it — which is why linking is safe here where reparenting would not be. The admin surfaces it as Add existing on a linked tab, opening the picker over the whole target collection. -
geopointandbinaryare real field types in the admin panel — both were in the property model with nothing behind them.geopointwas missing from the widget lookup altogether, so it resolved to no field: the column never rendered on a form, and its property dialog opened showing a name, a description and no type-specific settings — indistinguishable from a property that has none.binaryresolved to the plain text field, which offers multiline, markdown and email (none of which mean anything for bytes) and whose editor mergestype: "string", so touching a binary property’s widget silently changed its type.Both now have a field binding, a widget config and a place in the property picker. Geopoint is two coordinate inputs rather than a map, because a map needs a tile provider, an API key and a network, none of which belong in a field that has to work offline; it holds a half-typed location rather than committing it, since sending the empty side through
Number("")yields a perfectly finite0and would drop the point in the Gulf of Guinea. Binary shows a collapsed card with the decoded size and expands only when someone wants to edit the base64.vector_inputjoins them in the picker. It had a binding and an editor already and was simply never listed, so a vector property rendered correctly once it existed but could only be created by writing code. -
A project is a bundle, and the runtime is the platform’s —
rebase buildnow emitsdist-bundle/: compiled collections, functions, crons and schema plus a generatedmanifest.jsonrecording the runtime range it needs, aschemaVersionhash, its declared dependencies, and whether it uses native modules.@rebasepro/serverboots it (bootFromBundle, binrebase-server), anddocker/server.Dockerfilepublishes that as an image. The consequence is the point: the engine can be replaced under a project without rebuilding it — upgrading is a new image tag against the same bundle — and self-hosting becomes “run the image with your bundle” rather than “build and maintain your own container”.docker/docker-compose.selfhost.ymlis that, ready to run.A repo-root
rebase.jsondeclares topology only — the runtime compatibility range and the apps this repository contributes (backend,static,admin,mobile). Schema, rules, hooks and functions stay TypeScript inconfig/, which is the point of the product and does not move into JSON.rebase linkaccepts a self-hosted base URL wherever it accepts a cloud project, and writes an uncommitted.rebase/cloud.json, because a project reference is per-checkout. -
Remote SDK generation from a running project —
GET /api/meta/contract(admin, service-key or admin API-key gated; fail-closed 404 when no auth is configured) serves the collection contract, andrebase generate-sdk --from <link|url>reads it instead of importing localconfig/. A second repository can therefore build a typed client against a backend it does not contain, which is what makes the multi-repo case work at all. The SDK records theschemaVersionit was generated against so drift is detectable;GET /api/meta/schema-versionis deliberately unauthenticated and returns only that hash. -
Collection tables are created at boot, additively — the runtime ensured its auth tables and nothing ensured the project’s, so a backend booted against a fresh database answered sign-in and then
500on every data route.REBASE_MIGRATE_ON_BOOT=ensure(the default) now creates missing tables, columns and enum types before serving. Additive only, permanently: it never drops, narrows or rewrites, so it is safe to run unattended on every start and re-running is a no-op. A removed field leaves its column behind and a rename reads as an addition — destructive changes stay a deliberaterebase db push, with its dry-run and confirmation gate.noneopts out. -
Storage authorization can look up ownership —
storageAuthorizereceived a key, a bucket, an operation and a user, and no way to answer the only question that matters: who owns this object? Ownership lives in a row, so a hook limited to prefix arithmetic on the key expresses no real multi-tenant rule — and it could not fetch that row itself, because the hook is declared in the project’sconfigpackage, which depends on@rebasepro/typesalone and cannot resolve@rebasepro/serverat runtime. The context now carries a trusted, read-only, RLS-bypassing reader (ctx.data). It bypasses RLS deliberately: the hook is the authorization decision, so making it decide through a reader already narrowed by the caller’s permissions is circular. -
Multiple data and storage sources — declare
dataSources/storageSourcesas exports of the config package and configure each by suffixing its env var with the source key:DATABASE_URL__ANALYTICS,S3_BUCKET__MEDIA. Two underscores, because one collides with real variable names (S3_BUCKET_NAME). A source that is declared but not configured fails boot rather than silently falling through to the default database. -
Prometheus metrics —
/metricsin Prometheus text format, off unlessREBASE_METRICS=trueand gated byREBASE_METRICS_TOKEN: request counts and latency histograms per surface, plus process heap, RSS and uptime. Self-hosters can scrape it directly. -
rebase buildfolds a single static app into the backend bundle — the runtime already served a SPA fromentry.static; nothing put the assets there. So a project whose container served its site at/and its API at/apilost the site when it moved to a platform-run runtime: the API answered and every page 404’d. The frontend now travels in the bundle and one runtime serves both, which is the shape the scaffolded template produces.--no-staticopts out. -
Local-first sync in the client SDK (
offline: true) — the data layer keeps a normalized local database of rows rather than a cache of responses, and answers queries against it. A row written offline therefore appears in every filtered list it belongs to (filters, sorting and pagination are evaluated locally), a row edited in one view updates in all of them, andfindByIdanswers for a row only ever seen inside afind. Server responses merge into that database instead of replacing it, so a row carrying unsynced local writes keeps them — the user’s own change never flickers away underneath them.Writes are decided locally: once the client knows the connection is gone it stops attempting requests, so an offline write costs nothing instead of a timeout, and it applies immediately and replays in order when connectivity returns. A write the server rejects is rolled back, along with the queued edits that were built on it — but not a later create or delete for the same row, which stands on its own. Temporary failures (429, 503, a dropped connection) are retried on an exponential backoff instead, up to
maxRetries.observe()/observeById()are the new reactive reads, on every collection client: local-first, de-duplicated, and re-emitted on any local write, replay, rollback, realtime event, or change from another tab. Each result carriesfromCache,hasPendingWritesandpartial, so an interface can say what it is showing. Tabs share the local database and the outbox over aBroadcastChannel, and only one replays the queue at a time.client.offlinegainedstatus()andonStatusChange()for a sync indicator, andisOfflineError()distinguishes “offline with nothing local to answer with” from a request that genuinely failed.A replayed write is recognised rather than repeated. The queue names each mutation with an idempotency key, and the server records what that key answered, so a create whose response was lost to a dropped connection comes back with the row it already made instead of inserting a second one — the case the client cannot detect for itself on a table with a server-assigned id, which is what the scaffold’s collections use. Keys are scoped to the authenticated user and honoured for 24 hours; a backend that cannot store them ignores the header rather than refusing the write, and auth signups are excluded because their response can carry a temporary password.
Writes made while another is in flight are safe too: an edit is no longer folded into a request already on the wire (where it was dropped, unsent, when that request was acknowledged), a delete no longer cancels out a create the server is in the middle of reading, and an update or delete now queues behind a pending write for the same row instead of racing ahead of it to a server that has not seen the row yet.
Not yet: conflicting concurrent edits are still last-write-wins — there is no row version, so two clients editing the same row overwrite each other with no conflict reported.
createManyis not keyed, onlycreate. Wherenavigator.locksis unavailable two tabs may both replay the queue.
Changed
Section titled “Changed”-
No bucket means no file storage, rather than a crash or a disappearing disk — 0.10.0 made a production backend refuse to boot on
type: "local", which stopped the silent data loss but replaced it with a crash-looping rollout for anyone who simply had not configured storage — a project that never uploads a file was taken down by a feature it does not use. Storage is now opt-in instead: with no bucket configured in production, no storage backend is registered,/api/storage/*answers501 STORAGE_NOT_CONFIGUREDwith the fix in the message, and everything else — data, auth, realtime — keeps serving.501and not503, so the client’s offline queue does not retry uploads that can never land.The scaffolded backend matches: it configures S3 for
STORAGE_TYPE=s3and now GCS forSTORAGE_TYPE=gcs, and falls back to local disk only outside production (or withFORCE_LOCAL_STORAGE=true, for a deployment with a real volume mounted). A named backend that is local-in-production is dropped from a multi-backend map without taking the durable ones with it.
-
rebase db pullwrote collection files that would not compile — introspection emits collection source code as template strings, which put it outside every check the relations refactor relied on: the codemod rewrites real declarations and never saw these,tscchecks the generator rather than the code it prints, and the existing tests asserted withtoContain, which passes happily on a field the type no longer has. So introspection went on writingcardinality,directionand a top-leveltargetlong afterRelationstopped accepting any of them.Fixed at every emission site, and the many-to-many case got simpler rather than merely renamed: with no owning and inverse side to choose between, it no longer guesses one from table-name ordering, and no longer hands the losing side a relation with no
throughand a comment asking the reader to finish it by hand. Introspection knows both junction columns already; each side now names them from its own end. -
The relation editor wrote kinds that do not exist — the relation property form still carried its pre-union
Cardinality(one/many) andDirection(owning/inverse) selects. Both had been pointed atrelation.kindwithout the controls being rethought, so their options went on writing the old vocabulary: choosing “One (has-one)” setkind: "one", choosing “Owning” setkind: "owning". Both also rendered fromvalue={kind}while comparing against"one", so abelongsTorelation displayed as “Many (has-many)” and “Inverse” at once — the form disagreed with itself, disagreed with the stored value, and offered no way to pick a real kind.It is one Kind select now, driven by a table shared with the relations tab so the two surfaces cannot describe the same thing differently, and typed so a sixth kind cannot be added to the union without failing to compile there. Three more in the same dialog: saving cast the draft straight to a
Relation, so a junction table filled in and then abandoned by switching to “Belongs to” was persisted alongside alocalKey— exactly the shape the union exists to forbid, smuggled past it by a cast; picking “Via” offered no way to enter a join path while Save stayed enabled, producing a relation with an emptyjoinPaththat joins nothing; and the relations table declared five header cells while rendering four, sokindappeared under a “Cardinality” heading and “Direction” had no cell at all.The JSON path was never affected —
validateCollectionJsoncheckskindagainst the union and rejects fields a kind does not own. Only the form drifted, because nothing typechecks a select’s option values against what its handler writes. -
The collection editor could not round-trip a relation —
targetis a() => CollectionConfigthunk, which cannot be written to JSON, so it travels as a collection slug. Nothing rebuilt it on the way back: the deserializer had no branch for relations, so one fell through to the pass-through default and returned withtargetstill a string, while every consumer in the codebase callstarget(). The cast toPropertyat the end of that function erased the difference, so it compiled and shipped. Serialization is now switched onkindand assigned without a cast, andfromSerializableCollectionConfigsrebuilds the thunks against the whole set — resolving lazily, so collections may reference each other in any order. -
Generated OpenAPI documented none of a collection’s subcollections — the spec read
relationNamestraight off the authoredrelationsarray. That name is optional and defaults to the property key or the target’s slug, so every relation relying on the default was skipped, and relations declared inline on a property were never seen at all, since they are not in that array. A collection could show three subcollection tabs in the admin panel and document zero. The routes now come from the resolved relations — the same names the nested-path router matches — in a second pass after every component schema exists, which also fixes subcollections whose target appeared later in the array silently degrading to an untypedobject. To-one relations are left out:posts/1/authorresolves, but documenting it as a paginated list describes a response the client never gets. -
A custom
FieldorPreviewattached as a lazy import rendered nothing — the documented way to attach one isadmin: { Preview: () => import("./MyPreview") }. JavaScript names an anonymous function after the property key it is assigned to, so that arrow’s name is"Preview", and component detection treated “zero arguments, starts with a capital letter” as proof of a component — which is true of every loader written that way. The thunk went to React as a component, React called it, got a Promise, and rendered nothing: an empty cell with no console error. Detection now leads with what the function does — a dynamic module load in the body outranks the name — and matches bothimport(...)and therequire(...)that CommonJS transforms produce. -
rebase devcould print a URL served by a different process — when the first port was busy, the port-retry helper bound the next one but reported the port it had just failed to bind. It passed its success handler toserver.listen(port, host, cb), and that form registers the handler as a one-shotlisteninglistener which a failed attempt never removes; the next attempt’s success then ran both, and the earliest won. So with something already on 3001, the server listened on 3002 and announcedhttp://localhost:3001. Whatever was already there answered normally, out of its own database, and nothing logged a warning.Two consequences are fixed with it. The port file recorded the wrong number as well, and port affinity from that file used to outrank an explicitly requested port — so setting
PORTin.envhad no effect while a file from an earlier run existed. The file now records the bound port and the requested one, affinity applies only when the same port is requested again, and this matches the precedence the CLI already used (--port, thenPORT, then affinity). -
A bundle build said nothing about ignoring
backend/src/index.ts—rebase devruns that file whenever a project has one, so throughout local development it is the server and every route in it works. A bundle has no entrypoint of its own: the runtime boots the bundle and mounts what the manifest points at — the config package, functions, crons and the schema. So a project with custom routes in its entrypoint built clean, deployed green, and answered 404 on every one of them, with the file still sitting in the repository looking exactly like the server.rebase buildanddeploy --bundlenow name the file, say it is neither compiled nor shipped, and give the two ways forward: move the routes intobackend/functions/, or declare the app as"type": "custom"to keep your own entrypoint (which is already what a manifest-less repo carrying one is inferred as). -
rebase cloud deploywith no flags did not say what it was about to build — the bare form uploads nothing. It asks the control plane to rebuild what it already holds: a git checkout, or the newest source archive some earlier--sourcedeploy left in object storage. Both are legitimate and neither is the working directory, so a deploy shipping month-old code was indistinguishable from one shipping today’s. It now prints the source first — the repository and branch, or the archive’s deployment id and age, with a reminder that--source .is what uploads this directory — and says plainly when the control plane holds neither.On a managed project it was worse than stale. A successful source build sets
runtimeMode: "custom"server-side, so the bare form silently swapped a project off the platform runtime and back onto a container image. That case is now a refusal naming--bundleas what was meant;--source .and the new--forceboth eject deliberately, and an explicit--sourcedeploy of a managed project warns before it does. -
deploy --bundlecould not skip type checking —rebase buildhas--skip-type-checkandbuildBundlealready accepted the option; only the deploy argument spec lacked it, so iterating meant building by hand and then pointing--bundle-dirat the result. The flag is accepted ondeploynow and threaded through.
Testing
Section titled “Testing”-
A stable release now runs the full gate before publishing anything. Publishing was not gated on tests: the canary job ran a build and published, and
publish.ymlhad no dependency on CI at all — the two workflows fired in parallel on the same push, so a release could go out while CI was still running, or after it had already failed. The stable job ran unit tests but no end-to-end suite, which meant the failures those suites exist to catch — a brokenrebase init, RLS not isolating rows — were exactly the ones a green build could not see.The whole gate (type checks, headless/BaaS guards, init-template check, unit tests, and every e2e suite) now lives in a reusable
verify.ymlthat CI and the stable release both call, so the release path cannot drift from the one that runs on every push. A stable release stops before any version bump, tag or publish if any of it fails. Canary is deliberately unchanged: it still publishes on a green build alone. -
The template e2e suite could test a server it had not started. It took the backend’s address from the announced banner, which is trustworthy only if the server announces the port it bound — see the fix above. Each backend is now given a port the OS reports as free, and the run fails loudly if the banner disagrees rather than continuing against an unknown server and a database it does not control. It also talks to
127.0.0.1rather thanlocalhost, which resolves to::1first on macOS while the server binds0.0.0.0. -
The CLI init e2e leaked its frontend.
rebase devsupervises a Vite that ends up outside the process group the teardown signals, so a frontend survived every run — one held port 5173 for hours with its project directory already deleted. Teardown now also reaps whatever still holds the dev server’s own ports, restricted to processes that were not already listening there when the run began (a developer’stsx watchserver gets a new pid whenever it restarts, so “any new listener” would have been a way to kill it). -
rebase cloud linkwas broken from a fresh checkout — three prompts still used inquirer’s removedlisttype, so running it interactively died withPrompt type "list" is not registered. Prompts are only constructed when a command actually asks something, so every non-interactive test passed and CI stayed green while the first command anyone runs did not work. -
rebase buildproduced bundles that could not boot — TypeScript emits import specifiers untouched, so a project onmoduleResolution: "bundler"compiledfrom "./posts"and Node ESM refused it. Specifiers are rewritten after compilation. Bundle tarballs no longer carry macOS extended-attribute headers, which GNU tar warned about once per file on extraction and which buried real errors.
[0.10.0] - 2026-07-20
Section titled “[0.10.0] - 2026-07-20”Breaking
Section titled “Breaking”-
The authenticated principal is
uideverywhere — the identity had two names.uidwas the domain model’s: theUsertype, theAuthenticatedUseradapter contract, the driver scope, and the RLS layer, where policies readauth.uid().userIdwas the JWT claim’s, inherited by the Hono request context because it was populated straight from the decoded payload. A request crossed that boundary twice, so a route handler and a collection hook two frames apart saw the same person under different keys — and three unrelated places had independently grown the same defensivea ?? bread to cope.uidwins becauseuserIdwas confined to four server-side packages whileuidis the vocabulary of twelve, and because the two ends of the stack — Postgres policies and the client SDK — already agreed on it.Tokens now carry a
uidclaim andc.get("user")returns{ uid, roles }. Anything readingpayload.userIdoruser.userIdmust move. -
ESM only — the CJS/UMD output is gone — the packages shipped both, but the output banner injects
import/import.meta.url, which a UMD bundle cannot parse as CommonJS, so the CJS half was never loadable.main,moduleand theimportcondition all point atindex.es.js; therequirecondition is removed. A CommonJS consumer mustimport()or move to ESM. -
idis an address, not a column — the synthesizedidwas written into rows on the way out, where it collided with the data three ways: it renamed the key (askuprimary key was served asid, withskuabsent entirely), it changed the type (an integer key reached the SDK as"42"), and it destroyed real values, becausedrizzleResultToRowspread it last so it would win over a rawidcolumn. Rows now carry their own columns under their own names and types. Code readingrow.idon a table not keyed onidmust read the real key. -
A write naming a field the collection lacks is a 400 — unknown keys used to travel into the INSERT, so a typo came back as
column "titel" does not exist, phrased by Postgres from a stack the caller cannot see, and only when the column really was absent. Theidcase is called out specifically:create(data, id)writes the id argument as anidcolumn, which is meaningless for a table keyed onsku, so the error names the real key instead of sending someone hunting for anidthey never wrote. Bulk writes are checked before the transaction opens and report the offending row index. -
policy.authenticated()no longer matches anonymous requests — it compiled toauth.uid() IS NOT NULL, a tautology on the user path:applyAuthContextcoerces a blank user id to the'anonymous'sentinel precisely so it cannot read back as NULL and pass for the trusted server context. So a rule reading as “logged-in users only” granted full access to anonymous visitors, and neither the type system, the DDL generator nor — at the time — the drift checker said a word.not(authenticated())was separately special-cased to mean “is the server context”, which the default policies leaned on — so both spellings moved together. Review any rule built on either.Upgrading does not change your database. The compiled SQL lives in
pg_policies, so an existing app keeps the permissiveauth.uid() IS NOT NULLuntildb pushruns again — nothing re-applies policies at container boot, so redeploying and restarting change nothing.rebase doctor --policiesreports it: alongside the name-keyed diff it scans the livequal/with_checkof every policy on a managed schema and flags the bare tautology as Insecure, and it flags a policy an earlier push superseded but never dropped as Orphaned — the two ways this fix fails to land. It exits non-zero, so CI can gate on it. The scan is narrow by design: it matches that one expression shape and treats an<> 'anonymous'guard anywhere in the clause as the corrected form, so a hand-written fail-open policy spelled another way (USING (true),USING (1 = 1)) is not flagged — read the qual out ofpg_policiesdirectly to confirm those. Then rundb push, which re-applies the current policies and drops the superseded ones. See Upgrading. -
RLS is the whole authorization model — reads are bound too — enforcement used to split by operation: writes ran through app-layer callbacks while reads leaned on RLS
SELECTpolicies. But a privileged connection — superuser,BYPASSRLS, or the table owner — bypasses RLS unconditionally, so on any such connection (the common case) tenant read isolation was silently dead. Authenticated, user-context requests now run as a restricted, non-ownerrebase_userrole, so Postgres RLS binds every statement:SELECT,INSERT,UPDATE,DELETE. A collection’ssecurityRulesare now the entire authorization model; callbacks (beforeSaveand friends) are validation and side-effects, not a security boundary. The server context — auth flows, migrations,dataAsAdmin— stays the trusted owner plane and bypasses RLS by design. Default policies are locked-by-default for every collection (a permissive server-or-admin read/write baseline; auth collections also get a self-read and keep the restrictive admin write gate), so RLS-on does not default-deny everything;FORCE ROW LEVEL SECURITYis gone, since the user role is already a non-owner. The opt-out isdisableDefaultPolicies. Isolation is provisioned at boot and ondb push/migrate; a privileged connection that cannot be isolated fails boot with the exact setup SQL, and connecting as superuser orBYPASSRLSwarns loudly — the auth-collection write gates do not bind those. -
22 retired package names deprecated on npm — the names the repo no longer publishes now carry a deprecation notice pointing at their replacement, so an install of an old name says so instead of silently resolving to an abandoned version.
-
Package renames — packages are now named for their role, not their position.
corewas frontend-only React whileserver-corewas the actual core of the product; they shared a word and were otherwise unrelated.client-firebasedepended onadmin/core/ui, so it was a UI integration wearing a client-SDK name. Import paths are the only change — no behavior moved with them.Old New @rebasepro/core@rebasepro/app@rebasepro/server-core@rebasepro/server@rebasepro/server-postgresql@rebasepro/server-postgres@rebasepro/server-mongodb@rebasepro/server-mongo@rebasepro/client-postgresql@rebasepro/client-postgres@rebasepro/client-firebase@rebasepro/firebase@rebasepro/formex@rebasepro/forms@rebasepro/sdk-generator@rebasepro/codegen@rebasepro/schema-inference@rebasepro/inference@rebasepro/mcp-server@rebasepro/mcp@rebasepro/plugin-data-enhancement@rebasepro/plugin-aiUnchanged:
types,utils,common,client,ui,admin,studio,cli,plugin-insights. -
@rebasepro/authremoved — it was one hook and an API helper whose only dependency was@rebasepro/types, and it always had to be installed alongsidecoreanyway.useRebaseAuthController,fetchAuthConfig,createAuthConfigCacheandclearAuthConfigCachenow come from@rebasepro/app, beside theRebaseAuthandLoginViewcomponents they are used with. The auth system was never here — it lives in@rebasepro/client(client.auth) and@rebasepro/server. -
defaultSecurityRulesmoved off the server config — it lived onRebaseBackendConfig, was applied to the in-memory registry, and enforced nothing:db pushgenerates the Postgres policies — the only thing that actually enforces access — from the collection files, and never sees the running server. Declare it inconfig/collections/index.tsinstead, where the loader reads it and both the runtime anddb pushsee the same thing. Its old doc also claimed collections without rules were “unrestricted”; they are locked to admin-only by the generator. Inbaasmode there are no collection files and nodb push, so the database’s own RLS is the whole model and there is nothing to default.// config/collections/index.tsexport const defaultSecurityRules: SecurityRule[] = [{ operation: "select", access: "public" },{ operations: ["insert", "update", "delete"], roles: ["admin"] }]; -
A collection file that fails to import is now a hard error — the loader used to log and continue, which turns a broken file into a missing API route and a missing policy, with a successful exit code. Both read as “no data” rather than as a failure.
-
RebaseCMS→RebaseCMS— the component now matches the package it ships from.mode: "cms"onRebaseBackendConfigis unchanged: it describes where collections come from (config vs database), not the UI. -
BaaS mode does not serve tables without row-level security — see Fixes. A table with RLS disabled is skipped and named at boot;
baas: { unprotectedTables: "serve" }restores the old behavior.
Features & Improvements
Section titled “Features & Improvements”-
Presence and broadcast channels in the SDK — the realtime engine had supported
join_channel,broadcastand the presence messages for a while, but the client could only send them fire-and-forget: no methods to call them, and no way to receive channel events, sinceon()handled only connect/disconnect/reconnect/error. Anything wanting presence opened a second socket and reimplemented the authenticate handshake, the reconnect backoff and the presence heartbeat — a couple of hundred lines per app, duplicating this package.client.realtime.channel(name)now providestrack/onPresence/broadcast/onBroadcast/leave, with channels as per-name singletons so two components cannot cut each other off by leaving. It also hides two protocol details discoverable from neither the message list nor the docs: a joining client is told only about its own join, sojoin()sends an explicitpresence_stateto get the roster; and presence expires after 30s, so tracking is re-sent on a heartbeat. -
Ordered, replayable per-channel history — broadcast was fire-and-forget to whoever happened to be connected. Enough for presence and for “someone saved”; not enough for op-based collaborative editing, where a client that blinks out for two seconds had to resync a whole document rather than catch up on the four operations it missed. Every broadcast on a retained channel now gets a per-channel sequence number, allocated by the same statement that stores it, so a reconnecting client can say where it got to and receive only what it missed. Retention is server-side and opt-in (
realtime.channels, matching exact names or a trailing*prefix) — a channel is created by whoever names it, so a client-supplied history depth would let any visitor commit the backend to unbounded storage. With no rules configured nothing is written, no table is created, and broadcast runs the same synchronous path as before. -
Database-level realtime — change data capture — realtime events were application-level: only writes through the Rebase API emitted them, so a change made with
psql, another service’s cron, a raw SQL statement or Studio’s SQL editor committed silently and no subscriber heard it. A database-level CDC source now feeds the existingRealtimeService, matching Supabase Realtime’s WAL-tailing model: an idempotentAFTER INSERT/UPDATE/DELETEtrigger per managed table emitspg_notify, a dedicatedLISTENclient fans the events in, and delivery is RLS-safe — a change is marked invalidated so every subscriber re-reads under its own auth context rather than trusting the publisher’s row.REALTIME_CDCisautoby default: on where the connection supports it, silent fallback to app-level otherwise (waldegrades totrigger— native WAL streaming is not bundled). An 8KB-overflow guard means CDC can never abort a write. -
Per-object authorization for storage — storage routes authenticated but did not authorize.
requireAuthandpublicReadare global switches: they decide whether a caller must be signed in, not what that caller may touch, so any authenticated user could read any key they could name. For multi-tenant apps the only thing between two tenants’ files was key unguessability, which is not an access-control model.storageAuthorize({ key, bucket, operation, user })is the storage analogue of a collection’s security rules; denials are 403, and a hook that throws denies too, so a failed ownership lookup cannot fall open. The load-bearing placement is/metadatarather than/file/*, because/metadatamints the short-lived path-scoped download token that/file/*trusts — and it minted one for any authenticated caller for any path. Listing is gated on the prefix, since a listing is how you discover keys nobody told you about, and TUS is gated at create time so a denied upload leaves no temp file to resume. -
Bulk writes and upsert — only single-row create/update/delete existed, so a ~10k-row ETL had no way to express itself and dropped to
admin.executeSqlwith hand-bound parameters, which is where injection bugs live.createMany(rows, { upsert: true })is available on both the HTTP and server-side clients, and asPOST /api/data/:collection/bulk. Every row still runs the normal pipeline — callbacks, relations, RLS — becausesaveManyreusessave(); the win is that the batch shares one transaction and one round trip.upsertisINSERT ... ON CONFLICT DO UPDATEon the primary key, one statement, so it cannot lose the race a read-then-write can. -
Junction tables inherit the security model instead of escaping it — a
throughrelation makes the generator create a table nobody declared, and those were the one kind of generated table with no RLS at all. Sincerebase_userholds full DML grants, any signed-up user could read or wipe every edge between two locked-down endpoints (3,648 rows on the live demo), and there was nowhere to write rules for a junction anyway. A junction’s security is now derived: the same locked server-or-admin baseline every collection gets; reads follow the endpoints via two correlatedEXISTSsubqueries that run under the caller’s role, so visibility is delegated rather than copied and endpoint policy changes propagate with no junction change; and writes follow the owning side, because linking an edge is editing the owning row. -
Account linking — the
EMAIL_NOT_VERIFIEDrejection on OAuth sign-in told users to link the provider from their profile, but no such endpoint existed; the only link route was anonymous→password, so the error was a dead end. An authenticatedPOST /auth/link/:providernow attaches a provider identity to the current account, with a matching clientlinkProvider(). Linking deliberately does not require a verified email or matching addresses: on sign-in the provider’s email is the only evidence tying an identity to an account, so an unverified address would allow takeover, but here the caller already proved ownership with a valid session. Refuses with 409IDENTITY_ALREADY_LINKEDwhen the identity belongs to another user, and is idempotent for the caller’s own. -
Cron is coordinated across instances — every app instance ran every cron job, since the scheduler is in-process
setTimeoutand the executing flag only guards within one process, so N replicas meant N executions per tick. Handlers stay app-level closures; only the mutual exclusion moves to the database, where each instance derives the same scheduled fire time from the cron expression and atomically claims the slot. -
First-class database backups —
rebase db backup/restore/backups, writing to a local path or ans3:///gs://destination. Restore is confirmation-gated into a fresh database (--create-db/--target-db) so it cannot clobber a live one. Backups can run on a schedule from a cron file (createBackupCron,backupCronConfigFromEnv) with retention pruning (BACKUP_RETENTION_DAYS/BACKUP_KEEP_MINIMUM). Arebase.backupsclient surface and server routes expose the same operations, and the scaffold’s.env.exampledocuments the settings. -
rebase cloudreaches operational parity — project slugs replace UUIDs across every user-facing surface (--projecttakes the subdomain the console URLs show; raw UUIDs still resolve for old scripts and link files), plusrebase cloud debugfor diagnosing deployed projects andrebase cloud storage create/attach.rebase initgains real--project/--setup-keyhandling — the setup page advertised both flags while permissive arg parsing silently swallowed them. -
Tail-follow logs explorer in Studio — sticky auto-scroll with a new-entry pill.
-
Admin: the RLS editor offers the roles that actually exist — it listed native PostgreSQL roles from
pg_roleswhen picking values forSecurityRule.roles, which matches the strings on the users table viaauth.roles(). Choosingpublicorrebase_userthere compiled to a condition no user could satisfy.fetchApplicationRolesnow sits alongsidefetchAvailableRolesacross theSQLAdminsurface, and the doc comments on both fields spell out which is which. -
Admin: an unsaved-changes guard for split and entity views, with shared view-mode routing.
-
pnpm verify:docs— typechecks documentation code fences against the workspace SDK, so a doc that names an API the code does not have fails instead of aging quietly. -
BaaS mode — a REST API over your database with no collections at all —
mode: "baas"derives collections from the live database at boot instead of loading config files. Every protected table becomes a REST resource, with types, primary keys and relations read frominformation_schema; the drizzle tables the query layer needs are built in memory, so no generatedschema.generated.tsis required either. Change the schema with a migration and the API follows. Join tables are skipped, the schema editor is off (it exists to write config files), and no React enters the backend’s module graph.introspectionSchemaon the Postgres adapter selects a schema other thanpublic. -
The SDK works with no collections —
rebase.data.collection("posts").find()needs only a table name against a BaaS backend: no collections map, no generated types, nothing to declare. The optionalcollectionsoption exists only to pin non-obvious slugs. -
rebase init --flavor baas— scaffolds a headless project:backend/alone, noconfig/, nofrontend/, and no UI package in the install tree. Without--flavor,initasks: BaaS + admin (default) or BaaS only. -
rebase doctor --policies— diffspg_policiesagainst the policies your collections generate, reporting missing, orphaned, diverged and insecure, and exits non-zero so CI can gate it. Policies live in Postgres and the config is only their source; nothing reconciled the two, so a stale policy outlived every config fix. ReusesgeneratePostgresPoliciesDdl— the same functiondb pushapplies — so it compares against what would really be written. It also reports policy roles this server can never assume, without booting one. Policy expressions are not diffed against the generated DDL: Postgres rewritesqual/with_checkon storage, and a check that cries wolf gets ignored. They are still scanned, for one shape — the fail-openauth.uid() IS NOT NULLtautology, without the<> 'anonymous'guard — which is the one drift no other field here can see, since a policy carrying it matches its expected counterpart on name, roles, command and clause presence alike. -
One definition of “the collections” — the runtime, the drizzle-schema generator, the policy generator and the doctor each scanned the collections directory themselves, four copy-pasted filters agreeing by discipline rather than construction. A drift between them would serve one set of collections while pushing policies for another. They now share one loader, exported from
@rebasepro/server. -
Guards for the two failure modes that ship silently —
pnpm run check:headlessimports every collection file and server package under a loader hook that rejects React, so a UI import cannot creep back into the backend.pnpm run check:namesfails on references to renamed packages and duplicate dependency keys. Both run in CI. A new BaaS e2e installs a scaffolded project from real tarballs and boots it against tables it was never told about — the only placeworkspace:*resolves, so the only thing that proves the templates rather than the library.
-
A signup with a typo’d field is now a 400, not a silent 201 — a write to an auth-enabled collection skipped unknown-field validation entirely, because a signup body carries
passwordand provider fields the users table does not declare as columns. The skip was total, soPOST /api/data/userswith an undeclaredemialreturned 201 and dropped the field, while the same typo onpostswas a 400 — directly contradicting the Breaking note above. The exemption is now scoped to exactly the fields the auth adapter consumes (the built-in one namespassword); everything else is validated as on any collection. An auth collection with a customonCreateUserhook opts out, since the hook then owns the body’s shape. -
POST /auth/refreshwith no session is a 401, not a 400 — clients refresh on page load before they know whether a session exists, so a first-time visitor with no token is the most common way the route is called. It answered400 INVALID_INPUTand logged a warning for every anonymous page view. Absent-token is now401 NO_SESSION, logged at debug; a present-but-malformed token is still a 400.ApiErrorgained anexpectedflag (and anunauthenticated()factory) so a routine outcome no longer looks like an incident in the logs. -
The generated
docker-compose.ymlcould not boot —63108aa90made the server refuse to start with local storage underNODE_ENV=production, on the grounds that the container filesystem is destroyed on the next restart and uploads go with it. The scaffold’s compose file setsNODE_ENV=productionand does mount a durable named volume at the storage path, which is the exact case the check tells you to acknowledge withFORCE_LOCAL_STORAGE=true— but the template never set it. Sodocker compose up, the “recommended for production” path in every scaffolded README, crash-looped the backend withFailed to start server. The flag is now set in the template, next to the volume that justifies it. This was invisible for days because the e2e step that would have caught it sits behind a step that was already failing. -
init --database-urlshipped a compose stack with the passwordchangeme—DATABASE_PASSWORDwas only written on the branch that generates a local database. Supply your own--database-urland it was omitted entirely, sodocker-compose.yml, which interpolates${DATABASE_PASSWORD:-changeme}into bothPOSTGRES_PASSWORDand the backend’s connection string, fell back to the literal default — on adbservice that publishes a host port. The password is now generated in both cases; the supplied URL is untouched. -
rebase inittold you things that were not true — the next steps were assembled from the flags you passed rather than from what actually happened.--introspectwithout--installprinted “Skipping introspection because dependencies were not installed” and then, four lines later, “Database has been introspected & collections generated!” — the second line branched on the flag, never on the outcome. It now reports what really ran, and when introspection did not, it prints theschema introspectandschema generatecommands that finish the job. In the same pass: thecdhint used the project’s basename, soinit apps/my-appsaidcd my-app— a directory that does not exist from where you are standing — andinit .told you tocdinto a directory you were already in; both now use the path you typed, and in-place scaffolds print nocdat all. -
rebase init --helpprinted the wrong help —initwas missing from the dispatcher’s namespaced-command list, so--helpfell through to the global command index.--template,--flavor,--yes,--database-url,--introspect,--projectand--setup-keywere documented in exactly one place: the error you get for running init on a non-TTY. You had to trigger a failure to discover the flags.initnow has its own help, and a test fails if a flag the parser accepts goes undocumented. -
--gitleft the work half-done — it rangit initand stopped, leaving every scaffolded file untracked on whateverinit.defaultBranchhappened to be, so the firstgit diffwas noise and the first commit was the user’s problem. It now lands an initial commit onmain, authored by the user’s own git identity where one is configured..gitignoreis in place before the commit, so.envand its generated secrets are never in it while.env.exampleis. -
--templatewas accepted and discarded for the baas flavor — baas has no collections, so a preset has nothing to swap; the flag was taken silently and the scaffold came out identical either way. It now says the preset is being ignored, and the help spells out that--templatedoes not apply to baas. -
OAuth token substitution allowed account takeover — the Google path resolved client-supplied access tokens through the userinfo endpoint, which does not check
aud, so any valid Google access token — including one an attacker obtained for their own OAuth client — was accepted and resolved to whatever account it belonged to. The audience is now verified against ourclientIdvia tokeninfo before the identity is trusted, and ID-token paths read the realemail_verifiedclaim instead of hardcoding it. On Microsoft,emailVerifiedis derived from a provider-provisionedmailmailbox rather than assertedtrue, so a bare userPrincipalName can no longer auto-link an OAuth login onto a pre-existing password account. CORS, rate limiting and vector SQL were hardened in the same pass. -
/admin/bootstrapwas a land-grab — the self-promotion endpoint only refused to run once an admin already existed. In a “users exist but no admin” state — reachable via concurrent first-registrations, or by deleting the first user — any authenticated user could seize the initial admin role. It is now gated to the earliest-registered user, deterministically tie-broken by id, with security-audit logs on both the denial and the success. -
The API served password hashes —
/api/data/usersreturned every user their ownpasswordHashandemailVerificationToken. RLS scoped the row to the caller so this was not a cross-user leak, but a salted hash is offline-crackable and a verification token can be replayed. The users collection only marked themui.hideFromCollection, which stops the admin panel from rendering a field and leaves it in the JSON. -
The data API was rate-limited by API key only — the limiter returned early for any request that carried no API key, so JWT and anonymous traffic — most of what a BaaS serves — was unbounded, and it was mounted only
if (apiKeyStore), making its presence depend on a feature it does not need. Every request now falls in exactly one bucket, resolved most-specific first: API key by id, signed-in user by uid, everyone else by IP. -
Storage had no effective upload size cap — the
bodyLimitwas registered after the routes, so Hono never ran it. A wrapper router now applies it in front. Storage also accepts API keys under a newstoragepermission namespace (read/write/delete), whererk_tokens previously 401’d as malformed JWTs. -
API keys and admin surfaces — the builtin auth adapter no longer authenticates
?token=query params, which could leak full JWTs and the service key into access logs (the non-adapter middleware already refused them). Admin API keys now genuinely reach admin surfaces, withrk_pre-auth running in front of/admin/*, cron, backups and logs. -
A purpose-scoped token is not an access token — every storage token is signed with the same secret, so a signature says the server minted it, not what it is for. A download token travels in URLs and grants one file; it was rejected as a session only because it happens to carry no id, and nothing stopped a future one from carrying one.
verifyAccessTokennow refuses any token with apurposeclaim outright. No live hole was found — this is defence in depth. -
Superseded RLS policies survived
db push— a generated policy is named after a hash of its own semantics, so editing asecurityRulewrites a policy under a new name, andpolicies.sqlonly DROPs the names it is about to CREATE. Because Postgres ORs PERMISSIVE policies together, a supersededUSING (auth.uid() IS NOT NULL)kept granting everything no matter how tight its replacement was — and push reported success throughout, so tightening a rule looked like it had worked and hadn’t. -
A pooled connection could leak its RLS GUCs — when the client-side
query_timeoutfires inside a drizzle transaction, pg rejects the promise but keeps the connection and splices queued queries, so drizzle’s ROLLBACK times out without ever reaching the wire and thefinallyreleases the client back to the pool with no error. pg-pool then re-pooled it mid-transaction with theapp.*GUCs still set, and the next checkout ran inside the zombie transaction under someone else’s auth context. -
Relation batching guessed on composite keys — batching matched parents on
parentPks[0], so two rows of a composite-keyed collection differing only past the first column collapsed together:tenant_id IN (1, 1)collected every row of tenant 1 and filed them all under"1", last write winning. Each booking of a tenant was handed its neighbour’s relations, and nothing errored. The WHERE is now an OR over whole keys, or it refuses rather than guess. -
Ephemeral local storage is refused in production —
STORAGE_TYPEdefaults tolocal, which on a managed platform is the pod’s ephemeral filesystem: every uploaded file destroyed on the next restart, with no error at write time, no error at read time, and a warning nobody reads until the data is gone. Boot now fails instead.FORCE_LOCAL_STORAGE=trueremains the opt-in for a deployment with a real volume mounted. GCS env vars were added alongside, local bucket defaulting made symmetric, and list paging fixed. -
Subscriptions could hang forever — a collection view could sit on its loading spinner indefinitely with no error until reload.
subscribe_collection/subscribe_oneare in theexpectsResponse = falseset, so unlike ordinary requests they had no timeout, and a subscribe that got no reply left the subscription pending forever; a subscribe whose send rejected — a token refresh losing a cold-load race — failed the same silent way. -
Channel messages lost their envelope — channel payloads are now wrapped consistently, and the realtime socket connects lazily rather than in the constructor, so constructing a client no longer opens a connection.
-
Realtime told subscribers the wrong name for their rows, and a save now names the row it saved rather than deriving an address the caller never asked for.
-
The doctor reported drift on a clean project, and the schema tooling now says which RLS policies you did not write and how to drop them.
-
rebase initfailed when installed from npm, hung on a non-interactive terminal, and defaulted to the wrong package manager;pnpm startnow filters the backend workspace by path, storage subcommands dispatch correctly, and a stale build warns instead of behaving mysteriously. macOS deploy contexts are handled — AppleDouble tar entries suppressed, dotfiles skipped in directory loaders, and the 100MB upload cap pre-checked. -
Postgres errors surfaced as opaque 500s — the underlying error is now reported, and a legacy auth schema is reconciled on boot.
-
Admin: a navigated entity is addressed by the path it was fetched by, field bindings in
DEFAULT_FIELD_CONFIGSare read lazily, and the twoWhereFilterOpdefinitions now fail loudly when they drift instead of silently disagreeing. -
Studio: the views that were lying — an RLS editor crash, dark-mode controls, a revoke confirmation, and the policies those views disowned.
-
BaaS mode served every table to every authenticated user — it introspects all tables,
ensureAppRolegrantsrebase_userSELECT/INSERT/UPDATE/DELETEacross the schema, and nothing enabled RLS, because that only happens viadb push, which BaaS never runs. Pointing Rebase at an ordinary database therefore exposed every row of every table. A table with RLS disabled has no authorization model, so it is now excluded and logged with theALTER TABLEneeded to protect it. Tables with RLS enabled but no policies are served and return nothing — legal, and indistinguishable from an empty table, so that is called out at boot too. -
Security rules targeting an unusable Postgres role now fail the boot —
pgRolessets a policy’sTOclause, so naming a role requests never run as means the policy never applies and RLS filters every row. The table reads as empty, which is indistinguishable from having no data, so the mistake shipped. Boot now throws, naming the collection and role, with a specific hint for Supabase’sauthenticated/anon/service_role. -
The demo app’s collections were empty — every collection but
usersgrantedpgRoles: ["authenticated"], a Supabase role name, while requests run asrebase_user. RLS filtered every row;authorsandpostsgrantedTO public, which is why they were the only two showing data. They now use the documented API (select: public, writesadmin), the same shaperebase initscaffolds. The generateddrizzle/policies.sqlcarried the same policies and is regenerated — it is whatdb pushapplies, so the config alone would have changed nothing. -
The service key did not authenticate websockets — the HTTP middleware compares it before JWT verification; the websocket path went straight to
extractUserFromToken, and a static secret can only ever fail that. Any SDK client using a service key (scripts, cron, server-to-server) gotjwt malformedon every connect and silently received no realtime events. -
collection-file → UI packageimports no longer drag React into the backend —users.tsimportedresetPasswordActionfrom@rebasepro/cms, so the Node backend loaded the entire admin bundle at boot. The action is already injected frontend-side forauthcollections, making the import redundant.@rebasepro/cmsis also gone from the config and backend templates, and@rebasepro/core/uifrom@rebasepro/auth— none were imported.
Testing
Section titled “Testing”-
CI had been red for three days on a bug in the test, not the product — every commit since 2026-07-17 failed the browser e2e with
Local API request failed with status: 401, and Publish kept shipping canaries past it. The suite writesREBASE_SERVICE_KEYinto the scaffolded.envwith a regex, and the regex put\s*before the variable name — where\smatches newlines. While the CLI shipped that line commented out, the#anchored the match and it worked.259ef0b7amade the CLI write the key uncommented, so the leftmost match began at the end of the previous line and swallowed the newline, welding the assignment onto the comment above it. dotenv then read the whole line as a comment, the server auto-generated its own key, and every service-key request was rejected — a failure three layers away from its cause. The writer is line-based now, and asserts the variable landed on a line of its own instead of trusting the write. -
The e2e suites refuse to run when their port is taken — both suites pin a port (3099, 3098) and assert against it, but
rebase devfalls back to another port when one is busy, so the browser step drove whatever else happened to be listening. A dev server left running in a git worktree held 3099 and silently served the entire local run — including a database that had already been torn down, which is a convincing way to produce failures that have nothing to do with your change. Startup now stops with the squatting pid and command named, and the port is overridable viaE2E_BACKEND_PORT/E2E_BAAS_BACKEND_PORT. -
Every
inittemplate is now driven to a persisted row — the e2e suite scaffolded one project, in one shape, and checked that tables and indexes existed. A template could scaffold, typecheck and migrate cleanly while being unable to store anything, and nothing would say so.test/e2e/templates.test.tstakes all six preset × flavor combinations through the path a user actually walks: scaffold, install, bootstrap a real PostgreSQL database, boot the backend, register, log in, write over the HTTP data API, read back, and confirm the row in Postgres — because an API that echoes what it was sent passes every assertion short of the last one. The baas cases additionally assert the security posture the flavor is built on: a table with no row-level security must not be served, the boot log must name it and say how to fix it, and once a policy exists,auth.uid()must hide one user’s rows from another. -
rebase init’s output is under test —test/e2e/init-ux.test.tspins the reporting defects above so they cannot return: next steps that contradict what happened, acdthat points at a directory that does not exist, undocumented flags, an uncommitted--gittree, and a silently discarded--template. It drives the real binary and installs nothing, so it runs in about three seconds. -
test/is typechecked — the build config only ever includedsrc, so the e2e suites could drift out of sync with the code they drive and fail only at runtime, minutes into a docker-backed run.tsconfig.test.json(pnpm typecheck:test) covers them; it caught a missing import while this was being written. -
A stale
dist/fails loudly — the e2e suites link the workspace packages and load their build output, so an unbuilt tree silently tests yesterday’s code. This surfaced asPermission denied on "posts"— a failure with no visible connection to its cause. The suite now checks that every linked package’sdist/is newer than its sources and, if not, names the packages and the build command instead of running.
[0.9.0] - 2026-07-13
Section titled “[0.9.0] - 2026-07-13”Breaking
Section titled “Breaking”-
Collection & callback API renames — several collection-related types took role-based names, the callback parameters flattened to plain rows, and the WebSocket protocol dropped the redundant
ENTITYfrom its message names. TheEntitytype itself is unchanged. This is a search-and-replace-level migration for consumers — no behavioral changes.Types (
@rebasepro/types)Old Name New Name EntityCollection<M>CollectionConfig<M>EntityCallbacks<M>CollectionCallbacks<M>EntityViewEntityCustomViewEntityCollectionViewDataCollectionViewCallback API (
CollectionCallbacks) — beyond the rename, the parameter shapes changed:Old Param New Param Notes entity(inafterRead)rowNow a flat Record<string, unknown>, not anEntity<M>wrapperentityId(in save/delete)idstring | numberpreviousEntitypreviousValuesPartial<EntityValues<M>>afterCreate/afterUpdateafterSaveUse status: "new" | "existing"to distinguishMigration example:
import type { EntityCallbacks } from "@rebasepro/types";const callbacks: EntityCallbacks = {afterRead: ({ entity }) => {return { ...entity, values: { ...entity.values, email: "***" } };},afterCreate: ({ entity }) => { /* ... */ },beforeDelete: ({ entityId }) => { /* ... */ },import type { CollectionCallbacks } from "@rebasepro/types";const callbacks: CollectionCallbacks = {afterRead: ({ row }) => {return { ...row, email: "***" };},afterSave: ({ id, status }) => { if (status === "new") { /* ... */ } },beforeDelete: ({ id }) => { /* ... */ },};WebSocket wire protocol
Old Message Type New Message Type FETCH_ENTITYFETCH_ONESAVE_ENTITYSAVEDELETE_ENTITYDELETECOUNT_ENTITIESCOUNTsubscribe_entitysubscribe_onecollection_entity_patchcollection_patch -
Unified
<Rebase>data props — Removed thedataanddriverprops. There are now exactly two ways to provide data:client(server transport) anddataSources(everything else). AdataSourcesentry keyed"(default)"with adriverreplacesclient.dataas the default source — this is how a fully client-side app (e.g. Firestore-only viaRebaseFirebaseApp) is wired. Migration:driver={x}→dataSources={[{ key: "(default)", engine: "firestore", driver: x }]};data={x}had no known users (custom backends implementDataDriver, now the documented integration SPI). -
Deterministic default-source resolution — The default data source resolves as:
"(default)"-keyed entry with driver →client.data→ the sole registered source. Several sources without an explicit default now throw instead of silently picking the first object entry (order-dependent). -
Side-panel / Edit-view / Collection-view component rename — Renames mechanically-generated “Entity” component names to descriptive, role-based names. Components bound to Rebase core data use the
Bindingsuffix. This is a search-and-replace migration — no behavioral changes.Types (
@rebasepro/types)Old Name New Name EntitySidePanelPropsSidePanelBindingPropssideEntityController(onRebaseContext)sidePanelControllersideEntityController(onEntityActionClickProps)sidePanelController"Entity.FormActions"(override key)"EditView.FormActions""Entity.DetailView"(override key)"DetailView""Entity.Preview"(override key)"RecordPreview"Components (
@rebasepro/cms)Old Name New Name SideEntityProviderSidePanelProviderEntitySidePanelSidePanelBindingEntityEditViewEditViewBindingEntityEditViewFormActionsEditFormActionsEntityDetailViewDetailViewBindingEntityViewRecordViewBindingEntityPreviewRecordPreviewBindingEntityJsonPreviewJsonPreviewBindingDataCollectionViewCollectionViewBindingEntityCollectionBoardViewCollectionBoardViewBindingEntityCollectionCardViewCollectionCardViewBindingEntityCollectionListViewCollectionListViewBindingDataCollectionViewActionsCollectionViewActionsDataCollectionViewStartActionsCollectionViewStartActionsDataCollectionTableCollectionTableBindingEntityCollectionRowActionsCollectionRowActionsEntitySelectionTableSelectionTableBindingEntityBoardCardBoardCardBindingEntityCardRecordCardBindinguseEntityPreviewSlotsusePreviewSlotsSideEntityControllerContextSidePanelControllerContextBridge key (
@rebasepro/core)Old Key New Key "sideEntityController""sidePanelController"sideEntityController(onStudioBridge)sidePanelController -
Client split into server/browser variants —
RebaseClientis now split so the RLS-bypassing accessor is explicit: userebase.dataAsAdmin(server-only) for admin-scoped, RLS-bypassing access, andrebase.datafor user-scoped access. The public API surface was curated to hide internal plumbing. -
update/deletethrow on not-found — SDKupdate()anddelete()now throw when the target row does not exist, instead of silently returningundefined. -
deleteAllis now internal — removed from the public data accessors. -
Scaffold defaults to cookie auth — new projects store the refresh token in an httpOnly cookie (
authFlowMode: "cookie") by default. -
AdminUser.provider→providerId— renamed to match the canonicalUsertype.
Features & Improvements
Section titled “Features & Improvements”-
Membership / relational RLS predicate (
policy.existsIn) — a first-class access predicate for scoping reads/writes by membership in a related collection (e.g. “only rows whose team the caller belongs to”). Compiles to a single correlatedEXISTSsubquery — no per-rowafterReadlookups. Addspolicy.existsIn({ collection, where })and thepolicy.outerField(name)operand for correlating the subquery to the outer row. -
Built-in email → user lookup for invites — opt-in
auth.allowUserLookupexposes an authenticatedPOST /auth/find-userand a clientrebase.auth.findUserByEmail(email)that returns a minimal public profile (uid,displayName,photoURLonly). Removes the hand-rolleddataAsAdminserver function that invite flows previously required. Off by default (enables user enumeration by signed-in users). -
Mount the admin under a path prefix —
RebaseCMSaccepts abasePathso the admin can live under a sub-path route (e.g./admin) without the collection data-grid hanging on URL↔collection resolution. -
Filter operators — LIKE family (
like,ilike, etc.) and null checks, with engine-aware, customizable filter fields. -
Scoped storage tokens — storage access is now governed by scoped, time-limited tokens, with a documented public-files + scoped-token URL model.
-
Uniform server error envelope — server error responses are routed through a central handler for a consistent
{ error: { message, code, details? } }wire shape. -
Inferred data-source transport —
DataSourceDefinition.transportis now optional: entries with a client-sidedriverdefault to"direct", entries without to"server". A"(default)"-keyed entry without a driver can be used to declare the default source’s engine/capabilities while the client keeps serving the data. -
installShutdownHandlers— New@rebasepro/server-corehelper that encapsulates graceful shutdown: drains viabackend.shutdown(), runsonCleanup(e.g. closing your database pool), guards against repeated signals, and force-exits if shutdown hangs. Replaces the hand-rolled ~40-line shutdown block in the backend templates — the CLI template previously lacked the re-entry guard and force-exit timer entirely. -
Honest Realtime Meta — Added
FindResponse.meta.estimatedflag on realtime first-paint updates. Whenlisten()emits its immediate heuristic metadata, the emission now carriesestimated: true. Redundant second emissions are skipped when the authoritative count matches the heuristic, and count failures no longer silently pretend to be authoritative — theestimatedflag remains as the signal.
-
Concurrency-safe refresh-token rotation — token rotation now uses an atomic
INSERT … ON CONFLICT DO UPDATEinstead of a DELETE-then-INSERT. Concurrent/refreshcalls (which cookie-mode boot can fire at once) previously raced into aunique_device_sessionviolation and returned 500, breaking the session. The client also single-flights concurrent refreshes. -
Cookie session restore —
/auth/refreshnow returns the user object, and the client restores the user (falling back to/me) instead of leaving a blankuid. A cold start restored from an httpOnly cookie alone no longer yields an empty user. -
Resilient auto-refresh — a transient refresh failure (network blip, backend restart, 5xx) now retries with exponential backoff instead of immediately signing the user out; only a genuine auth failure (401/403/invalid/expired token) or exhausted retries signs out.
-
server-postgresqlshipssrc/— the driver package now packssrcalongsidedist, fixing✗ Could not find CLI entry point for @rebasepro/server-postgresqlforrebase db push/schema generatein published/packed installs (the CLI runssrc/cli.tsvia tsx; nodist/cli.jsis built). -
Malformed request bodies — the API now rejects malformed JSON bodies with
400and tightens the public-path check. -
Auth collection callbacks warning — the server warns at startup when an auth collection defines
beforeSave/afterSave/beforeDelete/afterDelete, since auth-driven user creation bypasses the collection save pipeline (use theafterUserCreateauth hook instead). -
CLI DX — friendly diagnostics for “SSL is not enabled on the server” (suggests
sslmode=disable) and for dependency-drop failures that leave a schema half-migrated; a clear warning when--collectionsresolves to a missing path; andrebase devnow surfaces when it overrides the project’s.envPORT /VITE_API_URLwith its derived per-project port. -
Scaffold hardening — the frontend Vite config ships
resolve.dedupefor React / React Router so a locallylink:ed Rebase checkout doesn’t load duplicate React copies (which broke the admin’s data router);.env.exampledocumentssslmode=disable.
[0.8.0] - 2026-07-01
Section titled “[0.8.0] - 2026-07-01”Changed
Section titled “Changed”- Strict collection accessors — When a
collectionsdictionary is passed tocreateRebaseClient, unknown property accessors onclient.datanow throw immediately with a nearest-match suggestion instead of silently producing a 404 later. Usedata.collection("slug")for dynamic slugs.
Cleanup
Section titled “Cleanup”- Removed — Six unused FireCMS-legacy builder identity functions (
buildProperties,buildPropertiesOrBuilder,buildEnum,buildEnumValueConfig,buildEntityCallbacks,buildAdditionalFieldDelegate). Migration: remove the wrapper call — they were identity functions, so the object literal is the same value. - Deprecated —
buildCollection/buildPropertyin favor ofdefineCollection. Both are marked@deprecatedand will be removed before 1.0. - Removed — Unused
<Rebase apiKey>prop (it was never consumed by the component). - Fixed — Duplicated sentences in
propertiesOrderJSDoc; rewrotesubcollection:description to cover both Firestore and Postgres.
Features & Improvements
Section titled “Features & Improvements”- Unified Policy & Filter Engine — Replaced ad-hoc permission checks with a centralized
evaluatePolicysystem andPolicytype. This system translates high-level security rules into both frontend conditions (for UI gating) and backend-specific filters (Postgres RLS, Firestore security rules). IncludespolicyToPostgresandsecurityRuleToConditionsutilities, ensuring the admin UI matches database enforcement by construction. defineCronauthoring helper — Typed identity wrapper for cron job files (parity withdefineFunction). Demo app now ships a working cron job (refresh-product-stats).- Multi-Backend Storage Sources — Introduced a first-class
StorageSourcesystem allowing a single project to use multiple storage backends (S3, GCS, Local, Firebase) simultaneously. AddedGCSStorageControllerfor native Google Cloud Storage support with TUS resumable uploads. Managed viaStorageSourcesContextandStorageRegistry, enabling complex multi-cloud storage architectures. - Custom Backend Functions — New
defineFunction()API for creating type-safe, discoverable backend endpoints. Functions are automatically mounted, type-checked, and can be invoked directly from the client SDK with full type safety. Includes a newinvoke_functionMCP tool for interacting with custom endpoints from AI agents. - Property Schema Consolidation — Refactored the property system to unify how database-level schemas, UI configurations, and validation rules are defined. Removed overlapping property types and introduced a more robust
PropertyConfigsystem that handles complex relations and references consistently across all data drivers (Postgres, MongoDB, Firestore). - Editable UI Table — Significantly enhanced
VirtualTablewith native editable cells (VirtualTableInput,VirtualTableSelect,VirtualTableNumberInput,VirtualTableDateField). Added a newSelectionStoreandSelectionContextfor robust multi-row selection, keyboard navigation, and batch operations within the CMS. - Expanded Agent Skills — Massive overhaul of the Rebase AI coding skills. Added new specialized skills for
rebase-custom-functions,rebase-ui-components, andrebase-storage. Expanded existing skills for auth, security, and SDK with deep architectural context, common patterns, and safety rules. - Public API Refinement — Cleaned up the public API surface of
@rebasepro/clientand@rebasepro/core, simplifying integration into existing applications. Consolidated data controllers, improved type inference, and refined theRebasecomponent props for better developer experience. - NPM Publishing Safeguards — Added
validate-no-workspace-protocol.shandcheck-packages.shscripts to the release pipeline. These prevent publishing packages withworkspace:dependencies or inconsistent versions, ensuring library consumers always get stable, resolved dependencies.
- Dependency Management — Resolved workspace-wide dependency conflicts and fixed “workspace protocol” leakage in built artifacts that caused installation failures in certain environments.
- Lifecycle Interception — Unified lifecycle interception systems across different data drivers. This ensures consistent execution of
beforeSave,afterSave,beforeDelete, andafterDeletehooks regardless of whether the collection is backed by Postgres, MongoDB, or Firestore. - OAuth Configuration — Refactored and stabilized OAuth provider configuration. Resolved inconsistencies in how environment variables were parsed for Discord, Microsoft, and LinkedIn providers.
- MongoDB & Firestore Parity — Improved collection support for MongoDB and Firestore, bringing their relation/reference capabilities and storage integration closer to parity with the PostgreSQL driver.
- Any Type Audit — Conducted a comprehensive audit of
anytypes across the core packages, replacing them with strict types or narrowing guards (e.g.,isSQLAdmin) to improve overall codebase robustness and prevent runtime errors.
Testing
Section titled “Testing”- Security Policy Tests — New test suites for
evaluatePolicy,policyToPostgres, andsecurityRuleToConditionscovering Kleene logic and complex nested expressions. - Storage Tests — Added comprehensive integration tests for
GCSStorageController, multi-storage routing, and TUS upload flows. - UI Tests — New unit and integration tests for
VirtualTableeditable fields, selection logic, and keyboard accessibility. - Schema Gates — Added
collection_registry_property_gatestests to validate property resolution and permission-based visibility gating at the registry level.
[0.7.0] - 2026-06-29
Section titled “[0.7.0] - 2026-06-29”Features & Improvements
Section titled “Features & Improvements”- Multi-Datasource Architecture — Introduced a first-class
DataSourceDefinition/DataSourceCapabilitiessystem that lets a single Rebase instance route collections to different database engines (Postgres, Firestore, MongoDB, or custom drivers). Collections declare adataSourcekey, and the frontend router, backend driver registry, and collection editor all resolve capabilities from the same definition. IncludesresolveDataSource(),createDataSourceRegistry(),registerDataSourceCapabilities(), and a newDataSourcesContextReact provider. The editor automatically shows/hides tabs (Relations, Subcollections, RLS) and property types based on each source’s declared feature flags. - Headless Collection Views — Extracted reusable, data-agnostic collection view components (
CollectionView,CollectionTableView,CollectionCardView,CollectionListView,CollectionKanbanView) into@rebasepro/ui. These headless components accept a genericCollectionDataController<T>— no coupling to entities or the CMS data layer — making them usable in custom pages, standalone apps, and third-party integrations. Includes aCollectionViewToolbarwith view-mode toggle, search, filters, and pagination. - Headless Entity Forms — Decoupled
EntityForm,EntityFormActions, andEntityFormBindingfrom the admin package internals. Forms now accept pluggable field bindings and layout props, enabling standalone entity editing outside the CMS shell. AddedPopupFormFieldfor inline editing and extended form layout controls. - Auth Hooks Expansion — Significantly expanded the
AuthHooksinterface with new lifecycle hooks:beforeLogin,afterLogout,onPasswordReset,beforeUserDelete,afterUserDelete,onAdminCreateUser,onAdminResetPassword, andtransformAuthResponse. ThetransformAuthResponsehook lets developers inject external tokens (e.g. Firebase Custom Tokens) or project-specific metadata into every auth response. AddedAuthMethodtype covering all authentication methods. - Custom Auth Adapter — New
createCustomAuthAdapter()factory for plugging existing auth systems into Rebase with minimal config. OnlyverifyRequestis required — capabilities, user lookup, and registration are all optional overrides. - Magic Link Authentication — Added passwordless magic-link login flow with
mountMagicLinkRoutes(). Generates secure tokens with 15-minute expiry, sends branded emails via the configured email provider, and integrates with thetransformAuthResponsehook and rate limiting. - API Keys — Full API key management with collection-level permission scoping (
read/write/delete), admin keys, rate limiting, expiration, and revocation. Includes server-side middleware (api-key-middleware.ts), a Postgres-backed key store, a Studio management UI (ApiKeysView), a CLI command (rebase api-keys list|create|revoke), and a client SDK module (@rebasepro/clientapi-keys.ts). Keys are stored with hashed secrets; the full key is only returned on creation. - Atlas Migrations (replaces Drizzle Kit) — Replaced
drizzle-kitwith Atlas for schema migrations. Addedgenerate-postgres-ddl-logic.tsthat produces raw SQL DDL (with enums, RLS policies, and indexes) from collection definitions. Migrations are now version-controlled SQL files underdrizzle/migrations/with anatlas.sumintegrity file. CLIrebase dbcommands updated accordingly. - Improved RLS Editor — Overhauled the Studio RLS editor with better policy visualization, shared
table-classification.tsmodule (classifying tables asrebase-internal,junction, oruser), and improved default auth policies generation. - Headless Collection Editor — Made the collection schema editor headless and decoupled from the admin shell. Extracted serializable types and utilities, allowing the editor to be embedded in custom Studio views or third-party tools.
- Security Audit Logging — Added structured security audit logging across all OAuth providers (Apple, Google, GitHub, GitLab, Facebook, Discord, Microsoft, LinkedIn, Slack, Spotify, Twitter, Bitbucket). Improved
ECONNREFUSEDerror handling with actionable diagnostics, and fixedchalkCJS compatibility. - Landing Page & Demos — New layered architecture diagram on the developers page, improved CRM dashboard demo (
CrmDashboardDemo), and fixed NEAT gradient mismatches across all landing pages. - CLI Skills Enhancements — Extended the
rebase skillscommand with updated skill definitions for auth, security, collections, realtime, and SDK documentation.
- Security Hardening — Parameterized queries in API key store and cron store to prevent SQL injection. Hardened WebSocket connection safeguards, strengthened
EntityPersistServiceinput validation, and added.dockerignore/.gitignorerules to prevent secrets leakage. Sanitized environment variable handling in production. - Repo Cleanup — Reorganized internal documentation (
BREAKING_CHANGES_POSTGRES.md,PUBLISHING.md,REBASE_ARCHITECTURE.md) into.github/internal/. Cleaned up legacyformex.yarn/cacheartifacts, updatedCONTRIBUTING.md,README.md, andAGENT.md. Deprecated export documentation moved todocs/DEPRECATED_EXPORTS.md. - UI & Ergonomics — Multiple ergonomic fixes across the admin panel: improved Sheet/Dialog focus management, refined
DrawerNavigationGroupand breadcrumb context, stabilized navigation resolution hooks, and cleaned upBreadcrumbsContextandCollectionRegistryContext.
Testing
Section titled “Testing”- Multi-Datasource Tests — New test suites for
buildRoutedRebaseData,resolveDataSource,collection_registry_datasource,routing_integration,multi-datasource-routing, androuted-realtime-service. - Auth Tests — Added tests for
custom-auth-adapter,transform-auth-response, and extendedauth-routestests covering magic links and lifecycle hooks. - Postgres Tests — New
auth-default-policiestests, extendedcli-helpers-extendedtests,connectiontests,databasePoolManagertests,doctor-extendedtests, andgenerate-postgres-ddltests. - UI Tests — Added
views.test.tsxcovering the new headlessCollectionView,ListView,CardView, andTableViewcomponents. - E2E Tests — Updated Playwright E2E tests for collections, studio features, and the new API keys flow.
[0.6.1] - 2026-06-23
Section titled “[0.6.1] - 2026-06-23”- CLI Init Crash — Fixed
rebase initcrashing withUnknownPromptTypeError: Prompt type "list" is not registeredafter entering the project name. Theinquirerv14 dependency renamed the"list"prompt type to"select", breaking the interactive flow. The non-interactive (--yes) path was unaffected, which is why E2E tests did not catch it.
Testing
Section titled “Testing”- Interactive Prompt Validation — Extracted prompt question building into a testable
buildInitQuestions()function and added unit tests that validate all prompttypevalues against the installedinquirerversion’s registered types. This prevents prompt-type regressions from shipping silently wheninquireris upgraded.
[0.6.0] - 2026-06-18
Section titled “[0.6.0] - 2026-06-18”Features & Improvements
Section titled “Features & Improvements”- Schema Drift & Previews — Added a schema drift notification banner to Starlight and Studio home page, and improved previews for collection reference/relation properties.
- Rebase Client & Types — Consolidated RebaseClient context hooks, aligned types in
@rebasepro/clientand reconciled data controllers for cleaner imports. - Observability — Integrated structured request-logger middleware and an
X-Request-IDcorrelation header to trace client requests across core backend services. - Code Quality & Testing — Added robust unit/integration tests across
@rebasepro/uicomponents, StudioHomePage, and data plugins. Cleaned up Vite configuration targets, and strengthened type-safety checks. - Multi-Factor Authentication (MFA) — Full TOTP-based MFA implementation with enroll, verify, challenge, and unenroll flows. Includes recovery codes,
aal1→aal2token upgrade on challenge verification, and anonMfaVerifiedauth hook. Auth routes extracted into dedicatedmfa-routes.tsandsession-routes.tsmodules. - Component Override System — New
ComponentOverrideContextanduseComponentOverridehook allow developers to replace built-in UI components at both the global (<Rebase components={…}>) and per-collection level, with resolution priority: collection → global → default. - CLI Skills Command —
rebase skillsauto-detects and installs Rebase AI coding skills for Cursor, Claude Code, Windsurf, and Gemini/Antigravity, writing the correct file format (.mdc,SKILL.md,.md) to each agent’s rules directory. - MCP Server Expansion — Added storage tools (
storage_list_objects,storage_delete_object,storage_get_metadata), cron tools (cron_list_jobs,cron_get_job,cron_trigger_job,cron_get_job_logs,cron_toggle_job), andinvoke_functionfor calling custom backend functions. Automatic package-manager detection for dev server commands. - Server Init Refactor — Decomposed the monolithic
init.tsinto focused modules:init/middlewares.ts(request ID, body limits, CSRF, CORS warnings, logging),init/health.ts(health-check endpoint with DB latency),init/shutdown.ts(graceful teardown ordering),init/storage.ts(multi-backend storage bootstrap), andinit/docs.ts(OpenAPI serving). - Entity Form Improvements — Enhanced
EntityDetailViewandEntityEditViewwith better field-binding support, addedPopupFormFieldinline editing, extendedEntityFormwith additional layout controls, and addedreplaceoption tonavigateToEntity. - Drizzle Schema Generation — Improved generated schema logic with richer column-type support and cleaned up
EntityPersistServiceby extracting reusable persist utilities. - Documentation & Website — Added
llms.txt, updatedsitemap.md, expanded backend auth, realtime, collections, SDK, and component-overrides documentation. Agent skills updated for auth, collections, realtime, SDK, and Studio.
- Auth Refactoring — Resolved auth issues and cleaned up redundant user management hooks, admin routes, and legacy decorators.
- Studio & UI Components — Corrected icon sizing bugs in navigation cards, restored and stabilized SQLEditor panel logic, improved tab scroll styles, and updated third-party dependencies across all packages.
- Relation Preview Rendering — Fixed broken relation previews in list views by correcting
useEntityPreviewSlotsresolution and adding proper hydration logic inRelationPreviewandPropertyPreviewcomponents. - Security Hardening — Hardened WebSocket client with connection-level safeguards, added input validation to GraphQL and REST generators, tightened API key store and cron store queries, improved image-transform and SPA-serve path handling, and added branch-service authorization checks.
- PostgreSQL Error Handling — New
pg-error-utils.tsmodule extracts native PG errors from Drizzle’s cause chain, translates 5-character SQLSTATE codes into user-friendly messages, and surfaces constraint, column, and table metadata. - Roles Query — Fixed roles query resolution in user management flows.
- Package Cleanup — Cleaned up
package.jsonfiles across the monorepo, fixed dependency declarations, and correctedplugin-insightsversion reference. - VirtualTable & UI — Refactored
VirtualTableandVirtualTableHeaderfor better resize handling and simplified render logic. ImprovedDialogfocus management andLoginView/ErrorViewlayout.
Testing
Section titled “Testing”- Admin Package Tests — Added component-level tests, data export tests, data import tests (including
get_import_inference_typeand transforms), and extended navigation utils test coverage. - PostgreSQL Tests — New
relations.test.tsfor relation service,pg-error-utils.test.tsfor error extraction, and expandeddrizzle-conditions.test.tsandgenerate-drizzle-schema.test.ts. - MCP Server Tests — Extended test suite covering new storage, cron, and function tool handlers.
[0.5.0] - 2026-06-15
Section titled “[0.5.0] - 2026-06-15”Features & Improvements
Section titled “Features & Improvements”- Aesthetic Landing Page — Added high-performance custom NEAT canvas background gradients, revamped hero illustrations, and introduced localized documentation and responsive demo page structures.
- Developer Workspaces — Added curated development skills rules (covering cron jobs, design-language, email, history, and SDK specs) directly into the agent workspace configs.
- Data Insights & Migrations — Integrated database migration
0002schema changes and a seed script, and introduced an automated insights calculator service. - CLI Improvements — Hardened CLI initialization options for PostgreSQL 18.
- RLS & Security — Resolved critical security gaps in Postgres Row-Level Security (RLS) policies.
- Multi-DB Drivers — Cleaned up type-safety and package path dependencies for
server-mongodbandserver-postgresql.
[0.4.0] - 2026-06-11
Section titled “[0.4.0] - 2026-06-11”Features & Improvements
Section titled “Features & Improvements”- Unified Authentication — Redesigned default auth routing, eliminated the
defaultUsersCollectionconstruct, and streamlined default view redirects. - Email Config — Added custom
SMTP_NAMEparameter configuration in SMTP email delivery properties.
- Layout & Sizing — Resolved side navigation alignment glitches, added scroll-overflow fixes in entity data grids, and corrected
ReadOnlyFieldBindingform fields. - Missing Build Configurations — Added missing
tsconfig.prod.jsoncompiler files and stabilized workspace-level packaging dependencies.
[0.2.5] - 2026-06-09
Section titled “[0.2.5] - 2026-06-09”Features & Improvements
Section titled “Features & Improvements”- Role Model Simplification — Removed roles as an independent table/collection, simplifying permissions into a standard DB enum column directly in the
userstable. - SDK & Client Methods — Extended Rebase client drivers with new data persistence methods.
- Types & Layouts — Extended schema types to support native UUID format in string fields, adjusted scroll behaviors in tab grids, and solved pnpm lockfile conflicts.
[0.2.4] - 2026-06-08
Section titled “[0.2.4] - 2026-06-08”Features & Improvements
Section titled “Features & Improvements”- PostgreSQL 18 — Upgraded core infrastructure and Docker configurations to support PostgreSQL v18.
- Scaffold Configurations — Added VPC and S3-compatible cloud storage setup inputs directly into the CLI project-creation prompts.
- Auth Hooks & Orgs — Added basic multi-tenant organization support and renamed
AuthOverridestoAuthHooks. - Advanced Query Operators — Introduced
array-contains-anyandnot-infilter clauses for postgres client drivers. - Error Boundaries — Wrapped main application routes in a robust
ErrorBoundarywith specific full-page and authorization error layouts, and attached global listeners for unhandled promise rejections.
- Stricter Typing & Logging — Replaced broad
anyusages with type-safeunknownkeywords, and migrated core controllers fromconsole.logto the structured monorepo logger.
[0.2.3] - 2026-05-31
Section titled “[0.2.3] - 2026-05-31”Features & Improvements
Section titled “Features & Improvements”- OIDC Publish Workflows — Migrated package publishing workflows to use GitHub Actions OIDC federation with NPM, removing hardcoded auth tokens and adding secure ID-token scopes.
- Dynamic Versions — Dynamically resolved workspace versions from
lerna.jsonduring canary package releases.
- CLI Scaffold — Fixed CLI template installation bugs, repaired Docker database image configs, and restored correct properties inside template collection schemas.
[0.2.1] - 2026-05-30
Section titled “[0.2.1] - 2026-05-30”- Lockfile & Build Issues — Fixed a missing integrity hash for the
xlsxdependency in the lockfile, and resolved frontend build failures by adding@types/nodeandvite/clienttype definitions. - SQL Editor Component — Updated the
SQLEditorcomponent for improved stability and rendering.
CI & E2E Testing
Section titled “CI & E2E Testing”- E2E Test Runner Improvements — Replaced the
execadependency with a custom spawn helper in E2E tests, resolved package packing/resolution issues, and fixed split chunk E2E test failures by accumulating logs for dev server URL detection. - Vite Template Config — Tracked
virtual.d.tsin git and fixed glob inclusions intsconfigfiles to prevent template compilation errors.
[0.2.0] - 2026-05-29
Section titled “[0.2.0] - 2026-05-29”Features & Improvements
Section titled “Features & Improvements”- Postgres Vector (pgvector) Support — Added a
vectorproperty type for embeddings, including admin UI field bindings, validation, Postgres schema generation, API generators, and data transformations. - Pluggable AuthAdapter Architecture — Replaced direct Firebase Auth logic in key controllers with a pluggable adapter system to support dynamic/external authentication providers (e.g., dynamic Postgres auth schemas).
- Users & Roles Collections — Migrated the user/role system to be treated as standard, customizable data collections, with built-in overrides and migration of auth UI components to the core package.
- A/B Testing & Landing Page Revamp — Added A/B testing infrastructure, hero CTAs, testimonials, landing page Bento Grid layouts (
ProductContent), and demo view modes. - SDK Drift Detection — Added SDK drift detection to the CLI doctor command to check for drift between collection definitions and generated SDKs.
- EntityDetailView & UI Enhancements — Created
EntityDetailViewfor read-only displays, newFilterChipcomponents, and support for collection filter presets. - CLI and Test Improvements — Upgraded pnpm to v11, added CLI init E2E tests, localhost validation tests, and AI coding assistant rules to CLI templates.
- Database Role Switching Config — Introduced
DISABLE_DB_ROLE_SWITCHINGandADMIN_CONNECTION_STRINGoptions with troubleshooting documentation. - License Update — Relicensed the project under the MIT License.
Fixes & Refactoring
Section titled “Fixes & Refactoring”- Realtime Service Shutdown Deadlock — Fixed potential deadlocks during shutdown by cleaning up websocket realtime services before closing the database pool.
- Environment Validation — Centralized environment variable validation in
server-core. - UI Styling & Translations — Refactored UI components to use consistent Typography/Alert variants, and updated i18n translation strings.
[0.1.2] - 2026-05-15
Section titled “[0.1.2] - 2026-05-15”Improvements
Section titled “Improvements”- Removed
lodashdependency — Replacedlodash/cloneDeepwith a customdeepCloneutility in@rebasepro/utils. This eliminates the external dependency and fixesnpx create-rebase-appfailing due to missinglodashat runtime. - New
deepCloneutility — A lightweight deep-clone function that preserves function references and class instances (Date, GeoPoint, etc.), designed specifically for Rebase collection objects.
CI & Tooling
Section titled “CI & Tooling”- Automated release pipeline — New GitHub Actions workflow (
Publish Stable Release) that handles version bumping, npm publishing, and GitHub Release creation in a single click from the Actions tab. - Local release script —
pnpm release:patch,pnpm release:minor,pnpm release:majorfor releasing from the command line with the same pipeline. - Canary releases — Every push to
mainpublishes a canary version to npm (@canarydist-tag).
- Fixed navigation utility tests to assert the correct call signature with
undefinedoptions parameter. - Updated package descriptions to reflect the Postgres-based architecture.
[0.1.0] - 2025-05-14
Section titled “[0.1.0] - 2025-05-14”🎉 First public release of Rebase — an open-source headless CMS and admin panel for Postgres.
Highlights
Section titled “Highlights”- Full Admin Panel — Spreadsheet, card, list, and table views for managing your data with inline editing, filtering, sorting, and search.
- PostgreSQL Backend — First-class Postgres support with Drizzle ORM, schema introspection, and automatic migrations.
- Authentication — Built-in auth with email/password, Google OAuth, and anonymous sign-in. Role-based access control with customizable permissions.
- Storage — S3-compatible file storage with image resizing, drag-and-drop uploads, and metadata management.
- Studio — SQL editor, RLS policy editor, schema visualizer, JS/TS editor, cron jobs, and API explorer.
- CLI —
npx create-rebase-appto scaffold a new project in seconds. Supports both npm and pnpm. - SDK Generator — Auto-generate fully typed TypeScript SDKs from your collection definitions.
- MCP Server — Model Context Protocol server for AI-assisted database management.
- Plugins — Data enhancement and insights plugins for extending the admin experience.
- UI Component Library — A comprehensive set of accessible, themeable React components built on Radix primitives.
- Firebase Support — Optional Firebase/Firestore data source and authentication adapters.
- MongoDB Support — Optional MongoDB data source adapter.
Packages
Section titled “Packages”| Package | Description |
|---|---|
@rebasepro/types |
Core TypeScript type definitions |
@rebasepro/utils |
Shared utility functions |
@rebasepro/common |
Common modules shared across packages |
@rebasepro/formex |
Lightweight form management library |
@rebasepro/ui |
React component library |
@rebasepro/core |
Core CMS logic and controllers |
@rebasepro/client |
Client-side data access layer |
@rebasepro/client-postgresql |
PostgreSQL client adapter |
@rebasepro/client-firebase |
Firebase/Firestore client adapter |
@rebasepro/server-core |
Server framework and middleware |
@rebasepro/server-postgresql |
PostgreSQL server adapter with Drizzle |
@rebasepro/server-mongodb |
MongoDB server adapter |
@rebasepro/auth |
Authentication controllers and views |
@rebasepro/cms |
Full admin panel interface |
@rebasepro/studio |
SQL editor, schema tools, and developer utilities |
@rebasepro/cli |
CLI for project scaffolding and management |
@rebasepro/sdk-generator |
TypeScript SDK code generation |
@rebasepro/mcp-server |
MCP server for AI integrations |
@rebasepro/schema-inference |
Database schema introspection and inference |
@rebasepro/plugin-data-enhancement |
AI-powered data enhancement plugin |
@rebasepro/plugin-insights |
Analytics and insights plugin |
