Changelog
Changelog
Section titled “Changelog”[Unreleased]
Section titled “[Unreleased]”Breaking
Section titled “Breaking”Server & REST
Section titled “Server & REST”-
?count=on a REST list is now the count switch, so a filter on a column named “count” goes inside?where=.?count=noneskips the count behindmeta.total,?count=exactis the default (see Added), and any other value is a 400INVALID_COUNT. Before,?count=5filtered on acountcolumn. Write that filter as?where={"count":["==",5]}. The SDK already sends it that way. -
A plain OPTIONS to a route with no OPTIONS handler is now a 404, not an empty 204. The CORS middleware answered every OPTIONS itself; it now answers only CORS preflights, and any other OPTIONS reaches its route.
OPTIONS /api/storage/tusanswers with the TUS capabilities (Tus-Version,Tus-Extension,Tus-Max-Size), as documented. A client or health check that probed a route with OPTIONS and read the 204 as “exists” must use the route’s real method. -
A collection set whose tables collide after Postgres’s 63-byte name truncation is refused at boot, with the reason. A table, junction table or enum type whose truncated names are equal used to fail at
db pushwith “already exists”. Two properties that resolve to the same column (myTitleandmy_title) and a relationtargetwritten as a string (target: "authors") are refused at config time too, each saying what to write. Rename one of the colliding names. -
An upsert on the users (auth) collection is refused with a 400
INVALID_CONFLICT_TARGET. It skipped both rule sets: the create rules (password hashing, email normalization,onCreateUser, the invitation) and the update rules (the last administrator cannot be demoted). A/bulkupsert of{ id: <the last admin>, roles: ["viewer"] }demoted them, and an inserted row had no password and the email as typed. The refusal covers?on_conflict=,/bulkwithupsert: true, a_batchupsertoperation, the SDK’supsert()andcreateMany(…, { upsert: true }), and the realtime socket. Create users withPOST(orcreate) and change them withPATCH(orupdate). -
A read naming both page and offset is refused with
PAGE_WITH_OFFSET. The server answers 400, and the client SDK, the in-processrebase.dataand the offline client refuse it before any request. Before,pagewon silently and theoffsetwas never applied, though the docs already said no window parameter is ignored. Send one of them; see the upgrade guide. -
An empty string on a required string field is refused like a missing value. Every write door (REST create, update,
/bulk, upsert and batch, the realtime socket, MCP) answers400 VALIDATION_CONSTRAINTwith therequiredviolation, on creates and on updates, even where the field has adefaultValue: a default fills an absent key, not an empty one. A collection with abeforeSavekeeps its exemption, since the hook may fill the value. The admin panel already writesnullfor a cleared field. See the upgrade guide.
Postgres
Section titled “Postgres”- On a table whose name is 42 bytes or more, the three default admin-write
policies get new names. They are now
<head>_<hash8>_<op>, at most 63 bytes. The old names never existed in the database: Postgres had kept one truncated policy, which the next boot orrebase db pushreplaces (see Fixed). Only a policy name that collided changes.
- The remote MCP’s
list_collectionsreply carries its listing once, in the text block, with nostructuredContent. A client that read the listing fromstructuredContentreads the text instead: the same content, as compact JSON. The other tools keep theirstructuredContent.
Admin (CMS & app)
Section titled “Admin (CMS & app)”-
@rebasepro/appno longer exports the Spanish translation table. The entry re-exportedesfrom./locales/es, the only language besides English it exported, and that kept the Spanish strings in every bundle built from the package.enis still exported. To change the admin’s strings, pass overrides through<Rebase translations={…}>. -
ClientStorageSourceRegistry.getOrDefault(key)throws for a key that is not registered. It used to return the default source, so a property naming a source the panel had not loaded yet uploaded to the default bucket. Callget(key)where a missing source is expected. -
useBackendStorageSource is removed from @rebasepro/app. Use
client.storage, orclient.createStorageSource(key)for a named source; both carrystorageId,publicandcontext, which the hook dropped.
Client SDK
Section titled “Client SDK”-
client.cron calls
/api/admin/cronby default, not the deprecated/api/cronalias. A server that serves only the old path (or a proxy that forwards only it) now answers 404 to the SDK’s cron calls. Passcron.cronPathto point the client elsewhere. -
Offline, findAll() and iterate() fail with OFFLINE on a page this device never fetched, and count() with no count cached from the server fails with OFFLINE. They used to return part of the collection as if it were all of it, and a count of the cached rows. Catch OFFLINE where the app showed those partial results.
-
QueryBuilder, createBackups and CreateBackupsOptions are no longer exported from @rebasepro/client. They were internal engines: use
client.data.<collection>.where(…)(withor,and,not,cond, still exported) andclient.backups.client.wsis unchanged.
UI kit & plugins
Section titled “UI kit & plugins”- TextField no longer declares maxRows, and MultiSelect no longer declares onChange, name, position, endAdornment, multiple or padding. None was ever read, so passing one was a silent no-op and is now a type error. Delete the prop.
-
A session lasts 30 days of inactivity by default, whichever way the server starts. The runtime (
JWT_REFRESH_EXPIRES_IN) defaulted to 400 days whileinitializeRebaseBackendand the scaffold’s.env.exampleused 30, so how long a session lasted depended on the entry point. The refresh token is still sliding: an active user is never signed out. SetJWT_REFRESH_EXPIRES_IN=400d(orauth.refreshExpiresIn) to keep the longer window. -
An administrator’s chosen password is held to the password policy (
400 WEAK_PASSWORD). OnPOST /api/admin/usersand on every create on the users collection through the data API (REST,/bulk, batch, MCP, the socket), as registration, resets andPUT /api/admin/users/:uidalready were. On a/bulkthe message names the row (Row <n>:), like the bulk’s other row errors, so the admin panel’s import can say which line failed. An import may sendskipPasswordPolicy: trueon a create; the server honours it only for an administrator or the service key, and never stores it. The admin panel’s import sends it for rows that carry apassword.AuthAdapter.prepareUserCreationtakes an optional third argument,UserCreationOptions { mayWaivePasswordPolicy }.
Storage & email
Section titled “Storage & email”-
signedUrlExpiration is removed from S3StorageConfig and GCSStorageConfig. It reached no client: routes hand out metadata and a download token, whose lifetime is
storageDownloadTokenTtl.controller.getSignedUrl()called from your own server code signs for an hour, the old default. -
A named bucket no longer becomes the default storage source by declaration order. A project that declared only named buckets had the first one promoted to serve uploads whose property names no
storageSource. The default source now always reads its own configuration:STORAGE_TYPE,STORAGE_PATH(or./uploads), and the unsuffixedS3_*/GCS_*. If your unqualified uploads relied on the promotion, mark that bucketbucket("media", { default: true }); in production these are the files the default wrote into the named bucket. Without the mark, those uploads answer501 STORAGE_NOT_CONFIGUREDin production, and boot says so once.
Storage & email
Section titled “Storage & email”-
Storage controllers can stream, through two new optional methods,
statObjectandgetObjectStream. The file route uses them to serve a file, aRangerequest, a revalidation and aHEADwithout holding the object in memory, and backups use them to stream a dump. The built-in local, S3 and GCS controllers implement both; a custom controller without them keeps the buffered path.putObject(props, { uploadLimits: false })writes without the controller’s upload limits; only the server’s own backup upload passes it (see Fixed), and it is not part of the client’s upload props. -
rebase storage orphanslists the stored files no row names, and--deletedeletes them. Every row of every collection is read, soft-deleted rows included; files newer than--older-thanhours (24) are left out; nothing is deleted while a collection cannot be read (409 STORAGE_SCAN_INCOMPLETE). BehindGET|DELETE /api/admin/storage/orphans(database:read/database:write). -
rebase storage movecopies a folder of files to another storage source under the same keys, resumable, with--from,--to,--prefix, and--delete-sourceto delete each source copy once the target holds it. BehindPOST /api/admin/storage/move(database:write). -
storageTargetsInText(text)lists the objects a text names (references and pre-reference download URLs), from@rebasepro/typesand@rebasepro/client. -
Local storage warns at boot when its folder is on a case-insensitive file system (macOS, Windows), where
Avatar.pngandavatar.pngare one file.
Server & REST
Section titled “Server & REST”-
isDatabaseUnreachable(),ApiError.databaseUnavailable()andDATABASE_UNAVAILABLE_MESSAGEin@rebasepro/server, for a custom route or function that wants to answer an unreachable database the way the built-in routes do:503 DATABASE_UNAVAILABLEwithRetry-After: 5. -
A list can skip its count, with
?count=noneover REST orcount: "none"onfind(); a value the server does not know is a 400INVALID_COUNT.meta.totalis aCOUNT(*)over every matching row, most of what a list request costs on a large table. Withnonenothing is counted andmeta.hasMorestays exact, because the read asks for one row past the page; over HTTPmeta.totalis then absent.exactis the default. The SDK’siterate()andfindAll()send it on every page, and the admin’s table on every read past its live first page (see Changed). A new client talking to a server that does not know the parameter repeats that read once without it and stops sending it. -
Custom data drivers gain
readTogether()and row preconditions.DataDriver.readTogether(work)runs several reads in one read-only REPEATABLE READ transaction under the driver’s identity; the REST list reads its page and count through it, and a get-by-id its ETag row and body.SaveProps.preconditionandDeleteProps.preconditioncarry the 404 andIf-Matchchecks into the write’s own transaction, on a driver that declareschecksRowPreconditions. All three are optional: a driver without them keeps the separate reads it had. Both Postgres drivers implement them. -
jobs.timeoutMsbounds one job attempt, and every job, queue and topic handler gets a signal aborted at that deadline. A handler that hangs on afetchcan pass the signal on and stop with the attempt. -
getSignal(c)in@rebasepro/server/functionsis aborted when a custom function’s request times out (504). Pass it tofetchso the work stops with the request instead of running on. -
CORS_EXPOSED_HEADERS is exported from
@rebasepro/server. A backend that wires its owncors()exposes the same headers the runtime does (ETag,Retry-After, the resumable-upload headers, …) instead of copying the list by hand. -
Server-side data can be typed from your generated Database. Register it once (
declare module "@rebasepro/types" { interface Register { database: Database } }, orrebase generate-sdk --register, see CLI) andcontext.datain collection callbacks,rebase.dataAsAdmin, and therebasea function or cron receives are typed from it: rows,where/orderBy,include, andcreate/updateinputs.defineFunction<Database>()anddefineCron<Database>()type a single file without registering. Projects that register nothing are unchanged. See “Typed server-side data”. -
With a Database registered, context.data.collection(“my-notes”) is typed like context.data.myNotes. A slug computed at runtime, or one the schema does not declare, stays untyped.
-
context.data.<collection>.trashin collection callbacks, andrebase.data.<collection>.trashin functions and crons:find,count,restore,restoreMany,purgeandpurgeMany, the same trash the browser client has. The reads return only soft-deleted rows; a restore sets the soft-delete field back tonull; a purge deletes the row for good. -
databaseDefaultmarks a column the database fills on create (NOT NULL DEFAULT gen_random_uuid()and the like). A required field with it may be left out of a create, by the admin form and the API; anullsent on create is left to the database; the generatedInserttype makes it optional. Given the expression, in the template formisIdtakes (databaseDefault: "sql`gen_random_uuid()`", new typeSqlExpression), the schema planner writes it as the column’sDEFAULT, sorebase db pushkeeps it;databaseDefault: truedeclares aDEFAULTwritten elsewhere and writes none. “Import from table” andrebase schema introspectset the expression they read on such columns. -
A built bundle can edit its collections from the admin panel. Set
REBASE_LIVE_SCHEMA_REPOSITORY=owner/nameandREBASE_LIVE_SCHEMA_GITHUB_TOKEN(orREBASE_LIVE_SCHEMA_GITHUB_APP_ID,_INSTALLATION_IDand_PRIVATE_KEY). Each change is planned, committed to that repository, and its DDL applied on the deployment’s database. It is off unless set, admin-only, and applied only by a person. A repository named without a usable credential refuses to boot.REBASE_LIVE_SCHEMA_BRANCHandREBASE_LIVE_SCHEMA_COLLECTIONS_PATHdefault tomainandconfig/collections. -
createRebaseBackendbuilds a backend and mounts, starts and listens to nothing: each of those is a call you make.routes.auth(),data(),storage(),functions(),admin(),cron()andmeta()are the route groups as Hono sub-apps. Each carries its own auth context, rate limits and admin gate, so one can be mounted alone at any path, a parameterised one such as/:tenant/dataincluded, andgetUser(c)andgetDriver(c)work inside it.mount(app, { basePath })is the whole layoutinitializeRebaseBackendserves;attachRealtime(server),start()(cron, job workers, sweeps) andshutdown()are the rest.backend.rebaseis the in-process client, andbackend.fetch(request)serves the whole backend as one fetch handler, for a script or a test with no server. See “Custom Server Integration”. -
Boot-time DDL is a decision:
createRebaseBackendrequires a provision option, which istrue,falseor{ collections, realtime }.truecreates what the backend serves: the collection tables and their RLS policies, change capture, the request role and the framework’s own tables.falseopens the database without changing it, for a script, a test or a second process.{ collections: false, realtime: false }keeps the framework’s tables and the role, for a process that does not own the schema in a split deployment.initializeRebaseBackendpassestrue, andREBASE_MIGRATE_ON_BOOT=nonestill turns the collection schema off for any backend. -
Functions and cron jobs are values, and
loadFunctionsandloadCronsread a directory into them.functions: { "send-invoice": sendInvoice }andcrons: { nightly: defineCron({ … }) }sit besidecollections; the record key is the URL segment or the job id.functions: await loadFunctions(dir)serves whatfunctionsDir: dirserves, and the same holds for crons;onSkipreceives each file a loader skips. Loading declares nothing:declareCrons(crons)records crons in the resource graph, for a build step that wants them there. A directory and values for the same kind are refused at boot, naming both. -
composeCallbackscombines several sets of collection callbacks into one, the way the backend combines its own global, collection and property tiers.beforeSaveandafterReadchain,beforeQuerynarrowings are AND-ed, anyfalsefrombeforeDeletevetoes, and theafter*hooks run in order. From@rebasepro/cms-types,@rebasepro/serverand@rebasepro/common.setCollectionCallbacksstill replaces a collection’s declared callbacks, and its warning now namescomposeCallbacks(declared, mine)as the way to keep them. -
createCallContextbuilds thecontexta collection callback receives, so a hook can be unit-tested by calling it. Fields you pass are used as they are; a missingclient,dataorstorageSourcethrows on first use, naming the field;userstays absent unless passed. From@rebasepro/serverand@rebasepro/common. -
Each backend keeps its own state in
backend.services, andbackend.verifyCredential(token)checks a credential against it.services.jwt(aJwtService: sessions, JWKS, download and purpose tokens),access(the access model and the personal-key owner resolver),apiKeyStore,rateLimitStore,authLimiters,collections(),topicsandqueues.createJwtService(config)builds a JWT service on its own, andJwtService,AccessControl,AuthLimiters,BackendServicesandJwtConfigare exported types. Every route group setsc.var.services; set it yourself,c.set("services", backend.services), to make a route of your own answer for a backend. -
setDefaultBackendmakes a backend built by hand the process default: the one the module-level API answers for, namelyrebaseoutside a request,verifyAccessTokenand the other JWT helpers,getAccessModel,verifyCredential, and topics and queues published outside a request.initializeRebaseBackenddoes this for its backend. -
installUnhandledRejectionHandler()is exported, so a backend built by hand can repeatinitializeRebaseBackend’s first step. It returns a function that uninstalls the handler, orundefinedwhen one was already installed orREBASE_EXIT_ON_UNHANDLED_REJECTIONis set. -
setLogOutput("stderr")sends every log line to stderr. From@rebasepro/server, whatever the level and in both the development and the production (JSON) format;setLogOutput()restores the default. For a process whose stdout is its result, asrebase console -eis. -
backend.services.authAdapter: the auth adapter that judges this backend’s sessions, the oneverifyCredentialuses.
Client SDK
Section titled “Client SDK”-
findById(id, { include })andlistenById(id, onUpdate, onError, { include })load relations into the row. A row subscription carries its include to the server, which reads it on every frame, so a live record keeps its relations. -
@rebasepro/clientexports the types its calls are written in:WriteOptions,WhereFilterOp,WhereFor, LogicalCondition, AggregateParams, AggregateResult, IncludeSpec, RowOf/InsertOf/ UpdateOf,StorageSourceand others. A helper that wrapsfind()orupdate()no longer has to rebuild them fromParameters<…>. -
client.offline.status().persistedsays whether the browser agreed to keep the offline database. It is asked withnavigator.storage.persist()when the first write is queued. Queued writes are also stamped with a layout version: a write saved by a newer SDK is kept, not replayed by an older one. -
observeById(id, onResult, onError, { include }) loads relations into every emission, online and offline. The options are typed as
ObserveByIdOptions, and the condition types behind a typedor()/and()group,FilterConditionForandLogicalConditionFor, are exported too (see Changed). -
aggregate() takes a typed orderBy, by group key or alias (
AggregateOrderKey). For exampleorderBy: ["sum_total", "desc"]. The HTTP client sends it, and the in-process accessor now applies it instead of dropping it. -
client.data.<slug>.trashlists, counts, restores and purges the rows a soft delete stamped. It maps to?deleted=only, the update setting the stamp tonull({ field }for a custom soft-delete field), and?hard=true. With offline enabled, the trash is never cached or queued, and a call fails withOFFLINEwithout the server. -
auth.restoreOutcome()says how restoring the session on load ended:restored,none,refusedorunreachable. -
client.data.<collection>.listenCount(params, onCount)keeps a count current: the total matching the filter, again whenever a write changes it. The server reads no rows for it. -
runSqlScript takes an optional signal. On
SQLAdmin.runSqlScriptandRebaseWebSocketClient.runSqlScript, aborting it rejects the run and cancels the statement where the server can. -
offline.keepLastUser(off by default): in cookie mode, an offline reload opens the last signed-in user’s local data instead of the signed-out user’s. Their queued writes replay only once the session is confirmed.offline.status().unverifiedUseris true meanwhile. -
offline.replayBatchSize(default 1): a run of creates queued offline replays as one createMany instead of one request each. -
offline.onConflict: a queued edit replays with If-Match, and a row changed on the server since is yours to “overwrite” or “discard”. Without it, the last writer still wins.
UI kit & plugins
Section titled “UI kit & plugins”-
VirtualTable,ListViewandCardViewacceptundefinedentries indata(a row not read yet, drawn as a placeholder) and anonRowsRendered(range)callback; EntityTableController hassparseDataandonRowsRendered. A table can hold a sparse window of a large collection (see Changed). -
The kit has a
HeartIcon, and gains the accessibility props Alertrole, AvataronClick, ResizablePanelsdividerAriaLabeland ToggleButtonGrouparia-label/aria-labelledby.DateTimeField’slocaleis now applied, as the input’slang. -
ToggleButtonGroup takes fullWidth.
-
UiLabelsProvideranduseUiLabels()give the kit’s own words in another language: the list and card notices, the table’s error title, the search bar’s names and the collection-view checkboxes. English is the default, and nothing changes without a provider. -
formatRelativeTime takes labels, to say “5m ago” in another language (
RelativeTimeLabels,defaultRelativeTimeLabels). -
TableBody and TableRow take variant=“list”: a list of rows on the sheet with no fill of its own, only the hovered row highlighted. Rows inherit the body’s variant. Studio’s storage list view uses it.
-
DateTimeField takes mode=“time”: a time of day alone, kept on 1970-01-01 in the field’s zone. New
DateTimeFieldModetype andUiLabels.openTimePicker. -
Markdown fields get the Autocomplete slash command from the AI plugin, behind the same gate as Autofill (the service available,
getConfigForPathallowing the collection). A field’s owneditorProps.aiControllerstill wins. -
warnDeprecated(key, message)from@rebasepro/commonprints a deprecation message once per process per key. Rebase’s own deprecated names warn through it, and a plugin or library can use it for its own.
Admin (CMS & app)
Section titled “Admin (CMS & app)”-
Four translation keys name the split-view dividers and the drawer’s CMS / Studio switch:
resize_sidebar,resize_editor,resize_listandpanel_mode. All four are optional onRebaseTranslations, so a custom translation table without them falls back to English. -
useCreateFormex takes
beforeSubmit, awaited at the start of a submit, before the values are read. The record form uses it to wait for a file that is still uploading (see Fixed). -
EntityAction.nameKeyis a translation key for an action’s name. The built-in actions set it. -
The record form saves with If-Match. A save made against a version someone has since replaced is refused, and the form says “This record changed since you opened it…”. The edit stays in the form, rebased onto the new version, with the conflicting fields marked.
-
The collection toolbar’s row count, and the “select all N” offer, follow rows other people add and remove, wherever the data source can subscribe.
-
A time-column date property is picked and shown as a time of day alone, in UTC, in the form, the table, the filter and previews — no more 01/01/1970 date part (
columnType: "time"). -
Creating a collection from a file infers a date for a column of epoch timestamps (seconds or milliseconds, 2001–2103) whose name ends in a word like
at,date,timeortimestamp(created_at,updatedAt,signup_date). -
EditorAIControllerProvider(from @rebasepro/cms) gives every markdown field below it an AI controller for the editor’s Autocomplete slash command. -
EntityFormPanelandCollectionsProviderput one record’s form, or one collection’s table, in a page of your own app.<CollectionPanel collection={postsCollection}/>now takes the collection as a value, and<EntityFormPanel collection={postsCollection} entityId="123"/>loads one record (or starts a new one withoutentityId) and saves it through<Rebase>’s data layer, withonSavedandonClose. Both work under<Rebase>and any React Router router, without<RebaseCMS>,<RebaseNavigation>or<RebaseShell>, and bring their ownCollectionsProviderwhen none is above them. Records open over the page, in a side panel or a dialog, never by URL. -
The
modeControllerprop of<Rebase>hands Rebase your app’s light and dark mode. Rebase then leaves<html>,prefers-color-schemeand localStorage alone.useBuildModeController({ enabled: false })builds an inert controller. -
Several layers can register collections with
<Rebase>at once. ACollectionResolverRegistrationContextregistration takes an optionalowneras its third argument, andUrlController.routesMountedisfalsewhere no CMS route serves the page.
-
Schema introspection writes each table’s row-level security policies into its collection’s securityRules, with their names, commands, modes,
TOlists and clauses, so therebase db pushafter it recreates them as they were. The<table>_default_*policies Rebase adds itself are left out. -
rebase doctor names a junction table no collection declares (orphaned_junction, info). It shows the
DROP TABLEfix and never runs it. -
rebase db generate names the destructive statements in the migration it writes, and
rebase db migrate --refuse-destructiverefuses one. The flag dry-runs the pending files and exits 1, applying nothing, when one destroys data. Without it,db migrateis unchanged. -
rebase generate-sdk --registertypes server-side data: it writes the generated types and theRegisterdeclaration toconfig/generated/, where both the backend’s and theconfigpackage’s compiles see them, and remembers the choice inrebase.json(registerDatabaseon the backend app). Later runs,rebase dev’s and a fresh clone’s included, keep them current. -
rebase db push, db generate and db migrate work on the managed development database (PGlite). Atlas needs a second, empty database to plan against, which PGlite cannot provide, so the CLI starts a throwaway in-memory PGlite for the command and stops it afterwards. A column can now be dropped, renamed or narrowed there; before,
rebase devcould only add. The destructive gate is unchanged. -
REBASE_ATLAS_DEV_URLnames the empty database Atlas plans against, instead of the derived_dev_diff. Rebase neither creates nor drops a database named this way. -
rebase consoleopens a Node REPL with the project’s data in scope.rebaseis whatopenRebasereturns andcollectionsthe project’s collections, with top-level await. The project,.env, database and--collectionsare resolved the wayrebase db pushresolves them, and a managed database is started if the project uses one.rebase console -e "<code>"runs one snippet instead: an expression’s value, or a function body’sreturn, is printed as JSON on stdout, with notices on stderr, and a throw exits 1. History is kept in.rebase/console_history. -
rebase upgrade --allow-downgradelets a dist-tag move pins to an older release. Without it,rebase upgraderefuses such a move, withdowngrade_refusedunder--json. -
normalizeEsmSpecifiers(outDir, { write: false })computes the import rewrite without writing anything. From@rebasepro/cli;rebase normalize-imports --checkuses it. -
A new project ships a root TypeScript config that typechecks
scripts/. The root also declares@types/node, sotsc --noEmitat the project root checks something instead of printing its help and exiting 0. Each workspace still typechecks on its own. -
rebase statusreports the branch. It includesbranch: { name, database }when the checkout is switched to one, and the human output says so.
CI & tooling
Section titled “CI & tooling”-
Three new gates:
pnpm check:test-types,pnpm check:castsandpnpm check:test-resolution. Type errors in tests are counted per package and may not grow; the count ofas any,as neverandas unknown asin package source may not grow; and every test runner must import workspace packages from source, notdist. -
pnpm check:auth-sql-tests: every auth module that issues SQL is run against a real database by a test. A module whose queries only a mock ever saw fails the gate. -
pnpm check:icon-button-namesfails on anyIconButtonin package source without an accessible name. -
A package build fails when its
distcontains Vite’s browser stub for a Node built-in.assert-build-output.mjslooks for__vite-browser-externalin every emitted file. The published Postgres driver carried one forreadline, which crashed every confirmation prompt.
GET /api/admin/users/pick, the user picker’s read. It answers id, display name, email and avatar per user, nothing else, tousers:readholders and to anyone who may write a collection with auserSelectfield, so an editor’s user picker is no longer empty and a picked user shows by name instead of a bare id. A collection whose write rules are raw SQL cannot be judged ahead and does not count: there onlyusers:readholders can pick. New typePickedUser.
rls-check
Section titled “rls-check”-
An unconditional policy that nothing can reach yet is reported as latent
info. For exampleUSING (true) TO anonon a table anon holds no privilege on, or in a schema it has no USAGE on. Postgres answers “permission denied” today, so it is not an exposure, but oneGRANTwould make it critical with no change to the policy. It is reported underpolicy-always-trueatinfo, titled “latent”, with what is missing and the fix. Info is below every--fail-onexceptinfo, so default runs are unaffected. -
anonymous-write-allowed reports Rebase databases. Every request there runs as
rebase_user, a signed-out one too whenAUTH_REQUIRE=falseor guest sign-in is on, so a write rule that accepts any row is reported as heuristic, naming the switch. A policy for a role thatanoninherits is reported too.
Studio
Section titled “Studio”-
The property editor warns when a smaller max narrows a varchar or char column. On those column types
validation.maxis the column’s width, so lowering it is a DDL change:rebase db pushasks before it narrows a column, and the change fails while a stored value is longer. -
SQL console: Cancel run while a run is in flight. A server that supports it stops the statement with
pg_cancel_backend; the console is given back at once either way. -
SQL console: Export typed JSON writes each value read by its column’s type (numbers a JSON number holds exactly, booleans, json); Export JSON is unchanged.
Realtime
Section titled “Realtime”-
Live row frames carry the row’s version.
etagOf(row)on alistenById()row is theETagaGETof it answers, so an edit made against what a frame showed can be saved withifMatchand is refused with 412 if the row has moved on. A successfulPATCHanswers with theETagof the version it wrote, andupdate()returns a row that carries it, so conditional edits chain without a read in between. -
The SDK notices a half-open socket and reconnects. A browser cannot see pings, so a socket whose network silently died used to look connected forever. A 0.25 server now sends a heartbeat every 30 s to clients that ask, and the SDK reconnects after two missed. Older servers and older clients behave as before.
-
Retained channels fill holes. A message that arrives past a gap in the sequence (a frame lost between instances, a bus that does not order) is held back while the missed ones are fetched from history, and all are delivered in order. Senders are told the number their own message took (
broadcast_ack), so their own messages are never mistaken for a gap. This is active only between a 0.25 SDK and a 0.25 server. -
REALTIME_MAX_SOCKETS_PER_USER(or realtime.maxSocketsPerUser) limits how many realtime sockets one user holds on a server. A sign-in past it is refused withTOO_MANY_SOCKETSand the socket is closed (4002). Unset, as by default, there is no limit. -
REALTIME_REFETCH_MAX_WAIT_MSsets the longest a change waits for its live frame (default 1000 ms);REALTIME_RECOUNT_INTERVAL_MSandREALTIME_RECOUNT_MIN_ROWSlet a big list reuse its total for a while. Instead of counting it on every write; such frames carrymeta.totalAsOf. Unset, every frame counts, as before. Each has arealtime.*option. -
realtime.pathon the Postgres adapter, andattachRealtime(server, { path }), set the URL path the realtime socket answers. The default is the base path given tomount()with a trailing/apiremoved (/api→/,/x/api→/x), which is where the SDK dials.RealtimeSocketOptionsin@rebasepro/typescarries it to a driver aspath.
Jobs & cron
Section titled “Jobs & cron”- POST /api/admin/cron/:id/trigger?async=1 (and
client.cron.triggerJob(id, { async: true })) answers 202 { accepted,
startedAt, job } as soon as the run has started, instead of holding the
request for the whole run, which a proxy may cut at 60 s. The outcome is in
the run history as the entry with
manual: trueand thatstartedAt. A run that cannot start still answers409. The synchronous call is unchanged and stays the default.
Postgres
Section titled “Postgres”-
openRebaseopens a project’s data from a script, a REPL or a test, with no server. It returnsdataAsAdminandsql, the accessors a function gets,withAuth(user)for that user’s row-level-security-scopeddata, andclose(). Opening changes nothing in the database and reads no environment variable. Against a database no server has booted on,withAuthfails rather than run unfiltered. From@rebasepro/server-postgres; see “Scripts and the Console”. -
Writes through
openRebaseandrebase consolerecord entity history. A write to ahistory: truecollection gets its entry the way a server write does: in the write’s transaction, so a write that rolls back leaves no entry, and as the identity it ran as (servicefordataAsAdmin, the user’suidunderwithAuth). Nothing is created for it. If the history table or itsrebase.record_historyfunction is missing, writes to those collections are refused withHISTORY_UNAVAILABLEbefore any callback runs, and reads still work. A script prunes no entries, because retention is the server’s setting. -
openPostgresDriverandpostgresDriverConfigFromEnvare the driver open and the boot’s environment reading, as two public pieces.createPostgresAdapteris one step above them, andopenPostgresDriverreads no environment variable. The adapter’sinitializeDriverconfig takesprovision: false, which opens the driver with no DDL of any kind, andrealtime: false, which takes no part in realtime. -
realtime.cdcanddirectConnectionStringon the Postgres adapter config are the explicit forms ofREALTIME_CDCandDATABASE_DIRECT_URL. Both variables keep working, and still win or fill in as before. -
A script’s openRebase() is typed the way a function’s rebase is. Its accessors follow the
Databasethatrebase generate-sdk --registerregisters, so a misspelled collection, field or enum value in a script is a type error.openRebase<Database>()andOpenedRebase<Database>name the type per call, or for a helper.
Changed
Section titled “Changed”Server & REST
Section titled “Server & REST”-
A database that cannot be reached is answered with 503 DATABASE_UNAVAILABLE, and Retry-After: 5, on every door: the data API, sign-in and the other auth routes, the admin routes, a service-key or API-key request, the realtime socket (an
ERRORframe, also onAUTHENTICATE) and a subscription. They answered503 ROLE_LOOKUP_FAILED“Could not verify your permissions”,500 INTERNAL_ERROR, a 500 with the codeECONNREFUSED, or, on the socket,AUTH_ERROR INVALID_TOKEN, which sent the SDK to refresh a valid session.ROLE_LOOKUP_FAILEDremains for a role lookup the database answered and that still failed. -
A service a request calls that cannot be reached is answered with 503 SERVICE_UNAVAILABLE, such as an S3 or MinIO bucket that is down, or a
fetch()to a host that refuses the connection or does not resolve. It answered500with the socket error as the code (ECONNREFUSED), or500 INTERNAL_ERROR. The server log names the host and the error; the response does not. -
A list page and its count are read in one transaction. A
GET /posts?limit=25was 8 statements in 2 transactions and is 5 in 1 (4 with?count=none); withinclude=*, 13 → 10. The count now comes from the same snapshot as the page. A?distinct=trueread is no longer counted, since its answer never carriedmeta.total. -
A get-by-id with an include or a field list reads in one transaction instead of two.
?include=author: 2 transactions and 9 statements → 1 and 6;?fields=id,title: 8 → 5 statements. The ETag is unchanged. -
An include’s per-relation limit is applied by Postgres.
{ comments: { limit: 2 } }on a page of 100 posts read every comment of every post and kept two of each. At 2,000 comments a post that was 200,000 rows, 4.5 s and about 100 MB of heap; it is now 200 rows and 0.2 s. Ties, and alimitwith noorderBy, go by the related row’s key, ascending. It is still one statement per relation. -
A to-one include takes the foreign keys from the rows it already has. One statement fewer per
belongsToper level:include=author4 → 3,include=author.company6 → 4. -
PATCH and DELETE no longer load every relation of the row before writing it. The 404 and the
If-Matchcheck run on the read the write already makes, inside its transaction. A PATCH of a post with 20,000 comments and 200 tags took 123 ms, 20.6 MB of heap and 2 transactions; it takes 5 ms, 0.8 MB and 1. An item PATCH is 13 → 9 statements, withIf-Match17 → 10, a DELETE 12 → 8. Bulk update,_batchupdate and the history routes do the same. -
A write with no hooks skips its savepoint and its pre-commit check. A create is 8 → 5 statements, an update 9 → 6, a delete 8 → 5. A write a hook started, and a write outside a request’s transaction, keep their savepoint.
-
Bulk writes send half the statements. For 1,000 rows: create 4,007 → 2,005, update 6,007 → 3,005, delete 4,007 → 2,005, upsert of existing rows 6,007 → 3,005. An upsert that meets a stored row reads it once, not twice, and history pruning after a write is one statement, not two.
-
Re-sending a parent’s unchanged children rewrites none of them, and a many-to-many write updates only the links whose pivot values changed. Saving a post with its 2,000 unchanged comment ids (the admin form sends them on every save) rewrote all 2,000 rows, sent 2,000 change notifications and bumped every child’s
updated_at; it now touches none. 20 unchanged junction rows were 20 UPDATEs and are 0. -
A bulk create of users hashes their passwords several at a time. 200 users: 5.7 s → 1.5 s. It applies when the user adapter declares itself
independent(UserCreationWriteContract), as the built-in one does when there is noonCreateUser,onAdminCreateUseror customhashPassword; otherwise users are still created one at a time. -
API responses under 1 KB are sent uncompressed, with a Content-Length. A single-row answer costs 22 µs instead of 68. Larger and streamed bodies are compressed as before; a JSON body of 1 KB or more costs about 10 µs more, for the measurement.
-
Static apps answer revalidations with 304 and gzip each hashed chunk once. Files and SPA deep links carry a weak ETag and honour
If-None-MatchandIf-Modified-Since, so/, a deep link or/logo.svgrevalidates with no body. Content-hashed assets from 1 KB to 8 MB are gzipped once into a 32 MB in-memory cache: an 862 KB chunk costs 0.4 ms of CPU per request instead of 16.7. Precompressed.br,.gzand.zstsiblings are served when a scan at mount finds them, and a release path behind a symlink (current -> releases/N) is still resolved on every request. -
/api/docsbuilds the OpenAPI document once per mount, not on every request. At 200 collections a request after the first takes 1.7 ms instead of 39. -
Webhooks each drain on their own, and an entity change is delivered to every matching webhook at once. With
enqueueEntityChangeand nojobQueue, a receiver that was down delayed every other webhook by 6–36 s per event; a healthy receiver now gets its deliveries in milliseconds, and each webhook keeps its own order.flush(timeoutMs)takes a time budget and resolvesfalseif it runs out (it resolvedundefined).onEntityChangewith three matching webhooks that each answer in T took 3T and now takes T; the results stay in webhook order. The in-memory queue is still unbounded. -
The in-memory rate limiter’s cost no longer grows with the limit. A hit near a limit of 100,000 took about 1 ms and takes 0.1 µs; at 1,000, 11 µs → 0.2 µs. It makes the same decisions.
-
An unknown relation in include is a 400 UNKNOWN_RELATION even when no row matched. A typo against an empty table, under a filter that matched nothing, on a read of a missing row, or nested below a relation that loaded nothing answered 200, and became a 400 the first time a row matched.
-
With liveSchema.repository configured, live schema editing works under NODE_ENV=production and with schemaEditor: false: it writes only to that repository, never to files beside the process.
-
A server committing to a remote repository makes one schema change per deploy. After a commit lands, the next apply is refused with
SCHEMA_CHANGE_FAILED, naming the commit, until the commit is deployed. -
initializeRebaseBackend is now the public steps it stands for: installUnhandledRejectionHandler, createRebaseBackend, setDefaultBackend, mount, attachRealtime and start. Its options, routes and behaviour are unchanged, and its
shutdown()still closes the server it was given; a backend fromcreateRebaseBackendcloses one only when passed{ server }.RebaseBackendInstanceis nowRebaseBackend, a superset of its old members. To change one step, copy those lines and change it. -
The server client’s control-plane calls (rebase.auth, rebase.admin, rebase.functions, rebase.cron) reach the backend’s own routes in-process, not the application’s Hono app. They work wherever, or whether, the routes are mounted. Middleware the application added to its own app no longer runs on them.
-
Two backends in one process no longer share state. Each signs and verifies sessions with its own JWT keys, judges scopes by its own access model, checks API keys in its own store and counts rate limits in its own store.
createRebaseBackendwrites no process global; the module-level API answers for the process default, whichconfigureJwt,configureAccess,setDefaultBackendandinitializeRebaseBackendset. A backend given noauth.jwtSecretfollows the process default’s JWT configuration, as before. -
Inside a request, cron run or job a backend serves, rebase (imported or ctx.rebase), topic publish and queue enqueue reach that backend. Anywhere else they reach the process default.
-
createRebaseBackend sets the log level only when logging.level is given; the logger is process-wide, and building a backend no longer resets it to
LOG_LEVEL. -
A refused session says why. The data API, storage and custom functions answer
401 SESSION_REVOKED(signed out, or the account deleted) or401 ACCOUNT_DISABLED, as the admin routes already did, instead ofUNAUTHORIZED“Invalid or expired token”.createBuiltinAuthAdapter().verifyRequestrejects with that error instead of resolvingnull. The service key now verifies throughverifyCredentialaskind: "service-key", as it does on the HTTP routes, where it used to benull. -
A route’s scope guard refuses a scope nobody could hold where the route is registered. In
requireScope, a name that is not a scope, a target written into the scope, or a misspelt built-in such asdata:raedthrows at registration. An app scope the backend does not declare underauth.scopesanswers500 SCOPE_NOT_DECLARED, instead of403 SCOPE_MISSINGto everyone, admin included, with a message saying admin holds every scope. -
loadEnv()names what is wrong. It throws anErrorreading “The environment is not valid: DATABASE_URL: is required”, with zod’s issues on.issues, instead of a rawZodErrorthat printed asZodError { name: 'ZodError', message: [Getter/Setter] }. -
A token this backend did not sign is one log line. A bad signature is one WARN line and anything else one DEBUG line, instead of an ERROR with a stack on every such request and every correct
nullfromverifyCredential. A routine 401 the server marks expected, such asNO_SESSIONfromPOST /auth/refreshfor a browser with no session, has its request line at info rather than warn, so an anonymous admin-panel load no longer puts a WARN in the dev log.
Postgres
Section titled “Postgres”-
A server booting against an unchanged database runs about a tenth of the statements, and no longer re-applies what is already there. It re-created the helper functions and four indexes (the example app logged “Applied 7 additive schema change(s)” on every boot, now none), re-attached the change-capture trigger on every table, ran ENABLE ROW LEVEL SECURITY on tables that had it, and re-issued the auth tables’ columns, indexes and back-fills. A function altered or an index dropped by hand is still put back. Each table’s RLS policies are now replaced in one transaction instead of one commit per statement. At 200 collections on a disk that really flushes, a warm boot took 8.8–13 s and 5,080 statements; it takes 1.3 s and 467.
-
A first deploy builds its tables in a few dozen transactions instead of one commit per column. At 200 collections the table step makes 49 round trips instead of 3,769, and the cold boot takes 2.2 s and 762 statements instead of 15.9 s and 8,839. A batch that fails is replayed one statement at a time.
-
Boot waits for a busy table in one-second slices, so live queries on it wait at most a second behind a deploy. Schema and policy changes take the table’s lock with a 1 s
lock_timeoutand retry, where a live read used to queue behind the boot for up to the 30 s statement timeout: with a reader holding a table for 5 s, live reads on it waited 4,981 ms and now wait 991 ms. Policies retry 3 times within a 10 s budget; a table still locked keeps exactly the policies it had, and the boot logs it, at error when the policy names differ. A table change retries for about 30 s, as before, then fails the boot with a hint naming the lock. The change-capture trigger retries 3 times, then is skipped with a warning, as before. -
RLS policies check the caller once per query instead of once per row. Every part of a compiled policy that does not read the row is wrapped in
(SELECT …), which Postgres evaluates once per statement and plans around. A deep page of a large table no longer turns into a full scan and an on-disk sort: 1M rows,ORDER BY id DESC LIMIT 50 OFFSET 20000as an admin, 7.3 s → 4 ms, andcount(*)300 → 45 ms. Owner- and tenant-scoped reads are 2–8× faster. Policy names, and who may read or write what, are unchanged; the next boot orrebase db pushrewrites each policy in place. A deployment that applies only generated migrations keeps the old, equivalent text until its nextrebase db generatewrites a migration. -
Each authenticated transaction sets the caller’s identity and role in one statement instead of two.
set_config('role', …, true)joins the claims query in place of a separateSET LOCAL ROLE, which saves a round trip on every read, write and realtime refetch: a get-by-id is 6 → 5 statements, a list 11 → 9. -
Schema planning scales linearly with the number of collections. At 1,000 collections it takes 55 ms instead of 164. Boot,
rebase db pushandrebase db generateplan the schema. -
The Postgres adapter reads its environment variables on the boot path, through postgresDriverConfigFromEnv, not while the driver opens. Every variable and its precedence is unchanged. A mistyped
REALTIME_MAX_SOCKETS_PER_USERnow failsinitializeDriverinstead ofinitializeWebsockets, with the same message.createPostgresDatabaseConnectionandresolveChannelBusSettingtake an optional trailingenv. -
collectionRegistry is optional on the adapter’s initializeDriver config; no bundled driver reads it, so a script no longer passes
collectionRegistry: undefined as never. -
openPostgresDriverno longer logs how authenticated requests are isolated. It returns the sentence asinternals.rlsEnforcement, and the server’s boot logs it, so a script that opens the project prints nothing of its own on stdout at the default log level.openRebaselogs it at debug, and rejects with one readableCannot connect to PostgreSQL at <host>:<port>: <reason>.when the database is down.
Realtime
Section titled “Realtime”-
Live views of a collection written several times a second update at least once a second. The refetch debounce had no maximum wait, so a steady stream of writes held every live view of the collection frozen until the writes stopped. With a write every 100 ms for 3 s, the first update came at 3.3 s; it now comes at 1.1 s.
-
One write’s realtime fan-out uses at most a quarter of the connection pool. Refetches queue, at most max(2, pool size / 4) at a time (5 on the default pool of 20), so REST requests no longer wait behind them. An unrelated query during a fan-out to 200 principals waited 1,001 ms for a connection; it now waits 13 ms.
-
A live list’s refetch reads and counts in one transaction, and skips the count when the first page holds the whole set. Statements per write: 9 → 6 for one principal watching, 801 → 501 for 100.
-
A bulk UPDATE on a list many users watch no longer stalls the event loop. Every changed row visited every group watching the list. A 100k-row UPDATE with 1,000 principals watching stalled the server for 940 ms and delivered its last frame 4.4 s after the commit; now 92 ms and 2.5 s.
-
Change capture reads a changed row’s id without reading the rest of the row. A row with a 200 KB document cost the trigger 150 µs and costs 9 µs; a 20-column row, 32 → 21 µs. The trigger function keeps its name and arguments, its payload is unchanged, and the next boot gives existing triggers the new body.
-
A presence heartbeat that repeats its state sends nothing. Every heartbeat sent each member of the room a
presence_diff: one round at 1,000 members was 1,000,000 frames (108 MB); it is now none. -
A closing socket finds its presence rows by index, and a socket that never tracked presence runs no query. 1,000 closes on a 20,000-member roster took 615 ms and take 3 ms. The index is created at boot.
-
Without change capture, a bulk save is announced to other instances in a few notifications, and they read nothing on receipt. A 1,000-row save across two instances sent 1,000
pg_notifyand made the other instance read 1,000 rows it never used; it now sends 3 and reads none, and the save takes ~235 ms instead of 447. A single change keeps the previous payload; a batch uses a new shape, which an instance still on the previous version ignores during a rolling deploy. -
Channel names that clients invent no longer accumulate in server memory. History retention is resolved per rule rather than cached per channel name, and the prune throttle forgets expired entries: 20,000 invented names at 3,600 a minute left 40,000 entries, now fewer than 6,000.
-
REALTIME_CDC=offremoves the change-capture triggers an earlier boot installed. The process that owns the schema drops both capture functions, and the triggers with them, and logs how many. Give every process the sameREALTIME_CDC: a schema owner set tooffbeside anapileft onautoleaves theapilistening for nothing. -
The realtime socket reads a filter the way REST, MCP and the SDK do. A two-element value array is an implicit
in, and an unknown operator is anUNKNOWN_FILTER_OPERATORrefusal, on list, count and subscription frames. -
Frames arriving on one socket while its identity check is in flight share that check. Each used to query the auth store, which a client re-subscribing after a reconnect did once per subscription. Nothing is cached past the flight and nothing is shared across sockets; a demotion during a shared lookup is seen one frame later.
-
The realtime socket URL gains ?rebase_caps=heartbeat,channel_seq, and an older server ignores it.
-
The SQL console’s cancel is negotiated like the other socket opt-ins. The SDK asks for
cancel_sqlon the socket URL (?rebase_caps=…,cancel_sql) and sendsCANCEL_SQL { requestId }(an admin-plane frame,database:write) only to a server whosehellolists it. -
The realtime socket answers WebSocket upgrades on its own path only,
/for an API at/api, which is where the SDK dials. An upgrade to any other path is left to the host app’s ownupgradelisteners, or answered 404 when there are none. Before, the socket took every upgrade on the server (see Fixed). -
The realtime socket verifies sessions and reads roles through what the server passes it,
verifyAccessTokenandaccessModelonRealtimeSocketOptions, so it uses its own backend’s keys and access model. A socket built by hand without them uses the process default.
Admin (CMS & app)
Section titled “Admin (CMS & app)”-
The admin’s login screen preloads about 2 MB less JavaScript. The example app’s eager set was 3.3 MB (966 kB gzipped) and is 1.3 MB (415 kB). Only English ships up front: another language is fetched the first time it is picked or configured, and the panel waits for it instead of flashing English. The collection table, record views, entity form and side panel load when a collection opens, prefetched while idle after sign-in. The lucide icon map and markdown-it load on first use (see UI kit & plugins), and the
/debug/uiview is out of the preload set.@rebasepro/appand@rebasepro/cmsdeclare"sideEffects": ["**/*.css"], so a bundler keeps only the modules an app uses from their barrels. -
A scaffolded admin no longer preloads the syntax highlighter to get clsx.
rebaseManualChunks, and the template’s copy of it, put React and clsx in chunks of their own (vendor-react,vendor-clsx), sovendor-prism(85 kB) leaves the preload set. -
A collection view loads only the relations its properties declare. List reads (table, list, cards, board, relation picker,
useCollection) name the relations a property declares instead ofinclude: ["*"]. A relation declared only for a record’s tabs is no longer loaded for every row of every page and every realtime re-read: the demo’s orders went from 2 relation queries and about 125 embedded line items per page to 1 and none. Custom code that read a tab-only relation off a list row (tableController.data, an additional field, an entity action) no longer finds it there. -
A scrolled table re-reads the rows it holds at most once every 2 s under a stream of writes, and never counts them. A write after a quiet spell is still picked up at once. Writes every 300 ms for 10 s with 1,000 rows held made 34 reads of 32,300 rows; now 6 of 5,700. Every read past the live first page sends
?count=none: scrolling to 10,000 rows ran 199COUNT(*)s nobody read, now none. -
The collection table redraws only what changed. A view render that changes nothing no longer redraws the rows; ticking a checkbox redraws the row actions instead of every cell (460 cell renders → 0); a table holding 10,000 rows compares only the rows on screen on each realtime update (27 ms → 1–2 ms). Cells share one ResizeObserver (784 per mount → 3) and one validation schema per column (276 → 12). Asking whether a row is selected takes constant time, so “select all loaded rows” at 10,000 rows takes 0.3 ms instead of 172. A date column in a fixed timezone builds its formatters once (60 cells: 240 → 2), and a table with pinned rows (the relation picker’s dialog) no longer gets a new row array on every render.
-
Loading the next page of cards draws the new cards only, and the list view no longer redraws every row on each update. With 2,000 cards loaded, the next page rendered 4,050 cards (798 ms); now 50.
-
The kanban board no longer keeps the table view’s first page subscribed beside its columns. A board of N columns held N + 1 subscriptions; now N. The toolbar’s “select loaded” no longer selects the hidden table’s rows; “select all matching” is unchanged.
DataTableControllerProps.enabled(defaulttrue) turns a table controller’s reads off. -
The relation picker reads the rows already linked in one request per 1,000. 40 linked rows took 40 requests and take 1; 2,500 take 3. This covers the dialog and the inline picker; a target with a composite key keeps one read per id.
-
A keystroke in a record form re-renders the field typed into, not every field. In a 40-field form with a 500-item array, one keystroke re-rendered 540 field bindings; now 1. A custom
admin.Field,dynamicProps,conditions, a functionstoragePathorfileName, and every field when a plugin has afieldBuilder, still re-render on each change. A form opened with a draft no longer rebuilds its touched map on every keystroke (1,000 keys: 62 ms per render → 0). -
Array fields no longer re-hash every item on every render or keep a document listener per item. 2,000 items made 2,000 hash calls per render, now none; re-rendering a 500-item sortable list took 192 ms and takes 23; and a 500-item list kept 500 document listeners with no menu open, now none.
-
Form undo keeps the last 200 steps instead of every keystroke. 10,000 keystrokes in a 500-item array retained about 43 MB of history and retain about 0.9 MB. The limit is
FORMEX_MAX_UNDO_STEPS, exported from@rebasepro/forms. -
CSV and JSON import writes up to 500 rows per request instead of 25. A batch is capped at about 1 MB, and a 413 halves it for the rest of the import instead of failing. 10,000 rows took 400 requests and take 20. A failed batch names the row of the file that failed (
failedRow), and Retry resumes after the last committed batch. Reading the file no longer throws an exception per text cell (10,000 × 20 cells: 600 → 90 ms). -
Export and “select all” read 1,000 rows per page by cursor instead of 500 by offset, and process each row in time linear in its columns. A 100,000-row export makes 100 requests instead of 200, with no growing OFFSET; turning 100,000 × 20 into CSV takes 0.8 s instead of 1.35.
-
Thumbnails of stored images download a small webp rendition instead of the original. Tables and cards ask the file route for a 96, 200, 400 or 800 px webp: a 2.47 MB photo comes down as 3.5 KB at 96 px, and a 100-row page as 0.34 MB instead of 247 MB. A server without
sharpis detected with one probe per page load and keeps serving originals, and a failed rendition falls back to the original. -
The user picker fetches its list when opened, not when it mounts. A form with three user fields made three list requests on open; it makes none until a picker opens.
-
A collection scrolled deep re-reads only the rows near the screen on a realtime update, and coming back to it shows the rows that were on screen at once. With 10,000 rows held, a write cost 10 reads of 9,950 rows before and costs 1 read of 120 rows. The scroll cache no longer keeps every row ever shown; the rows above load when scrolled to.
-
The cards view mounts only the rows of cards on screen, and a CSV export is written a page at a time. With 2,000 loaded cards the view held 42,000 DOM nodes and holds about 300. Exporting 100,000 rows × 20 columns held about 196 MB and holds about 51 MB; the file is unchanged.
-
buildEntityPropertiesFromData no longer drops keys starting with _, so a collection created from a Mongo export keeps
_id. Pass{ ignoreKey: k => k.startsWith("_") }to keep the old behaviour. -
CollectionViewHeaderAction and PropertyAddColumnComponent are removed. They were not exported and mounted nowhere.
-
Component overrides compose across scopes: a collection’s wrap override receives the app’s override of the same component as OriginalComponent. Before, it always received the built-in component. A collection override that replaces the component still replaces both.
-
When Rebase owns the theme, it removes the dark class it added to the html element when it unmounts. A
darkclass the page already had is left. -
@rebasepro/cms-types depends on @rebasepro/common, for the
composeCallbacksit re-exports. -
A collection table shows the row id once. When a collection declares a single key property (
isId), the table and the relation picker no longer give that property a column next to the ID column, which showed the same value. SethideIdFromCollection: trueto show the key in its own column. -
@rebasepro/forms: a pendingonValuesChangeDeferredcall runs when the form submits (beforeonSubmit) or unmounts, and is dropped when the form resets. It is otherwise still debounced by 300 ms. -
A relation tab takes its target’s singular name.
getEntityChildViewssets the child view’ssingularNamefrom the target collection; the declaring property’snamestill names the tab, so dialogs say “Remove this Tag from this record?” and “Add Tag”, not “this Tags”.
UI kit & plugins
Section titled “UI kit & plugins”-
Importing one component from
@rebasepro/uibundles that component, not the whole kit. The package ships one file per module: an app importing{ Button }bundles 31 kB instead of 925 kB, minified. Import paths and the package’s exports are unchanged, and the entry is still one"use client"boundary. -
The lucide icon map behind icons chosen by name loads the first time one renders, not before the login screen. That is about 480 kB less preloaded in the example admin.
-
Markdown loads its parser the first time it renders. markdown-it (44 kB gzipped) leaves the preload set. The first markdown of a session shows its source text for one chunk fetch; server rendering, and the hydration after it, show plain text.
-
Chips follow the theme with one observer, multiline fields re-measure only when their content or styling changes, and date fields format once per change. 500 chips created 500 MutationObservers, now 1; five identical re-renders of a multiline field measured it five times, now never; date fields reuse their formatters for named time zones and share one stylesheet (ten fields added 11 copies, now 1).
-
Typing in a form no longer rebuilds the AI plugin’s autofill property map, and building it is linear. It was rebuilt on every keystroke; it is built once per run. At 1,000 blocks a build took 371 ms and takes about 5.
-
The editor’s slash autocomplete sends at most 8,000 characters on each side of the cursor (
MAX_AUTOCOMPLETE_CONTEXT). That is exactly what the hosted service reads. It used to upload the whole document on every request. -
Plugin field wrappers compose: every plugin’s
fieldBuilder.wrapgets the field as the plugins before it left it, and all the wrappers render.params.Fieldis the previous plugin’s wrapper, so the first plugin’s is innermost; a plugin that returnsnullorenabled: falsepasses the field on unchanged. Before, only the last plugin that returned a wrapper rendered.
Studio
Section titled “Studio”-
The SQL console keeps at most 10,000 rows, or 32 MB of values, of a result, and only the last statement’s. A million-row
SELECTno longer takes the server down: its heap went from 432 MB to 7 MB, peak memory from 2.1 GB to 240 MB, and the browser reads it in 31 ms instead of 4 s. A result cut short says how many rows there were (“Showing the first N of M rows”);SqlScriptResultcarriestruncated: trueand the truerowCount. Copy and export include the rows shown. A result too large for one string is no longer reported as an SQL error.client.executeSqlis unchanged. -
The storage browser shows a folder as soon as it is listed, and reads metadata only for the files on screen. A 500-file folder with 12 on screen made 501 requests before it rendered and 500 metadata requests; it makes 1, then 12, six at a time. Deleting a folder deletes up to eight files at once.
-
Picking a table in the RLS editor no longer re-reads the catalogue. Opening it makes 3 catalogue reads instead of 6, and a table click none instead of 3.
-
The SQL console’s schema browser groups 5,000 tables in 10 ms instead of 1.8 s.
-
The schema visualizer lays a graph out once. It laid it out again when RLS status arrived and when the view was reopened, 5 s each at 200 tables; both now take 0.1 ms. The first layout still blocks.
-
The admin no longer asks the database for unmapped tables on every load. The new-collection dialog asks when it opens, its only reader.
-
POST /api/admin/schema/planlists every file/applycommits infiles: the collection’s source first, when the backend can write it, then the generated schema. It used to list only the generated files, so Studio’s review under-reported the commit.
-
The check that stops a destructive schema change also stops the drop of a CHECK or EXCLUDE constraint the database holds, and names it. It is the check behind
rebase db push,rebase db migrate --refuse-destructiveand the reportrebase db generateprints. A push that would still drop one asks on a terminal and refuses elsewhere;--allow-destructiveapplies it. -
Schema introspection classifies a table whose policies give each user their own rows as an entity, not as a code list or as a child hidden from the navigation, however few rows it has.
-
rebase devno longer reads through the node_modules folder under config before it starts the backend. tsx’s default ignores apply only under the project root, so with aconfig/node_modules, as in the monorepo’s example app, the backend started after 16–17 s of crawling; it now starts in 0.1 s. Installing a package there, or a.DS_Storeor editor swap file appearing inconfig/, no longer restarts the backend. -
rebase devreaches “Server running at” about a second sooner. It no longer re-checks a schema it has just generated, and the driver it runs no longer loads the server package: 11 collections took 3.7–4.7 s and take 2.6–2.8 s. -
Driver commands start without loading the server package.
db push,db generate,schema …anddoctorresolve 189 modules instead of 426;rebase schema generateat 11 collections takes 1.2 s instead of 1.6–2.0. -
rebase db pushon a database that already matches runs Atlas once instead of twice. That is one Atlas plan fewer (about 0.9 s at 111 tables, 5.5 s at 411) and 7 database connections fewer per push. The post-steps (RLS, auth tables, vector, search, triggers, policies, role) all still run. -
The CLI loads a command’s code only when that command runs.
rebase --versionand--helpload about a fifth of the modules they did: from source, 254 → 103 ms. Their output is unchanged. -
A project pinned to a prerelease stays on its channel when upgraded.
rebase upgradewith no--to, on a canary for example, takes the newest canary unlesslatestis newer. An exact--toolder than the project is still applied, and the summary says it is a downgrade. -
The telemetry question at the end of an interactive init defaults to no.
-
A failed import check reports on stderr with exit code 1.
rebase normalize-imports --checkused to throw, with a stack-trace hint. -
The other commands follow the database the last dev server ran on.
rebase devrecords whether it ran with--docker. The choice is kept in.rebase/dev-database.json(gitignored) and ranks where--dockerdoes, below everyDATABASE_URL, the branch pointer and--database-url; commands say so on stderr. The nextrebase devwithout--dockerswitches them back to the managed database, and warns that it is leaving the Docker one. -
Anonymized emails are random.
rebase db pull --anonymizewritesuser-<random hex>@example.invalid(no longeruser<id>@…), and gives unique columns<label>-<random hex>values. -
The resource graph in JSON says what the check or the write found.
rebase resources --check --jsonand--write --jsonprint the graph withcheck: { file, status }orwritten: { file, changed }besideversionandresources. -
Starting a bundle with no database URL stops before it boots.
rebase startexits 1 when noDATABASE_URLis set, and printsDATABASE_URL="$(rebase db url)" rebase startfor a local run. It already failed then, inside the runtime, with a less useful message. -
Backups default to ./backups where you run the CLI.
rebase db backupwithout--out, andrebase db backups list, use./backupsin the directory you runrebasefrom, normally the project root: the same directory--out ./backupsnames. Before, they usedbackend/backups. A relative--out, and the dump pathrebase db restoretakes, resolve against that directory; a relativeBACKUP_DESTINATIONstill resolves against the backend directory, where the server reads it. If the new location is empty andbackend/backupsholds dumps,backups listsays where they are. -
A backup takes its destination only from –out. Use
--out ./backupsinstead ofrebase db backup ./backups.rebase db restorerefuses more than one backup. -
The manifest the CLI infers for a project leaves out what equals a default, and spells the build for the project’s package manager.
synthesizeManifest(@rebasepro/cli) writesspa,functionsandcronsonly when they differ from their defaults, and builds the static app with pnpm in a pnpm project. The inferred static app is still namedweb. -
A pull replaces the local database’s contents.
rebase db pullempties every schema it copies, in one transaction, before restoring. For a whole-database pull that is every local schema, so tables that exist only locally, migration history included, no longer survive a pull. -
The service-key commands need an address for a server dev did not start. For
rebase api-keysandrebase storageagainstrebase startin production mode, or a server in a container, setREBASE_BASE_URL; see the Security entry. -
The branch subcommands refuse a flag they do not take. Every
rebase db branchaction,switchincluded, names the closest real flag (“did you mean –older-than?”), asdb backupanddb restoredo. They used to ignore it, so--froceran without--force. -
Each new project gets its own compose port.
rebase inittakes the host port from a per-project range (5433–5632, derived from the project path), never 5432, and skips ports any Docker container is bound to, running or stopped. -
After
schema introspecton tables with row-level security off,rebase db push --dry-runlists what the push does to them: the RLS it switches on and the admin baseline it adds, instead of “No changes”. The push itself is unchanged.
Client SDK
Section titled “Client SDK”-
Walking a collection seeks by cursor and skips the count on every page.
iterate()andfindAll()hand the server’smeta.nextCursorback asafterinstead of a growingoffset, and sendcount: "none", since a walk reports no total. A 100,000-row walk spent about 4.5 s in the database and spends about 26 ms. Listings the server issues no cursor for (relevance, vector, distinct) still page by offset;cursor:now means “require seeking”. -
Response bodies and realtime frames parse at plain JSON.parse speed. The typed-value revive runs only on text that names a
__type: a 1,000-row page took 10 ms of main thread and takes 1.7; a 1,000-row socket frame, 36–41 ms → 4–6. The parsed values are identical. -
A live query sends no HTTP request for rows the socket already replayed to it. Ten
observe()calls on a query the socket held sent ten requests whose answers were discarded; they send none. -
A request made with a token the client knows has expired waits for one shared refresh. It went out, failed with a 401 and was sent again: ten requests after a device slept made 21 round trips, now 11. A custom
Transportcan take part through the optionalsetTokenRefresher. -
The typed object-form where checks each value against its column, as the fluent
.where()does, and.fields(),.vectorSearch(), sum/avg and field operations ($inc, $push, …) accept only what the server accepts. For example["array-contains", ["a"]]on astring[]column is now a compile error, where the server refused it at run time. -
On a client with generated types, or(), and(), not() and cond() groups are checked against the row, as the object-form where is.
posts.where(or(cond("titel", "==", 1)))andcond("views", "==", "many")are now compile errors. Relation and JSON paths are not checked. -
etagOf and withETag now live in @rebasepro/common.
@rebasepro/clientstill exportsetagOf. -
The realtime socket gets its token the way HTTP requests do: the token getter first, then the held token (the session, setToken() or the token option). After a sign-out it no longer falls back to the
tokenoption, which HTTP never did. Installing a token getter or callingsetToken()rebuilds an open socket, so it authenticates as the new credential. -
The token client option is read each time a token is needed, so it may be declared as a getter, on HTTP as well as on the socket.
Storage & email
Section titled “Storage & email”-
Storage files are streamed instead of read whole, and a Range request reads only that range from S3 and GCS. Serving a 100 MB object from S3 held 401 MB of buffers and holds 33 MB. A 1 MiB
Range, what a<video>sends on load and on every seek, read the whole object; it reads 1 MiB. A revalidation that ends in 304, aHEAD, a 416 and a cached image rendition read no body at all. GCS reads are pinned to the generation the route described. -
Uploads are written a chunk at a time. S3 files over 8 MiB go up as multipart uploads, which also lifts the 5 GB single-upload cap; GCS writes from a stream; local disk writes a temporary file and renames it into place. A 100 MB upload to S3 held 201 MB and holds 73 MB. A resumable (TUS) upload’s finalize no longer reads the whole file into memory: it looked for
openAsBlobin the wrong module and always fell back to reading the file.POST /uploadstill parses its multipart body in memory. -
Backups to and from S3 and GCS stream, and dumps over 2 GiB upload. A 100 MB dump held 402 MB of the server’s memory and holds 73 MB; a dump over 2 GiB failed to upload and now uploads. The backup download route and
rebase db restore s3://…stream the dump. -
Paging through a local storage folder reads the directory once, not once per page. 20,000 files in pages of 100 took 5 s and take 40 ms.
-
Listing a local backup folder no longer blocks the server while it stats every dump. At 8,760 dumps the longest stall went from about 50 ms to about 20 ms.
-
An image transform with the rendition cache on reads one stored rendition, not four. It probed four file extensions; on S3 or GCS that is one round trip instead of up to four.
-
S3’s metadata route no longer signs a URL it throws away.
-
Previews of one file share one signed-URL request, at most 8 run at once, and the URL caches are bounded. 50 previews of one private file made 50
/storage/metadatarequests and make 1; 100 different files go 8 at a time; at most 2,000 URLs are kept. This covers the SDK’sclient.storage,useBackendStorageSourceand the admin’s thumbnails, throughcreateDownloadUrlCacheandcreateConcurrencyLimitin@rebasepro/common. -
SMTP mail reuses its connections and holds at most 5 open at once. 50 sends one after another opened 50 connections and open 1, at 112 ms a message instead of 163. A burst now queues behind 5 connections: 50 at once against a server with no connection cap took 175 ms and take 1.15 s. Set
smtp.maxConnectionsto change the cap (3 for Office 365); the pool closes 5 s after the last send. -
In production, a project whose named buckets are bound but whose default source is not logs one warning at boot naming both fixes. Before, it logged an INFO “File storage is off” and an ERROR “No default storage source”.
-
A transform without its image library throws a named error.
transformImagethrowsImageTransformUnavailableErrorwhensharpis not installed, with a message namingpnpm add sharp.
Jobs & cron
Section titled “Jobs & cron”-
A cron job with a time zone no longer blocks the server while it finds its next run. The search read the zone’s wall clock once per minute: a yearly schedule in Madrid took 319 ms and takes 0.4 ms, 29 February in New York 1.9 s and 2 ms. Studio’s Cron panel on a split deployment’s
apirole spent 450 ms per poll on four zoned jobs; it now spends 1 ms, then nothing, since each job’s next run is kept until it passes. -
Finished jobs and old cron claims are pruned every hour, in batches, instead of once at boot. A boot with 2M finished jobs ran a 6 s DELETE before serving; the backlog now drains in the background in batches of at most about 220 ms, and a long-lived pod stays near its retention size. A year of claims took 7.8 s at boot and now goes in batches of at most 170 ms, then about 8 ms an hour. The new partial index
rebase.idx_jobs_finishedis built at the first boot after upgrading (4.7 s on a 2M-row table). A custom store takes part through the optionalJobStore.sweepExpired()andCronStore.sweepClaims(). -
A job attempt times out after 15 minutes when
jobs.timeoutMsis not set. The worker names the default in one log line at start. Setjobs.timeoutMsfor longer jobs, or0for no limit (it used to be refused).
-
Every MCP tool answers in compact JSON, and the collection listing is sent once. Indentation was about half of every answer. A project of 200 collections with 20 fields each got a 1,983 KB listing reply, about 317,000 tokens of it read by the model; it is 697 KB and about 150,000 tokens. At 20 collections, about 32,000 tokens → 15,000. This covers the remote and the local MCP server.
-
The connected-apps list and disconnecting an app read refresh tokens through an index, and expired ones are swept while the server runs. At 210,000 refresh tokens the list took 547 ms and takes 9. Expired codes and refresh tokens are swept at most once an hour, when a credential is issued, instead of only at boot.
CI & tooling
Section titled “CI & tooling”-
The bundle budget fails when a module that should load lazily reaches the login screen.
check-bundle-budget.mjsreads the eager chunks’ sourcemaps and refuses non-English translations,packages/app/src/debug/, the CMS record views and entity form, prism-react-renderer, markdown-it and more than 400 lucide icon modules; a chunk without a sourcemap fails.--distruns the same check and exits 1 on a violation.@rebasepro/ui’s build checks that{ cls },{ Button }and{ Typography }each bundle under 60 kB. -
pnpm check:hookscounts an exhaustive-deps disable that has no-- reason;pnpm check:gates-docchecks the “Tests” rows against the workflow and every package test script against a register in docs/gates.md;pnpm rls:check --strict-baselinefails on a stale baseline entry, and CI passes it. The admin e2e job log lists every Playwright test, flaky ones included. -
Canary builds are numbered as the minor they preview:
0.25.0-canary.g<commit>, not0.24.1-canary.g<commit>, so a canary’s version matches the Since badges in the docs it ships with. It still sorts above every release on the current minor and below the release it previews.
-
/auth/configand sign-up learn whether any user exists without counting and sorting the users table. At 100,000 users that was 2 statements and 26–43 ms; it is 1 statement and 0.3 ms, and in production/auth/configruns no query at all. A custom auth repository can provide the optionalfirstUserIds. -
Verifying an access token no longer re-imports the JWT secret. A verification takes 5.8 µs instead of 20.6, an HS256 signature 3.2 µs instead of 15. A rotated secret is imported once, on first use.
-
A storage request verifies the caller’s token once in the rate limiter, not twice, and a personal API key reads its owner in one statement, not two. A disabled owner’s key is still refused.
Deprecated
Section titled “Deprecated”Client SDK
Section titled “Client SDK”-
The client.ws admin methods (executeSql, createBranch, deleteBranch) are deprecated, with no removal date. Run SQL server-side. The Studio SQL console still uses
client.ws, so those methods stay until it has another path. -
client.ws.setAuthTokenGetter()is deprecated: useclient.setAuthTokenGetter(), which authenticates HTTP and the socket together. It keeps its old meaning, the socket alone, and warns once.
Server & REST
Section titled “Server & REST”-
loadFunctionsFromDirectoryandloadFunctionsWithDiagnosticsare deprecated: useloadFunctions(dir), withonSkipfor the skipped files. Both keep their old shape and behaviour, and warn once per process. -
loadCronJobsFromDirectoryandloadCronJobsWithDiagnosticsare deprecated: useloadCrons(dir), anddeclareCrons(crons)where the old pair declared them. Both keep their old shape and behaviour, still declare what they load, and warn once per process. -
The
prefixoption ofbucket()is deprecated; nothing ever applied it. A source declared withprefix: "tenant-a/"stored its objects at the root of the bucket, so two sources sharing one bucket shared one namespace, and nothing said so. Give each source a bucket of its own (S3_BUCKET__<KEY>,GCS_BUCKET__<KEY>, orSTORAGE_PATH__<KEY>for a local one), or put the folder in each storage property’sstoragePath. A declaration that sets it still compiles and deploys, and the backend warns once per bucket at boot. -
DatabaseAdapter.mountRoutesandBackendBootstrapper.mountRoutesare deprecated. The server never called them, and its adapter wrappers no longer forward them. The types still accept them. A driver that needs HTTP routes exports a route factory for the application to mount.
UI kit & plugins
Section titled “UI kit & plugins”- The plugin hooks
hooks.modifyCollectionAsyncandlifecycle.onCollectionChangeare deprecated; neither was ever called. Fetch the data before rendering<Rebase>and build the plugin from it, so itshooks.modifyCollectionreads data it already has; subscribe withcontext.data.collection(slug).listen(…)fromlifecycle.onMount, unsubscribing inonUnmount. A plugin that sets either still compiles, and<Rebase>warns once per plugin, naming the replacement.
Server & REST
Section titled “Server & REST”-
NO_COLLECTIONSon/api/dataand/api/docsnames the tables that were skipped because row-level security is off on them, with the SQL to enable it, anddetails.unprotectedTableslists them. It said the database had no tables. -
A
400 INVALID_SCOPESno longer offerskeys:readandkeys:writeas valid scopes for a key, in its message or indetails.validScopes. -
A backend with a single database driver no longer warns
No "(default)" driver providedat every boot. -
A
viarelation loads on a booted schema.include, get-by-id, and the nested listing whose path ends on a foreign key returned the relation absent: a join column written in column terms (author_id) was looked up as a Drizzle key (authorId). -
An included row is the row its target’s own route serves. Included rows carried admin reference objects (
{ id, path, __type: "relation" }) for the target’s own to-one relations and dropped undeclared columns. They now carry the foreign key as a column (postId) and no__typeanywhere. Code that read the reference object off an included row reads the foreign-key column, or includes that relation. -
A
distinctlisting’smeta.hasMoreis false when nothing follows. It was true whenever the page came back full. -
Two
If-Matchwrites carrying the same tag can no longer both succeed. Each compared the tag in a read-only transaction before either had written. The tag is now compared after the row is locked, so the second write waits for the first and gets 412. -
like,ilike, not-like and not-ilike on a stringenumfield match its labels. They failed with a 500 on every door. On a number, a date or another non-text field they are a 400INVALID_FILTER_OPERATORinstead of a database error.citextcolumns stay case-insensitive. -
A misspelled operator inside
?or=,?and=or?not=is a 400 UNKNOWN_FILTER_OPERATOR.?not=(title.contains.x)was read as an equality that matched nothing, so the request returned every row. -
An update that changes nothing writes no history entry, and changed_fields no longer lists
idon every update.beforeSaveandafterSavereceivepreviousValueswith the row’s key, likevalues. -
A webhook retry carries the same X-Webhook-Delivery as the first attempt, so a receiver can deduplicate at-least-once deliveries. Queued deliveries send their real
X-Webhook-Attemptwhen registered asctx => dispatcher.deliverQueuedJob(ctx.payload, ctx); every attempt used to say it was attempt 1. -
A function or cron file with a syntax error is reported as
syntax error at <file>:<line>. The boot log told you to move configuration out of module scope, or to usedefineCron. -
A request refused for its size names the configured limit, and health probes stay out of the request log. The message said “Maximum size is 1000 bytes” or “512KB” where it said “0MB” or “1MB”;
/api/healthwas written to the log on every call. -
A production boot with several environment problems lists them all at once, not one per restart. The runtime also refuses a bundle with a static app at a backend path (
/api,/health, …), and logs an error for a static app under a movedREBASE_BASE_PATH, which was never served. -
Two live schema changes applied at once (a double confirm, two tabs) run one after the other. They interleaved their writes and commits. Live schema editing also works in a repository with no commit yet, and its dirty-tree check sees both paths of a staged rename.
-
schema generatewarns when ahasMany/hasOnetarget declares no foreign-key column back. -
A
mapproperty can store objects whose keys start with$. JSON Schema’s$schemaand$id, or a MongoDB export, were refused as misspelled field operations. On a map, only a single-key object whose key is a real operator ({ "$merge": … }) is an operation; other types still refuse a misspelled one. The offline client has no schema, so it keeps the strict reading while offline. -
Reverting to a history entry recorded before 0.24 writes the row’s columns. It answered 400
VALIDATION_UNKNOWN_FIELDS. An embedded owning relation goes back as its foreign key, andmeta.dropped_fieldsin the answer names every other key it left out. A masked value (********) cannot be told apart and is still written back. -
context.data.myNotes (and rebase.dataAsAdmin.myNotes) reaches the my-notes collection whatever its table is. The server resolved every property as its snake_case, so a collection whose table was not that name was unreachable under the accessor its generated type offers.
-
A PATCH answered from an Idempotency-Key replay, or with Prefer: return=minimal, carries the ETag of the version it wrote, like any other successful
PATCH. -
With a remote repository, adding a relation to another collection works. It used to fail with “no file for it in
”. -
Deleting a collection works with a remote repository. It is refused while another collection imports it, and its
index.tsentry is removed in the same commit. It used to be refused outright. -
SCHEMA_EDITOR_DISABLED and SCHEMA_EDITOR_PRODUCTION now say how to edit collections from a deployment.
-
GET /api/auth/configis no longer served by a process with the auth surface off, such as thefunctionsandworkerroles. -
A functions record of what defineFunction returns typechecks:
functions: { "send-invoice": defineFunction(…) }was refused, becauseFunctionAppwasHono<Env>; it isHono<HonoEnv>. -
Storage answers
503 DATABASE_UNAVAILABLEwhen the database is down, withRetry-After, as every other route does. It answered401 UNAUTHORIZED, which told the SDK the session was bad. -
A headless boot names
rebase ejectforunprotectedTables. The boot log and theNO_COLLECTIONSanswer suggested settingbaas.unprotectedTables: "serve"as if it were config. It is a server-entry option, reachable only afterrebase eject.
Postgres
Section titled “Postgres”-
A server that boots while a long report, transaction or
pg_dumpreads the database no longer revokesrebase_user’s access to tables that already had RLS. A no-op ENABLE ROW LEVEL SECURITY that timed out on the table’s lock was read as “RLS could not be enabled”, and the table was revoked, so every replica lost those collections until the next clean boot (12 of 12 in the test). Boot now skips ENABLE where RLS is on, and revokes only after reading that it really is off.rebase db pushdoes the same. -
While a server boots, signed-in users no longer briefly read an empty result for their own rows. Each policy was dropped and re-created in separate commits, so for a moment other replicas evaluated RLS without it. Each table’s policies are now replaced in one transaction.
-
A claim-tenancy policy on a uuid column, brought in with “Import from table” or Studio’s “Import to codebase”, admits its tenants again. A raw policy’s
{column}placeholders were also replaced inside string literals, which broke the claim’s uuid guard regex ({8},{12}), so the imported policy denied every tenant. Placeholders are now replaced only outside literals, quoted identifiers and comments. -
Per-database pools (cross-database admin SQL, branches) time out like the main pool. They waited for a connection with no limit; they now give up after 10 s, and a statement after 30 s.
-
rebase db push --dry-runwrites nothing of the project’s. It no longer regeneratessrc/schema.generated.ts, and it names, rather than deletes, thedrizzle/*.sqlcopies an older release committed. The generator’s output is plain text underNO_COLORand when written to a file or pipe. -
executeSql with several statements answers the last statement’s rows. It answered none.
-
A scheduled backup registered on a machine without
pg_dump/pg_dumpalllogs an error at boot naming the missing tools. It failed silently every night.rebase db backup,restoreandbackups listalso build their S3/GCS client with the function the Backups panel uses (backupStorageFromEnv, exported from@rebasepro/server). -
A table whose name is 42 bytes or more no longer loses its default insert and update policies to Postgres’s 63-byte name truncation. Before, only DELETE survived, and admins could not insert or update. Only names that collided change: they are now
<head>_<hash8>_<op>, at most 63 bytes. The next boot orrebase db pushdrops the truncated policy and creates one policy per operation. -
Boot gives an
isId: "uuid"key the default its table is missing. A table adopted withrebase schema introspect, or created by hand, could carry a uuid key with no default, and every create there failed on a NULL primary key. The next boot now setsDEFAULT gen_random_uuid(), as it already did for the implicit key. A SQL strategy’s key is still left alone, because its function may not exist yet. -
rebase db generate stops with exit 1 and removes the migration it wrote when it cannot append the RLS policies, search, vector or trigger DDL to it. It used to warn and say “You can now run rebase db migrate”, and that migration created its tables with RLS off.
-
A backend created with provision false no longer creates or migrates the auth tables, creates the history table and its function, or re-grants the request role after the driver opens. On a database without the history table, it refuses writes to
history: truecollections withHISTORY_UNAVAILABLEbefore any callback runs. -
readConnectionStringon the Postgres adapter config is honoured. It was declared and ignored, and onlyDATABASE_READ_URLworked. -
Add existing on a many-to-many tab links the chosen rows again. In the admin panel, a post’s Tags tab answered 404 “No row with id …” for every tag not linked yet:
PATCH /api/data/<parent>/<id>/<relation>/<targetId>judged the row’s existence as a member of the set it was being added to. A missing target, a missing or hidden parent and a staleIf-Matchare still refused. -
The history refusal reads “this database is missing the rebase.entity_history table”. The
HISTORY_UNAVAILABLEmessage said “has no the …”. -
When
schema.generated.tsand the database disagree, the boot warning says to runrebase db push(orrebase db generate). It saidrebase schema generate, which leaves the database as it is.
-
rebase db pushafterrebase schema introspectno longer drops a policy whose name has the shape an unnamed rule compiles to (<table>_<op>_<hash>, as on a database Rebase built), andrebase doctor --policiesno longer reports an introspected database’s own policies as orphans. -
rebase doctor --policiesandrebase db pushtell a superseded policy from one written by hand. The first is dropped by the next push; the second is still in force, and is kept by describing it insecurityRules. Both were “left behind by an earlier push”. -
rebase db pushno longer drops the CHECK and EXCLUDE constraints on a collection’s table. A collection cannot declare either, so on a database adopted withrebase schema introspectthe first push dropped every one of them without a prompt, and the database then accepted the rows they refused. Push now leaves them alone, as it does the tables and indexes your collections do not describe; to remove one, drop it by hand. Triggers and functions you wrote were never touched, and still are not. -
rebase db pushno longer drops a hand-written index whose name holds[,],*or?, and no longer refuses a database with a.in a table’s or an index’s name. Each name went to Atlas as a pattern, soorders_idx[v1]matched nothing and the index was dropped without a prompt, and a dotted name failed every push withtoo many parts in pattern. -
rebase db backup,rebase db restore,rebase db branchandrebase schema introspectagainst a database that is not running print the bannerrebase db pushprints, “Cannot connect to PostgreSQL at host:port”, instead of a rawconnect ECONNREFUSEDor a DrizzleFailed query: CREATE SCHEMA …. -
Listing branches, and asking about one, only reads.
rebase db branch listandinfocreated therebaseschema and the branch registry. -
rebase db branch prunewith no terminal and no--yesexits 1 and says to pass--yes, instead of printing “Nothing was changed.” and exiting 0. -
rebase db push --yessays it proceeded because--yeswas passed, not--allow-destructive. -
rebase schema introspectno longer leaves an empty output directory when it cannot connect, says why once, and prints the output directory as an absolute path. -
Backups and their
.globals.sqlroles file are readable by their owner only (0600), fromrebase db backupand from scheduled backups. They were 0644. -
A server created by
rebase ejectkeeps local uploads in the project’suploads/when it runs compiled, as it does underrebase dev. Compiled, it usedbackend/dist/uploads, which the next build deleted. -
The
.env.examplea new project gets no longer suggestsSTORAGE_PATH=./uploads. Afterrebase ejectthe server runs inbackend/, so that line pointed atbackend/uploads, not the<project>/uploadsthe server uses when the variable is unset. It now says where files land by default and suggests an absolute path. -
pnpm run buildin a new project no longer warns that “Some chunks are larger than 500 kB”. The chunks it named were the admin’s, already code-split, which the project’s author did not write and cannot split. The template’svite.config.tssetschunkSizeWarningLimit: Infinity, as the repository’s example app does. An existing project can add the same line underbuild. -
rebase doctorandrebase db pushno longer report everypolicy.authenticated()andpolicy.registered()policy as the insecureuid() IS NOT NULLtautology. The drift check accepted only<> 'anonymous', and Postgres stores the guard as<> ALL (ARRAY['anonymous', 'anon']). -
rebase devno longer misses “Server running at” when that line arrives in two chunks. When it missed it, the admin panel never started,.rebase-dev-portwas never written, and after 30 s a healthy backend was reported as down. A partial line is carried to the next chunk, and flushed after 100 ms of quiet so prompts still appear. -
rebase db pushandrebase db generateno longer end their first step with “You can now run rebase db generate”. -
A collection that does not evaluate makes
rebase schema generate,db pushanddoctorprint the error’s message and “Re-run with –debug for the stack trace”, not a stack trace.--debugnow reaches the driver. -
A new project’s
rebase devand builds print no warnings they did not cause. Vite’s “Both esbuild and oxc options were set” and, frompnpm run exampleor an ejected server, dotenv’s banner are gone. -
On a checkout switched to a branch, rebase db branch create,
delete,infoand prune use the main database’s branch registry, aslistdoes. A branch created there is recorded wherelistshows it.createstill copies the branch you are on unless you pass--from. -
rebase db push’s banner for making a populated column required offers only remedies that work. It no longer suggests a default for a column that already exists (a default never fills existing rows); it offers the backfill, and says nothing in the push was applied. -
rebase db push(and--dry-run) explains what it cannot do. A type change PostgreSQL cannot cast gets theALTER … USINGto run; auniqueadded over repeated values lists the repeats; and an enum option you removed that the database keeps (PostgreSQL cannot drop an enum label) is named, with how to retire it.rebase doctorno longer sends you back todb pushfor it. -
rebase db pushrenames relation columns that still carry their pre-0.13 name (categorie_id) the wayrebase devdoes, instead of listing them as destructive drops. -
rebase schema introspectno longer writes a Kanban board withoutorderProperty. It warned on every generate; it declares one only when the table has an order-key column. -
Re-running
rebase skills installkeeps skill files you edited (--forcereplaces them) and removes skills a release retired. A.rebase-skills.jsonmanifest records what was installed. The command no longer promises “the latest skills” on a re-run: it reinstalls the version this CLI pins. -
The skills tell agents to use the project’s own package manager, and the
rebase-basicsskill’s MCP reference lists all 42 local tools and the remote/mcpendpoint’s tools. The scaffold’s agent instructions no longer sendpnpm db:pushat the managed development database, which refuses it. -
A new git-initialized project stays clean through its first dev run, so its first Studio schema edit is no longer refused with
SCHEMA_EDIT_DIRTY_TREE.rebase init --gitwrites the preset’s real generated schema instead of a stub thatrebase devrewrote. -
Every next step init prints exists in the project it printed it for.
rebase init --headless --database-urltold you to runpnpm run db:push, a script the headless scaffold does not have. Every next step is now checked per flavour, and the headless steps include theCREATE POLICYthat opens a table’s rows. -
The headless scaffold’s config workspace typechecks on its own; it was missing
@types/node(TS2688). -
Upgrading a canary project no longer moves it back to the stable release without asking, and no longer orders two canary builds of one version by their git hash.
-
The import check no longer rewrites the files it reports on.
rebase normalize-imports --checkwrote its fixes. Imports that resolve to no file are counted separately, with a message that says the command cannot complete them. -
The status report shows each function’s real mount path instead of
/api/functions/<name>. -
The telemetry project id is stable for the life of a checkout; it changed on every
rebase devrestart. -
A dev server on a fresh compose database no longer fails its schema push with
read ECONNRESET.rebase dev --dockerwaits until the database answers a query (up to 60 s) before pushing, and a push that does fail prints its own output above the warning that refers to it. -
Anonymizing a pulled database works where users have signed in more than once, and is all-or-nothing. In
rebase db pull --anonymize, a column under a unique index (such asrefresh_tokens.token_hash) gets a distinct value per row, replacements fit the column’s length, NULLs stay NULL, views and generated columns are skipped, andcitextcolumns are matched. The pass is one transaction: if it fails, the tables it would have rewritten are emptied and named and the command exits 1, so real personal data is never left behind silently. The pull re-grantsrebase_userafter any restore that started. -
The resource check fails in JSON mode too.
rebase resources --check --jsonexits 1 with an error envelope (resource_graph_missing/resource_graph_stale) on a missing or stalerebase.resources.json, and--write --jsonwrites the file. Both printed the graph and exited 0. -
After a dev server on Docker, the other commands use that database.
rebase db url,db push,consoleand the rest used to start an empty managed database beside it. -
With the backend stopped, the hint names the port the dev server uses.
rebase api-keysandrebase storagenamed only thePORTin.env. -
rebase db pushasks before a destructive change on a terminal again, and a restore without –yes asks for “yes” again. In the published driver every confirmation prompt crashed withcreateInterface is not a function. Ctrl-C or Ctrl-D at a prompt answers no instead of hanging. -
A destructive
rebase db pushthat you decline, that cannot ask, or that crashes no longer leaves the<db>_dev_diffscratch database behind. -
Backup and restore refuse an option they do not take, before doing anything.
rebase db backup,rebase db restoreandrebase db backups listname the one you probably meant (--targt-db: did you mean--target-db?). A misspelled--target-dbwas ignored, and the restore went into the live database. -
A failed restore from
s3://orgs://no longer leaves the downloaded dump in the temp directory. -
rebase db migrate --baseline <version>records the version on a database that already has a migration history, with Atlas’smigrate set. It printed that it had, then failed with the same error. When the history already reaches the version, it says there is nothing to record. -
When
rebase db migratefails on a column (42701) or a schema (42P06) that already exists, it prints the--baselineremedy, and names the migration that failed when that is not the newest one. -
rebase db pushon the managed database while the dev server runs no longer fails with a bareError: EOF. If Atlas does lose its connection, the error says what happened and that the command is safe to repeat. -
rebase console -e "…" | jq .works as documented: stdout holds only the result. Log lines, such as theRLS enforcement activenote the scaffold’sLOG_LEVEL=infolet through, go to stderr. -
Input piped into
rebase consoleruns one line at a time, each after the previous line’sawaithas settled, so the documented two-line example no longer fails withCannot read properties of undefined. Input after.exitis ignored, and piped output has no colour codes. -
The status report follows the database the last dev server ran on. On a project on
rebase dev --docker,rebase statusno longer reads as a managed database that is not running. -
rebase apps config <app>, for a checkout not linked to a project, prints this project’s dev backend URL instead of a fixedlocalhost:3001. -
rebase db pullkeeps both connection strings’ passwords offpg_dump’s andpg_restore’s command lines, where any process listing could read them; each tool gets its password inPGPASSWORD. -
rebase apps init --forceon a scaffolded project writes back therebase.jsonthe scaffold ships. It keeps each app’s name,pathandcmsfrom the file it replaces, infers the rest, builds the static app with the project’s package manager, and names a declared app it no longer finds instead of dropping it. -
A project without a
rebase.jsonbuilds its frontend with its own package manager. The inferred build command was alwaysnpm run build --workspace frontend, even in a pnpm project. -
rebase apps init <name>is refused. It never registered one app: it ignored the name and inferred the whole manifest. -
A
--dockerthat aDATABASE_URLoutranks no longer starts the compose container, onrebase dev, therebase dbcommands andrebase console.devalso pushed the schema into that container while the backend ran on the other database. One line now says--dockerwas ignored, and why. -
rebase initaccepts only apostgres://orpostgresql://connection string as the database. The prompt asks again (typingnused to writeDATABASE_URL=n), and--database-urlwith anything else is refused before anything is written. -
Interactive
rebase initrefuses a directory that already holds files before it asks anything, and checks a typed project name as it is typed. -
rebase generate-sdk --from <url>says why the contract endpoint refused: a key in.envis not sent, the shell’sREBASE_SERVICE_KEYgoes only to the linked project, or the token sent was refused. It always said “set REBASE_SERVICE_KEY”. -
rebase auth reset-passwordprints the backend’s refusal, a weak password say, as its message and code, not as the raw JSON body. Both paths label a generated passwordPassword:. -
rebase console --collectionsandrebase doctor --collectionswith a missing path no longer warn that applying it would drop every table. Each says what it did not do instead. -
rebase devsays when it regenerated an out-of-datebackend/src/schema.generated.ts. -
The top-level help lists the api-keys get command, and the help pages of db push, generate and migrate name the database flags they take.
rebase --helpleft outapi-keys get, whichrebase api-keys --helplists, andrebase db push --helpnever said it takes--database-urland--docker. -
rebase db migrateworks on the managed development database. Whilerebase devran it failed withError: postgres: querying system variables: EOF. With dev stopped it failed withrelation "rebase.atlas_schema_revisions" does not exist, thenalready existson every later run, and left a straypublic.atlas_schema_revisions. A client that disconnects mid-response, such as Atlas after a failed run, no longer holds one of the database’s few connections untilrebase devrestarts. A failure on Atlas’s own revisions table no longer recommends--baseline. A managed database that already randb migrateon a canary keeps a harmlesspublic.atlas_schema_revisions, whichDROP TABLE public.atlas_schema_revisionsremoves. -
rebase db branch switchapplies on Postgres in Docker. Afterrebase dev --docker, withDATABASE_URLcommented out,db url,console,db backupanddev --dockerkept using the main database, so writes meant for the branch landed on main. A switch the managed database cannot honour is reported. -
rebase db pullno longer reports a false “incomplete copy” when the local database has a table the source lacks, such asrebase.atlas_schema_revisionsafterrebase db migrate, and a failed restore sends you to “pull again” only when another pull could succeed.--anonymizealso overwritesdisplay_name,middle_name,family_name, and a barenameon tables of people. -
rebase schema introspectreads the project’s database, managed or Postgres in Docker, and honours--dockerand--database-url, instead of “DATABASE_URL is not set”. -
rebase statusdescribes the database the project uses. It reports Postgres in Docker whilerebase dev --dockerserves it, judges a second database by its own connection string instead of marking a workingdatabase("analytics")✗ for the default’s, and says when the checkout is on a branch. -
The headless scaffold can migrate. Its
backend/installs Atlas, sopnpm run db:migrate, and thedb push/db generatethatinit --headless --introspectpoints to, no longer stop on “The atlas binary is missing”.init --database-urladdssslmode=disableto a URL for a database on this machine that names nosslmode, so schema commands no longer fail with “SSL is not enabled on the server”. -
rebase startbesiderebase devon the managed database. It uses one connection, as dev does, so it no longer logsrebase.cron_claims is unavailableand history-sweep warnings for tables that exist, and it listens on its ownPORT. Its hint for a missingDATABASE_URLspells the command the way the project runs it (pnpm exec rebase …). -
The managed development database keeps its port across a stop and a start. After
rebase db stopunder a runningrebase dev, the next command that starts it brings it back where the backend expects it, and the backend reconnects instead of answering 503 until dev restarts. -
rebase devsays what it does. An.envedit restarts the backend. Stopping dev says that the managed database keeps running, stops itself after 30 minutes with nothing connected, and thatrebase db stopstops it now.dev --database-url <url>exits 1, naming the flag, when nothing answers there, and leaves the database.envnames alone.dev --dockeroutranked by.envno longer says “Pass –docker to stay on it”, and keeps the remembered Docker choice. -
rebase db generateon a database that has booted ends with therebase db migrate --baseline <version>it needs first, with the version filled in. -
Quieter commands. With input piped into
rebase console, the banner and database notices go to stderr.rebase console -eno longer printsRLS enforcement active,db branchno longer ends with a pool-shutdown line, and with the database downdb push,db migrateandconsoleprint the “Cannot connect” banner once. A managed database with no free connection is said in words, not asread ECONNRESETwith its stack. -
rebase inithints repeat what you typed. With no terminal it suggests the command that was run, plus--yes, in the form it was run (pnpm dlx @rebasepro/cli@<version>,npx -y …), leaving out the database password and setup key.--helpsays what each mode does about git and the install.rebase upgrade --dry-run --jsoncarrieswouldRefusewhen the real run would refuse. -
Scaffold details.
backend/tsconfig.jsonincludesscripts/, sotsc --noEmitchecks the scripts the Scripts page puts there. The generated.envnames the compose database’s own port everywhere.scripts/example.tssaysinitalready wroteREBASE_SERVICE_KEY.rebase buildsays to commitrebase.resources.jsonwhen it changes it.rebase deploy,login,logsand the other cloud commands typed at the top level suggestrebase cloud ….rebase apps configsays an unlinked checkout is not linked whether or not dev runs. -
rebase db pullworks on the managed development database, the default setup. It failed at the first table withCOPY failed … unexpected EOF on client connection, and the database then stopped answering every command until it was killed. The dump now carries the rows as INSERT statements for it, the copy is the same as a pull into Postgres, and the connection settings pg_restore changes (an emptysearch_path,row_securityoff) are reset afterwards rather than left on every connection, a runningrebase devincluded. A source that uses an extension the managed database lacks namesrebase db pull --docker. -
rebase db pullfrom a source it cannot reach prints the “Cannot connect to PostgreSQL at host:port” (or “Authentication failed for user …”) banner, says nothing was changed, and exits 1, instead of pg_dump’s raw error and the command line. A failed dump leaves no dump file behind. -
rebase schema introspect --data-inferenceand--no-data-inferencework. The driver refused both as unknown options, though introspect told you to pass the first. Both help pages name them, inference no longer prints aDeprecationWarningabout concurrent queries, and “Review the generated files…” is not printed when none was generated.rebase schema staleandrebase doctorname what moved, such asposts (column subtitle is in the file, no longer generated), instead ofposts (differs). -
A mistyped flag names the one it was probably meant to be on
rebase storage,api-keys,auth,apps,build,eject,upgrade,console,status,startand the other commands parsed the same way:unknown or unexpected option: --form (did you mean --from?).rebase storage orphansno longer prints “1 more no row names are newer than 24h”. -
rebase db push --dry-runlists the row-level security the push will change: the tables it switches RLS on for, and each policy it creates, replaces or drops, computed by the push’s own steps. It says “No changes” only when nothing would change. Afterschema introspectit said “No changes” while the push added<table>_default_admin_*policies that let admins read and write every row.rebase db pushprints a one-line count of the RLS changes it makes, and shows each statement Atlas applies once instead of twice. -
rebase schema introspectkeeps a table’s access as it was. A table with row-level security on that does not hold the admin baseline getsdisableDefaultPolicies: true, so the push after it adds no admin policies, and introspection says which tables have RLS off, which the push switches on. An introspected relation whose key another index already serves names that index, instead of “Delete this line to have one built”, which built a duplicate. -
Atlas connects to a database on this machine without SSL when its URL names no
sslmode, as the app does, instead of failing with “SSL is not enabled on the server”. A run that fails before it plans anything no longer leaves a<db>_dev_diffdatabase behind. The missing-atlas hint, from the driver and fromrebase doctor, installs intobackend/in a workspace (pnpm --dir backend add -D @ariga/atlas), which works from the project root.db migrate --database-urlon a project whose default is the managed database no longer gets the managed database’s Atlas handling. -
rebase db migrate --baseline <version>refuses a version no migration file has, naming the versions there are, before anything runs. It printed “Marking every migration up to abc as applied…” and then failed, or answered “Nothing to record” for a version older than every file. -
rebase db restorerefuses what it cannot finish before applying anything, roles included. Into a database that already holds what the backup creates, it names the schemas and tables in the way and both remedies,--cleanor--create-db --target-db <new>, instead of ending on pg_restore’sschema "rebase" already exists. It refuses the managed development database, which cannot take a dump’s COPY data and hung until it was killed, and names the route through Postgres in Docker. A pg_restore failure says what stays. With no terminal it says to pass--yes, and its help says it asks on a terminal. -
The “Cannot connect to PostgreSQL” banner names what named the database:
--database-url, the shell’sDATABASE_URL,.env, a branch, Postgres in Docker or the managed database, instead of “Verify DATABASE_URL in your .env file” whatever named it.db stopanddb reseton Postgres in Docker say so, instead of “this project uses its own DATABASE_URL”.db restore --clenasuggests--clean, anddb backups lstgets a did-you-mean before anything starts.
Admin (CMS & app)
Section titled “Admin (CMS & app)”-
The markdown editor’s link box takes a link, and pressing Enter in it no longer saves the record. The box reset to empty on every keystroke, so no link could be typed or edited, and its submit went on through the popover to the record form around the editor, which saved. Text that is not a link now gets a message instead of being ignored.
-
The record form checks
validation.uniquewhen saving a changed value. It built the check and never ran it, so a duplicate was left to the server to refuse. A duplicate is now an error on that field. It is one lookup per changed unique field, made with the user’s own access, on save only. -
Table cells already drawn follow inline editing, permissions, additional columns and row actions when they change. A mounted cell kept the setting it was drawn with until its row changed: switching inline editing off left every visible cell editable. Row actions also follow the collection, open mode and selection setting they are drawn for.
-
An export releases its file from memory once the download starts. The file stayed in the tab until it closed: 32 MB for a 100,000 × 20 CSV.
-
The relation picker’s dialog shows its selection when one preselected row fails to load. The failure left the selection uninitialised; the row is now logged and left out.
-
Opening a record by its URL, or from its list row, shows its many-to-many values, and saving it no longer unlinks every related row. Adding a tag in the form unlinked every other tag of the record when its live copy arrived.
-
A browser
afterSavethat throws after the record was saved shows a warning, not a failed save. A retry no longer creates a duplicate. -
⌘S and ⌘Z act on the record form you are in. They acted on the first one opened.
-
formAutoSave no longer creates a blank record when a New form is opened, and saves only values that pass the form’s validation.
-
When the server refuses a save, the form marks each field it names.
-
List, card and board views show a date-only value on its own day. West of Greenwich a
columnType: "date"15th read as the 14th in the date slot and the list’s date columns, and a declaredtimezonewas ignored there; they now match the table and the form. -
A record opened from a custom view that asks for full screen opens. The URL changed and nothing opened, including from a shared link. A pasted
#new_sidelink opens one new-record form over the collection, instead of a second full-screen form under the panel. -
List-view rows open from the keyboard. Rows are focusable buttons; Enter or Space opens the record.
-
Load errors and board notices use the kit’s alert and the reader’s language, and “Collection not found”, the unsaved-changes prompt of a full-screen record and unnamed filter presets are translated. They were English “Warning:” / “Error:” banners. “Add existing”, the bulk delete button and the filters dialog’s count follow the neutral colour rule.
-
A property with a
storageSourcethe panel has not loaded yet uploads to that source, never silently to the default bucket. A field withstorage.public: truestores public files with stable URLs, and every panel upload names its property so the server can apply itsmaxSizeandacceptedFiles.useBackendStorageSourcereports the server’s error message instead of “[object Object]”. -
An enum written as a plain list of values in a JS config (
enum: ["draft", "published"]) fills the admin dropdown, and write validation checks it. The dropdown was empty. -
Creating a collection from a CSV or spreadsheet accepts that file’s rows. A
constructororprototypeheader no longer crashes the mapping step; repeated headers (x,x_2) and _-prefixed columns such as a Mongo export’s_idare kept; a text column holding some numbers (S, M, L, 10, 12) gets an enum that includes them;emailandurlare set only when every value is one; and fields inside a map are inferred from their values (enum, required), as top-level fields are. -
The collections editor’s Kanban “Add column” keeps the colour and every other field of the existing columns, refuses a name that is already a column, and shows a failed save. Duplicating a collection with relations saves; it was refused with “Relation property … has no target collection”.
-
Blocks (oneOf) follow their own validation rules. A block’s value is checked against the property its type names, in the form and on the server.
-
alwaysApplyDefaultValues fills a declared default into an empty value when an existing record opens. A Postgres row’s
nullwon, and the flag did nothing. -
A record created from the form lets the database fill the columns nobody set, such as
DEFAULT now()and other column defaults. -
Save waits for a file that is still uploading, and saves its path. The record was saved without the file, and the upload was orphaned.
-
Importing a file of some columns by id (
id,price) updates the existing records instead of failing with “‘name’ is required”. -
Typing a field back to its stored value leaves the form clean.
-
A latitude outside -90..90 or a longitude outside -180..180 is refused, by the form and by the server.
-
disabled: { clearOnDisabled: true } empties every field kind, relations, maps, markdown and user pickers included.
-
A CSV export writes the id column once.
-
An integer column refuses a fraction, in the form and on the server, without validation.integer. Date-bound messages name the bound plainly, and uniqueInArray catches the same related row linked twice.
-
Adding a key-value entry no longer saves an entry under the empty key.
-
The user picker says it could not load users, and why, instead of “No users found”.
-
Emptying a markdown field writes NULL, as a text field does.
-
Table cells, inline and in the popup editor, apply a property’s conditions and dynamicProps, as the form does.
-
An autosave the server refuses marks the fields it names, as a save from the Save button does.
-
The record form’s “does not exist”, “fix the highlighted errors” and “save before adding collections” notices are translated.
-
A bulk delete with failures shows one summary, how many failed and the first reason, instead of one snackbar per row.
-
Account settings → Sessions shows each session’s address and creation time, and names its revoke button, in the user’s language.
-
Importing into the users collection works again. Rows with a stored id are updated, and the rest are created.
-
The built-in record actions (Edit, Copy, Delete, Remove, Reset password, …) are named in the admin language.
-
List, card and search labels, and relative dates (“5m ago”), follow the admin language.
-
Every icon button in the admin and Studio has a translated name, and row-selection checkboxes are named.
-
An app whose locale prop starts in a language other than English can change it later. The first language is no longer stored as the user’s choice.
-
A table cell shows validation only after you edit it. A stored value that fails a column’s rule (a rule added later, or a row written over the API or by SQL) drew a red border and an error tooltip on every cell holding one as soon as the table rendered. Cells now judge a value once it is edited, as the record form does. An invalid edit is still flagged and not saved.
-
“Import from table” keeps an unbounded varchar unbounded (as text) instead of declaring VARCHAR(255), and keeps NOT NULL on jsonb and array columns, so the first push no longer plans narrowing those columns or dropping their NOT NULL.
rebase schema introspectdeclares an unboundedvarcharastexttoo. -
A collection created with “Import from table” keeps each column’s literal default (
DEFAULT true,0,'draft','{}'::jsonb) as the property’sdefaultValue, and readsDEFAULT now()on a date asautoValue: "on_create". Before, the New form sent the type’s fallback (falsefor aDEFAULT truecolumn), and the first push plannedDROP DEFAULTon every such column. -
Record forms, side panels and split views no longer throw outside a React Router data router.
NavigationBlockerProviderworks under any router; under a plain<BrowserRouter>, unsaved-changes guards fall back to the browser’s leave-page prompt. -
Importing @rebasepro/app no longer adds a resize listener to window or reads matchMedia;
useLargeLayoutlistens only while something reads it. -
A record form reported each save twice:
onSaved, and the side panel’sonUpdate, ran twice per save. -
A save inside the draft window leaves no draft behind. Saving a record within 300 ms of the last keystroke left an “Unsaved local changes” draft of it on the next new record, and applying it created a duplicate.
-
Filters → “Clear all” empties every filter field, not only the count. The old values stayed on screen while Apply removed them.
-
Narrow collection toolbars use their icons. On a relation tab beside the split list, controls were drawn over each other; they switch to icon-only forms when the labels do not fit.
-
Admin details. The rich-text editor sets the white-space ProseMirror requires, so runs of spaces no longer collapse and the console warning is gone. An unsaved record’s relation tab says “Save this record before adding related records.” in every locale.
Studio
Section titled “Studio”-
The SQL console’s CSV export follows RFC 4180. Values with commas, quotes or line breaks are quoted, with quotes doubled; the header is escaped too; NULL is an empty field; each value sits under its own column; and objects are written as JSON.
-
On a server that cannot edit collections, the collections editor shows why, instead of a disabled “Add new collection” button.
-
The SQL console starts one run per Cmd+Enter, and a cell editor saves once. Cmd+Enter while a query ran started a second run; Enter saved twice, and Escape now saves nothing.
-
The storage browser asks before an upload replaces files of the same name, and an upload or delete that fails part-way still shows what it did.
-
The storage browser’s list view announces Name, Type and Size as column headers.
-
The panel calls
/api/admin/schema-editor,/api/admin/logsand/api/admin/cron, not the deprecated aliases. The aliases drewDeprecationheaders on every page load. Cron Jobs, the Logs Explorer and the collections editor are translated, and cron relative times follow the app’s language. -
Studio “Commit and apply” commits are authored by the user’s display name and email instead of their user id.
-
The SQL console runs the text the editor shows. Run, Cmd+Enter or Explain right after a paste executed the previous statement, which could re-run a
DELETEexecuted a moment earlier, and the destructive-statement confirmation was decided on that old text. Format right after a paste no longer writes the old query back. The JS console’s Run and the RLS policy editor’s Save had the same stale read and read the editor now. Both consoles keep edits when the remembered active tab no longer exists. -
Studio details. The home page’s SDK snippet points at the project’s API address instead of
http://localhost:3001. The string column type select says “Default (text)”, which is what the column is created as.
Realtime
Section titled “Realtime”-
A batch on the server’s own driver announces its rows after it commits, and announces nothing when it rolls back.
saveMany,updateMany,deleteManyandbatchWriteon the base driver announced each row inside the open transaction, so another instance could refetch the state before the commit, and rows that were rolled back were announced. -
In a split deployment with change capture off (
REALTIME_CDC=off, orautofalling back), writes made byworkerandfunctionsprocesses reach subscribers on theapi. -
Two replicas left on the in-memory channel bus get the warning that broadcast and presence reach only their own pod, under change capture too.
-
With change capture off, editing a link’s own columns, or saving a to-many relation through the parent, updates live child lists such as
posts/1/tags. -
The socket answers a data frame that names an unknown collection with
NOT_FOUND, as REST does, and a frame with an unknowntypewithUNKNOWN_MESSAGE_TYPE. They were answeredINTERNAL_ERROR, or left to time out. -
A host app running its own WebSocket server on the same http.Server no longer collides with the realtime socket (“handleUpgrade() was called more than once”).
Client SDK
Section titled “Client SDK”-
Requests sent while a stored session is being restored go out as the restored user. With an expired token in json mode, or on any load in cookie mode, they went out with no token, so a collection with a public read policy answered them with its public rows. They now wait for the restore.
-
A session whose token answer states no expiry no longer refreshes in a loop. A backend that omitted
accessTokenExpiresAtcaused about 1,900/auth/refreshcalls in 15 s, and one before every request. An unstated expiry is no longer read as passed; a 401 still refreshes. -
functions.invoke()reads a function that answers with text, CSV or a file: passresponseType: "text" | "blob" | "response". A non-JSON answer no longer blamesVITE_API_URLwhen its content type says it was sent on purpose.invokealso refuses a./..path segment (INVALID_FUNCTION_PATH) that would have reached routes outside the function, such as/api/admin/.... -
Every error the client throws has a code. A proxy’s 413/502/500 gets one from its status; a Node client with no
baseUrlfails withBASE_URL_REQUIREDinstead of a network error;storage.getObject()failures areRebaseApiErrors with a status. -
client.dataanswers"posts" in client.data, can be turned into a string, and no longer servestoStringorconstructoras collections. -
admin.createUser() types the one-time
temporaryPassword(and invitationSent, emailDeliveryFailed) it returns, and a generated date column takes aDateoncreateandupdate. Regenerate withrebase generate-sdk. -
An edit queued offline replays under an idempotency key, so a lost answer can no longer write it again over a colleague’s later edit.
-
A queued delete, or bulk delete, of a row someone else already deleted counts as done instead of putting rows back. A queued edit of such a row removes it locally instead of restoring it. Offline bulk edits and deletes are no longer undone by a realtime frame that arrives before they replay.
-
Writes that reference a row created offline in another collection follow the id the server assigns it, and are discarded with it if the server refuses it.
-
An offline page keeps its
limit, and an offline edit that moves a cached row into a filter shows the row there. The offline count moves with every queued write, and the offline database’s tombstones and query bookkeeping stay bounded. -
A sync listener that throws no longer aborts the offline replay or runs it a second time without the cross-tab lock.
-
Queued writes keep GeoPoint / Vector values across a reload, a proto column survives the offline database, and a syncIntervalMs above about 24.8 days no longer retries in a tight loop. A tab running an older build that opens an offline database a newer build upgraded keeps working, in memory, instead of failing every call.
-
A find() that gets a 204, an empty body or a page without data fails with
UNEXPECTED_RESPONSE, and so does a findById() on an empty body. find() resolved as “no rows” or threw aTypeError, and findById() returned{}. -
createMany, updateMany, deleteMany and batch called with something other than an array throw a RebaseClientError with INVALID_BULK_BODY, not a
TypeError. -
A collection whose slug is a reserved name gets a typed accessor. The slugs
collection,thenandtoJSONgenerateclient.data.collectionCollection(and so on), with a warning naming both. The generatedclient.data.collection,.thenand.toJSONnever worked;client.data.collection("<slug>")still does. -
upsert() errors no longer carry the bulk route’s
Row 0 of 1 (id …) failed:prefix. Code, status and details are unchanged, andcreateManykeeps the prefix. -
client.setAuthTokenGetter()authenticates the realtime socket as well as HTTP requests. A host signing users in with its own provider (Clerk, Firebase Auth, its own session) got an anonymous socket, so its live queries ran as a visitor.client.setToken()andclient.setOnUnauthorized()reach the socket too. -
An HTTP request made while a token refresh was in flight no longer goes out with the token of an account that signed out during that refresh.
-
A disabled account is signed out on its next refused request, as a revoked session already was.
useRebaseAuthController().getAuthToken()with nobody signed in waits for the session restore and throwsNOT_SIGNED_INwithout sendingPOST /auth/refresh.
-
An expired access token is logged at debug, not as an ERROR. These are the 401s the SDK refreshes on. Every other verification failure still logs as an error.
-
Shutdown waits for the expired-token sweep’s tick. Closing the pool right after
backend.shutdown()logged “Cannot use a pool after calling end on the pool” from the sweep. -
An authenticator enrolment that was started and never confirmed can be removed.
DELETE /auth/mfa/unenroll(client.auth.mfa.unenroll) asks for anaal2session only once the account has a verified factor, the same rule as enroll and verify. -
The admin user update and the admin password reset with a chosen password report a weak password with the WEAK_PASSWORD code. That is
PUT /api/admin/users/:idandPOST /api/admin/users/:uid/reset-password. -
A session token whose uid names no account is refused with
401instead of503 ROLE_LOOKUP_FAILED“Please try again”. This includes a uid the users table cannot hold, such asgenerateAccessToken("u1", …)against UUID ids. -
requireScopemounted with no auth middleware in front of it says so in its 401 and namesrequireAuth, instead of answering “Authentication required” to a valid session.
UI kit & plugins
Section titled “UI kit & plugins”-
A
VirtualTablecell draws with the current renderer, columns and sort props. Its comparator ignored them, so a change reached a drawn cell only when its row changed. Studio’s SQL and JS consoles pass an inline renderer, so their visible cells now redraw with each console render. -
The table header shows the filter just applied, not the one before. A filter set from outside the header (the URL, a preset, a clear) appeared one render late.
-
Accessibility across the kit. A table header’s cells are column headers (
th scope="col") andTableRowaccepts aref; error and warning alerts are announced; a bare avatar is no longer an extra, unnamed tab stop inside the user menu; a grouped select announces each group with its options; an errored date field or multi-select is announced as invalid; split-view dividers resize by touch and from the keyboard; the table filter, view-settings and board “add” icon buttons are named; toggle-button groups are named by what they choose, not “Toggle options”; and a disabled search bar can no longer be typed into. -
A multi-select’s “(Select All)” selects every option, including one whose value is empty, and shows checked when they all are.
-
The dialog scrim and the popover arrow follow the theme, and a keyboard-opened popover no longer rings its whole panel. With reduced motion on, dialogs, sheets, popovers, tooltips, collapsing panels and icon buttons no longer animate.
-
Split-view dividers say what they resize (“Resize the sidebar”, “Resize the editor”, “Resize the list”) in the app’s language. This covers the SQL and JS consoles, the RLS editor, the schema visualizer, the collections editor and the split collection list.
-
The drawer’s CMS / Studio switch is named in the app’s language, and Tab no longer stops on it while the collapsed drawer hides it.
rls-check
Section titled “rls-check”-
policy-authenticated-tautologyreports a rule whose condition is onlypolicy.authenticated(). Its pattern expectedarray [with a space, and Postgres storesARRAY[…], so it never fired on Rebase’s own policies. It is a high, heuristic finding, sorls-checkwith the default--fail-on highnow exits 1 on such a rule: scope the rule, or skip the check with--skip policy-authenticated-tautology. -
A policy role whose name contains a comma is read as one role, and a database is labelled Rebase only if it has the
rebaseschema. A server with arebase_userrole was enough. -
A check that crashes makes the scan incomplete (exit 2), and a scan with no tables in scope says that nothing was checked. Both passed silently.
-
The printed fixes for current-setting-throws, rls-enabled-no-policies and
policy-anonymous-tautologyrun when pasted, andpolicy-always-truecatchesNOT false,1 > 0,true OR …and constants compared through multi-word casts. -
A policy that only tests auth.role() = ‘authenticated’ (or the role claim of auth.jwt(), either way round) is reported like TO authenticated USING (true). It is critical, under
policy-authenticated-tautology, and used to give no finding. -
view-bypasses-rls no longer calls a view a certain bypass when its owner is itself subject to the base table’s policies (not a superuser, no BYPASSRLS, not the owner, or the owner under FORCE). Such a view returns what the policies give its owner, to every caller. It is still reported, at the same severity, as heuristic.
Storage & email
Section titled “Storage & email”-
On a self-hosted container, every local bucket’s files survive a recreate. A named local bucket (
bucket("media", { engine: "local" })) ignoredSTORAGE_PATHand resolved beside the runtime’s bundle, on the container’s own filesystem in the compose filesrebase initandrebase ejectwrite, so its uploads succeeded and went with the container. It now sits beside the default bucket’s directory:STORAGE_PATH=/storage/uploadsputsmediain/storage/uploads__media. Both compose files mount their volume at/storage, which the runtime image and the ejected Dockerfile create for the user they run as. Their old/uploadsvolume was root’s, so default-bucket uploads failed withEACCEStoo. A project scaffolded earlier makes the same change to its compose file (see the upgrade guide). Where a named bucket’s old directory still holds files, boot names it and theSTORAGE_PATH__<KEY>that keeps serving it. -
Backups over 50 MB upload. A backup was held to the storage’s upload limits,
maxFileSize(50 MB unless set) andallowedMimeTypes, sorebase db backuptos3://orgs://, and the backup cron set up as the docs show, failed withPAYLOAD_TOO_LARGEonce a dump passed 50 MB. The backup upload now skips them (see Added); user uploads still apply them. -
A local-storage write that fails part-way no longer leaves a truncated file in place of the previous object.
-
A mistyped S3/GCS bucket or refused credentials answer
502 STORAGE_MISCONFIGUREDnaming the bucket, and anallowedMimeTypesrefusal is400 STORAGE_FILE_TYPE_REFUSED. Every file read “not found”, every delete succeeded, and the refusal was a 500. -
A declared bucket with nothing bound is named at boot with the variable to set (
S3_BUCKET__MEDIA), and its 501 names it too. -
Local storage’s
/metadatacustomMetadata holds only the caller’s metadata, as on S3 and GCS. -
A mail server that accepts the connection and never answers fails the send after 30 s (
smtp.timeoutMs). It took nodemailer’s two to ten minutes, holding the invite, verification and reset requests that await their mail. -
Images saved in a markdown field before storage references show again. Their download URLs’ tokens had expired;
resolveStorageReferences, the admin’s markdown preview and editor renew them from the object the token names.storageReferencesForDownloadUrls(text)rewrites such text to store references;storageTargetOfDownloadUrl(url)reads one URL. -
Adding
bucket("media", { engine: "s3" })toconfig/resources.tsno longer hides the files already in./uploads. The default source stays on./uploads(or itsS3_BUCKET), andmediagets its ownuploads__media. -
Two storage sources configured onto one directory (or one inside another), or onto one bucket on one endpoint, are refused at boot with the variable to change. They silently served each other’s files.
-
Without the optional image library, a resized-image request is served the original image instead of a 500. That library is
sharp. The backend logs one warning namingpnpm add sharp, instead of an error with a stack for every thumbnail.
Inference
Section titled “Inference”- inferTypeFromValue calls a
Dateadate.
Jobs & cron
Section titled “Jobs & cron”- The cron tables’ end-user revokes no longer fail at boot with “tuple
concurrently updated”, and “Cron logs table ready” is logged once. The
scheduler, the expired-token sweep and the history retention sweep each ran
the setup on the same database at the same time, so the concurrent
REVOKEs onrebase.cron_logscould fail. Callers on one database now share the setup in flight; a later call runs it again, so tables dropped under a running server (a database reset) come back.
-
On the managed development database, a refused DB tool explains what to do (restart the dev server, or point
DATABASE_URLat a real Postgres). It suggestedREBASE_MCP_ALLOW_REMOTE_WRITES. -
cron_list_jobsreports refused cron jobs and their reasons. It said the project had none.
CI & tooling
Section titled “CI & tooling”-
The headless guard imports the template’s preset collections, and introspect-live fails under CI when it cannot fetch its pinned dumps or reach a server. Locally it skips instead of failing.
-
A release no longer cuts the first words off a sentence that a “Since” badge started. Written
<badge> for the SDK types: on 0.23, …, the badge’s words were the sentence’s head, and the 0.24.0 cut left ten pages starting mid-sentence.verify:docsnow refuses such a badge on an English page, andrelease-docs.mjskeeps its words as plain text if one still reaches a release.
Security
Section titled “Security”Storage & email
Section titled “Storage & email”-
A private file’s signed URL is no longer handed to the next user in the same tab. The SDK’s
createStorage, the admin’suseBackendStorageSourceand the CMS thumbnail cache kept download URLs for the token’s life (about 5 minutes) with no owner. After a sign-out, or another user signing in, the next user’s preview of the same path got the previous user’s URL, even for a file they may not read. Each cache now belongs to the credential that asked (the signed-in user, for thumbnails); a change empties it, and a request in flight at the switch is not reused. A token refresh also starts the cache over. -
The
storageAuthorizehook is skipped only for the two grants storage makes itself (a public object’s read, a download token’s read). An identity that merely has the uidpublicordownload-token, such as a custom auth adapter’s guest, was never asked; it is now asked on every operation, like any user. -
On S3 and GCS the hook’s
bucketis always"default". Naming the provider’s bucket on an upload, listing, folder or resumable upload passed a hook that judged the bucket. -
Local storage refuses keys ending in
.metadata.json(400 INVALID_STORAGE_KEY). Such an upload overwrote another file’s stored content type and metadata. -
The metadata of a key ending in / answers 404 on S3 and GCS, as it already did on local storage. That is
GET /api/storage/metadata/<folder>/and the same key inPOST /api/storage/metadata. Afolder/marker object used to be described and given a download token that read every object under the prefix, while the authorize hook had been asked only about the marker.
Server & REST
Section titled “Server & REST”-
A one-to-many membership write no longer takes a child that the target’s
beforeQueryscope or the trash hides. Setting the foreign key on the listed children ignored the scope that the clear step beside it applied, so a caller could name a hidden child and take it from its current parent wherever RLS allowed the UPDATE. A hidden child is now left alone, as an unknown id is, so the answer does not reveal that it exists. -
A resumable upload can no longer be continued or cancelled by a non-admin user whose uid is
service. -
A query naming an excludeFromApi field or relation answers exactly as one naming an unknown field, so the error no longer confirms the field exists. A filter, sort,
fields,include, aggregateselectorgroupBy, orvector_searchnaming one gets the misspelt name’s code (UNKNOWN_FILTER_FIELD,UNKNOWN_ORDER_BY_FIELD,UNKNOWN_FIELD,UNKNOWN_RELATION,UNKNOWN_AGGREGATE_FIELD,UNKNOWN_VECTOR_PROPERTY), message and details. No unknown-field answer lists such a field among the valid ones. -
A filter through a relation (
?author.salary=gt.1) and an aggregate sort over a relation’s field (orderBy=max(staff.salary)) are judged by the target’s read rule. Both used to be answered for a caller who may not read the field, which made them a one-predicate-at-a-time oracle. -
The functions 404 names a file that failed to load only to a signed-in caller. An anonymous caller gets the plain “No function named …” answer.
-
History entries never expose a related row’s values. Relations are stored and served as
{ __type, id, path }references. Delete entries recorded before 0.24, which embedded the related row, are served with the embedded values removed, and the recorder strips an embedded row before it stores an entry. NewwithRelationReferencesOnlyin@rebasepro/common. -
verifyCredentialreaches the verdict the HTTP routes reach.backend.verifyCredential(token)and the rootverifyCredential(token)checked only the token’s signature, so a custom socket or tunnel built on them admitted a session its owner had signed out of, a token minted for a user that does not exist, and a demoted administrator with the roles in their old token. They now share the built-in adapter’s judgement with the data plane: revocation, the account’s existence and status, and the roles it holds now. The realtime socket already used it. They throw when the database cannot be reached. -
A backend built from the scaffold’s
collectionsarray keeps its read policies.config/collections/index.tsdeclaresdefaultSecurityRulesfor the collections that declare none, and only the directory loader applied them.createRebaseBackend({ collections, provision: true }), as two custom-server.md examples have it, provisioned posts, authors and tags admin-only and dropped all 12 generated policies from the database, so non-admins got empty lists on every server sharing it,rebase devincluded. The scaffold’s index now applies the defaults to the array it exports, and provisioning warns, naming each policy, when a collection loses every rule it had. A project scaffolded before this keeps its old index: if it hands that array to a backend of its own, copy the closing loop from the new template’sconfig/collections/index.ts.
-
Backup and restore never print the database password.
pg_dump,pg_dumpallandpg_restorereceive it inPGPASSWORDinstead of on their command line, so it is in neither an error message, the scheduled backup’s run history nor the process list, and connection strings in the driver CLI’s errors are shown asuser:***@. -
The CLI sends the service key only to this project’s backend. With
rebase devstopped,rebase api-keys,rebase storageandrebase auth reset-passwordfell back to.env’sPORT(3001 in every scaffold) or to a stopped backend’s leftover address, and sent the admin service key to whatever listened there. They now useREBASE_BASE_URLwhen you set it, or the address the running backend recorded while its process is alive. Otherwiseapi-keysandstoragerefuse before sending anything, andreset-passwordresets through the database. -
rebase dev --dockernever logs into another project’s database. Projects scaffolded side by side could get the same compose port, and--dockertook whatever listened there for its owndbservice, then suggestedALTER USERon it.dev --dockerand everyrebase db … --dockernow ask Docker who holds the port, and refuse with its name and how to give the project its own when it is not this project’s container.
Documentation
Section titled “Documentation”Storage & email
Section titled “Storage & email”-
GET /api/storage/sourcesis public on purpose, and the storage page says why. It names the configured sources, never a file; listing and reading files still need a caller. A test holds it public. -
The upload endpoint names its fields.
POST /api/storage/uploadtakesfile,key,bucket,storageIdandmetadata_<name>, and ignores anything else, so a guessedpathfield stored the file at the root key.
Realtime
Section titled “Realtime”-
The realtime page’s listenById() example reads the row it is given (row.name): the SDK hands back a flat row, not an
Entity. -
Database change capture (CDC) has its own page, “Database change capture” (
/docs/backend/realtime-cdc/).
Jobs & cron
Section titled “Jobs & cron”- The cron REST API and the typed SDK’s client.cron have their own page,
“Cron Jobs API” (
/docs/backend/cron-jobs-api/).
- Multi-factor authentication with the SDK has its own page,
“Multi-Factor Authentication” (
/docs/sdk/multi-factor-authentication/).
Server & REST
Section titled “Server & REST”-
Scripts and the console have their own page, “Scripts and the Console” (
/docs/backend/scripts/), and “Custom Server Integration” teaches the backend as public pieces. -
The custom-server page’s “one step under
initializeRebaseBackend” file serves what the runtime serves when pasted into a scaffold. It bound port 3000, loaded no.env(so sign-in was off) and served no functions, crons, storage, health routes or CORS. The services snippet mints a session for a user that exists and putsrequireAuthbeforerequireScope; the scripts page’s snippets live inbackend/scripts/, where the driver can be imported, and say how to run them. -
The quickstart, the instruction-files page and the agent skills say that
rebase db push,db generateanddb migratework on the managed database, as the CLI does; they said it needs your own PostgreSQL. -
custom-server.md’s Express example says what creates its table (
rebase db push; the driver never creates collection tables, andprovisioncovers auth, history and the role), and its install line includesdrizzle-orm. scripts.md’swithAuthexamples hold on the blog scaffold they name.
-
rebase schema staleis documented, on the CLI reference and the schema page, andrebase db branch deleteis documented as refusing the branch you are on (it said it returned you to the main database). -
The database commands have their own page, “Database Commands” (
/docs/cli/database/): push, generate and migrate, backups, pull, the connection string, the managed database and branches. The CLI reference lists them and links there. -
The compatibility page says what a Since badge means on a canary. A canary such as
0.24.1-canary.g<commit>is built frommainand carries what the badges for the next minor describe; its users read “On 0.24 there is none” as applying to them. -
The CLI reference lists the api-keys get command and the database flags of db push.
rebase db pushtakes--database-urland--docker, asgenerate,migrate,branch,backupandrestoredo. -
The agent skill and the MCP page no longer say the CLI refuses db push on the managed database. The MCP gate still refuses its database tools there; the CLI runs
db pushanddb migrate. -
The custom-server page no longer tells you not to declare zod while the scaffold declares it, at the runtime’s range: keep the two ranges equal.
[0.24.0] - 2026-10-08
Section titled “[0.24.0] - 2026-10-08”Breaking
Section titled “Breaking”-
rebase db pushandrebase db generatewrite their SQL to.rebase/sql/instead ofdrizzle/. This coversschema.sql,policies.sql,search.sql,vector.sqlandtriggers.sql. Both commands regenerate these files from the collections before reading them, so the copy in the repository was never read, and it showed up in the diff of every collection change. The new directory ignores itself and is not committed. On the first run after upgrading, the command deletes the old generated copies indrizzle/and names them, so you can commit the deletion. A file there that the generator did not write is kept, and so are the migrations. If one of your scripts readsdrizzle/policies.sql, point it at.rebase/sql/policies.sqland runrebase db pushorrebase db generatefirst. -
rebase dev --generateis removed, because what it did is now the default.rebase devregenerates the SDK types ingenerated/sdk/on start and on every save underconfig/collections/, as it already did for the Drizzle schema. Before this, the SDK was only regenerated with the flag, so a fresh clone had no SDK types until someone ranrebase generate-sdk. Passing--generateor-gnow stops with a message telling you to drop it, andREBASE_AUTO_GENERATEandREBASE_GENERATEare no longer read. -
A relative
--collectionspath is resolved from the directory you run the command in, on every command that takes one. This is the rulegenerate-sdkanddb backup --outalready followed;schema generate,db push/generateanddoctorpreviously resolved it frombackend/instead. The same now applies toschema --outputanddoctor --schema/--sdk. A script written as--collections ../config/collectionsand run from the project root must drop the flag (config/collectionsis the default) or writeconfig/collections; the CLI refuses the old spelling and says exactly this. New scaffolds’ scripts carry no flag.generate-sdknow also accepts--collections(--collections-dirremains an alias). -
rebase generate-sdkfails on a collection file that does not load, instead of skipping it with a warning. It now loads every top-level file inconfig/collections/with the same predicate the runtime boot uses, not theindex.tsbarrel, so a file left out of the barrel is still typed, and a broken file stops the command the way it already stops boot. The barrel is a sort order (plusdefaultSecurityRules), not a registry. -
rebase initandrebase skills install --mcpconfigure your AI agents to start the project’s own@rebasepro/mcp, pinned as a devDependency with the CLI (pnpm exec rebase-mcp;npx --no rebase-mcpon npm), instead ofnpx -y @rebasepro/mcpfrom the registry. A project already pastinitgets the devDependency added, with a message to install it, the next timeconfigureAgentsorskills install --mcpruns. -
rebase api-keys createtakes scopes.--scopes data:read:posts,logs:readnames what the key may do,--rolesthe RLS roles it runs as, and--expires-in <days>or--expires-at <date>when it stops.--full-accessis every scope the service key holds exceptkeys:*.--permissions,--adminand--expiresare removed and refused by name with their replacement.rebase api-keys scopeslists every scope this backend knows.
Server & REST
Section titled “Server & REST”-
createBackupRoutestakesstorageFor(destination), replacing itsstorageoption.listBackupObjects,readBackupBytesandopenBackupStreamthrow for an object destination they have no controller for, instead of returning nothing.BackupScheduleStatus.lastRunexcludes manual runs, and addslastManualRun,disabledInCode,refusedandhistoryError.BackupListingaddslocalDiskOfThisProcess. -
A live schema edit commits the collection and
schema.generated.ts, and no SQL. The commit also carried the five SQL files, but it wrote them at the project root while the CLI writes them next to the backend, so a project could hold two copies that disagreed.SchemaCommitPathsandDEFAULT_COMMIT_PATHSnow hold onlyschemaFile. -
An upsert whose key belongs to a soft-deleted row is refused with
409 ROW_IN_TRASHon every upsert door (?on_conflict=,/bulk,/_batch, the SDK, the socket, andrebase.data/context.data). It used to write the new values into the hidden row and answer201 Created. -
An update that changes a row’s key is refused with
400 KEY_IMMUTABLE, naming the field, on every door including in-processrebase.data. It used to answer500, and in-process the row had already moved under its new key when the write threw. -
Strict-Transport-Securityno longer sendsincludeSubDomainsby default. A deployment that relies on the parent domain’s HSTS header to force HTTPS on a subdomain that cannot itself speak HTTPS yet should setREBASE_HSTS_INCLUDE_SUBDOMAINS=trueto keep the old header. See the entry under Security. -
Permissions are scopes: one
resource:actionvocabulary for every credential. A scope names what a caller may do —data:read,storage:write,functions:invoke,users:write,schema:write,database:write,backups:read,cron:write,logs:read,keys:write— and may name one target:data:read:postsisdata:readonpostsalone. Every signed-in person holds the data plane (data:*,storage:*,functions:invoke), because their rows are still decided bysecurityRules. The admin plane is held by theadminrole, and by any role the app declares underauth.roleson the users collection. Every admin surface now asks for its scope instead of “is admin”: users and roles (users:read/users:write), the schema editors, the RLS audit, the contract and the private API docs (schema:read, andschema:writeto apply), the SQL editor and branches on the realtime socket (database:read/database:write), backups, cron, logs, the development mailbox (users:write, since a captured message carries a working sign-in link) and service keys (keys:read/keys:write). A refusal answers403 SCOPE_MISSINGwithdetails.requiredScope. -
API keys hold scopes, not a permission list and an
adminflag.permissions: [{ collection, operations }]andadmin: trueare gone fromCreateApiKeyRequest,UpdateApiKeyRequestandApiKeyMasked, along withisOperationAllowed,ApiKeyPermissionandApiKeyOperation. A service key hasscopesandroles— the RLS roles it runs as besideservice, where["admin"]reads every row. Keys already stored are converted on boot to the scopes they held and never more:{ collection: "posts", operations: ["read"] }becomesdata:read:posts,"*"becomes the data plane,admin: truebecomes theadminrole and the users, schema, backups, cron and logs scopes. A function grant withoutwritebecomes nothing, since calling a function is not a read."storage"and"functions"are no longer names in the collection field, so a collection calledstorageno longer shares a grant with the files. -
schema-adminis no longer an administrator, and roles are no longer stored objects.adminis the one built-in role. A project that usedschema-admindeclares it with the scopes it should hold, underauth.roles.createRole,updateRole,deleteRole,getRoleById,getUserRoles,RoleRepository,RoleDataandCreateRoleDataare removed: the Postgres driver’s versions saved nothing, and nothing read theisAdmin,defaultPermissionsandcollectionPermissionsthey carried.getUserWithRolesreturns role ids.GET /api/admin/roleslistsadminand the declared roles with their scopes, andBootstrappedAuth.roleServiceis gone. The MongoDB driver no longer seeds arebase_rolescollection. -
The hosted MCP server’s scopes are
data:read,data:writeanddata:delete. Each can be narrowed to collections (data:read:posts), and the tools reach only the collections granted. Grants made withmcp:readandmcp:writekeep their reach: they are read asdata:readand asdata:write data:delete. -
RealtimeSocketLimitsis nowRealtimeSocketOptions. It carries the API-key resolver beside the limits, as the sixth argument ofinitializeWebsockets.
- The remote MCP endpoint (
/mcp) takes the SDK’s own arguments:where(wasfilter),orderByas["field","desc"]or"field:desc"(wasorderBy+order), anddata(wasvalues).query_collectionanswers REST’s shape,{ data, meta }(was{ rows, count, truncated }). An argument a tool does not declare is now refused instead of ignored, and alimitabove 1000 is refused, as on REST, instead of silently clamped to 200.
Client SDK
Section titled “Client SDK”- A write made through
context.dataorrebase.data(in a function, hook or seed) now throwsUNSUPPORTED_OPTIONif it is givenifMatchoridempotencyKey, instead of silently ignoring them. Neither option means anything for a write that never leaves the process.
UI kit & plugins
Section titled “UI kit & plugins”-
ButtonandIconButtonfrom@rebasepro/uiare typed against the element they actually render. A prop the component does not use, includingIconButton’s oldcolorattribute (which never did anything), is now a compile error. Fix a call site by dropping the prop; nothing about what renders changes. -
The insights plugin reads its figures from named sources, over one comparison period. An insight carried its own
data()callback and ascorecardmapping, so a figure on the home page and the same figure on a collection’s card were two fetches that could disagree, and the change beside a figure was whatever precomputed delta the callback returned.InsightsPluginConfignow takessources, functions that each return one record of figures and are called with theperiod(from,to,previousFrom), and an optionalperiod: { days }(30 by default). AnInsightDefinitionnames itssource, itsvalue: { field, format }and an optionalcomparison: { previous, intent, show }; the plugin works out the change from the two figures, as a percentage or, withshow: "absolute", a difference. A source is fetched once per signed-in user however many insights read it. A config whose insight names a source it does not declare, or whoseperiod.daysis not a whole number of at least 1, throws when the plugin is created. Gone:ScorecardConfig,ScorecardFormat(nowInsightFormat, withoutshowSign),InsightDataResult,InsightsCache,useInsightsData,InsightWidgetSkeleton, and thedescriptionanddateRangefields — the row above the figures names the period itself. New:InsightsEngine,resolvePeriod,useInsightSourceandInsightsRow.
-
An open realtime socket is re-checked, and closed when its identity has ended. Before each frame, every 30 seconds for a socket that only listens, and when its token expires, the socket asks whether the account still exists, is still signed in and still has its roles. An ended identity gets
AUTH_ERROR(SESSION_ENDEDorTOKEN_EXPIRED) and close code 4001, and a changed role re-scopes its subscriptions. The SDK reconnects with a fresh token on its own; a client speaking the socket protocol directly must send a fresh token inAUTHENTICATEbefore the old one expires. -
Following an email-verification link proves the address, and nothing else. Without a live session of the account or its password, the link now removes the password and any identity nobody proved, and ends every session, as magic links, email codes and password resets already did. This closes an account pre-hijack (see Security).
client.auth.verifyEmail(token, options?)now POSTs, sends the session when signed in, and may throwPROOF_REQUIRED: pass{ password }to keep the password, or{ removeUnproven: true }. -
client.auth.signUpresolves{ confirmationRequired, user | null, … }, because withrequireEmailVerificationon there is no session until the address is confirmed. -
mfa.enroll().recoveryCodesisnullwhen adding a second factor. The codes are made once, with the first factor;mfa.regenerateRecoveryCodes()replaces them on request. -
/auth/anonymous/linkand linking a provider to a guest need registration open (allowRegistration), since both turn a guest into an account. -
A deleted account’s token answers 401
SESSION_REVOKEDon every door (/meused to answer 404).
Admin (CMS & app)
Section titled “Admin (CMS & app)”- A markdown field stores an uploaded image as a storage reference, not a
URL. The editor wrote a private file’s signed URL into the text, and its
download token expires (five minutes by default), so the image broke in the
panel and on every site that published the text. The text now holds
rebase-storage:<key>, with?storageId=for a named source, and the panel’s editor, preview and board cards exchange it for a fresh URL when they render. A site that renders the markdown itself must do the same:resolveStorageReferences(text, client)from@rebasepro/clientsigns each object once per render and leaves a reference to a missing object as it is. Text saved before keeps the URLs it was saved with.
-
rebase initsets up your AI coding agents. It asks whether to install the Rebase skills and register the Rebase MCP server, lists the agents it finds on the machine (~/.claude,~/.cursor,~/.gemini,~/.codex,~/.kiro, Windsurf’s~/.codeium/windsurf), and pre-ticks those. The skills and each agent’s project-level MCP config land before the first commit:.mcp.json,.cursor/mcp.json,.gemini/settings.json,.codex/config.toml,.kiro/settings/mcp.jsonor.vscode/mcp.json. Existing servers and settings in those files are kept, and a file with comments is left alone. Windsurf reads MCP servers only from a user-level file, so it gets the skills alone. Under--yesnothing is set up unless you pass--agent(-a), e.g.rebase init my-app --yes --agent claude,cursor. Before,rebase initonly printedrebase skills install. -
A static app can have a hostname of its own. Its
pathinrebase.jsonmay be a full URL, such as"path": "https://admin.example.com". The app then answers only on that hostname, and every app without one keeps answering on the others. The longest path still wins a request, and at an equal path the app naming the hostname wins./apiand the other backend paths answer on every hostname before any app. The app is built for the path part of the URL, andcmsstays a path, on the app’s hostname. On Rebase Cloud,rebase cloud deployregisters the hostname on the project and verifies it if its DNS records are already published. If they are not, the deploy prints the records andrebase cloud domains verify <host>to run once they are. The console’s Open CMS link then points at the hostname. Before, every app answered on every hostname the project had, so the admin could only be a path such as/admin. -
rebase skills install --mcpregisters the Rebase MCP server in each agent’s project config, the wayrebase init --agentdoes. Without--mcp, it names the agents whose config is missing the server. Use it for a project that already exists. -
Tokens for CI and agents on Rebase Cloud.
rebase cloud tokens create --can deploy,logsmints a key that acts as you, narrowed to one project and those actions;rebase cloud tokenslists them andrebase cloud tokens revokeends one. Set it asREBASE_TOKENand everyrebase cloudcommand uses it instead of a login session, with no session file read or written. The capabilities aredeploy,logs,env,databaseandbackups; none of them can restore over a live database, which stays with a signed-in owner or admin. -
rebase cloud cron listandrebase cloud cron logs <job> [--limit N]read a deployed app’s cron jobs and their recorded runs with yourrebase cloudlogin. The run history is in the app’s ownrebase.cron_logs, behind the app’s admin gate, which accepts an admin of the app, anrk_key or the service key, and a project owner signed in to Rebase Cloud often holds none of them.rebase cloud debug logscovers only the pod’s last half hour or so. Each command asks the control plane for a token that reads cron and nothing else, is bound to the one project and expires within minutes, then reads the app at its public address with it (--hostreads another address).--jsonreturns the jobs or the runs as the app reports them. It needs a control plane that servesruntime-tokenand an app on a runtime that accepts platform tokens (below); against either one that does not, the command says which one and exits non-zero. Arebase cloud tokenskey needs--can logs, and one created before this release must be created again to cover it.
Admin (CMS & app)
Section titled “Admin (CMS & app)”-
A record’s actions can be buttons, not only menu items. An entity action declared
collapsed: falseis now a labelled button in the record’s bar, ahead of Save, as it already was an icon on the table row. Every action used to sit behind the ⋮ on the record, whatever the developer declared. Where the bar is short of room (a side panel, a dialog, a narrow window) the buttons drop their labels, then the last-declared fold back into the menu. The read-only view carries the record’s actions too; it had none. The menu now lists the collection’s own actions first, then Copy, History and Inspect, then Delete last and set apart — Delete used to be second, between Copy and the collection’s actions. A record action that throws or rejects now shows its error in a notification instead of failing silently, and a button shows a spinner while its promise is pending. -
An entity action can say why it is disabled.
disabledReason, asked with the same props asisEnabledwhen that returns false, is shown under the action’s name in a menu and as the tooltip of a disabled button or icon — on the record’s bar and on the collection’s rows. A disabled row icon now shows its tooltip at all: it was the tooltip’s own trigger, and a disabled icon takes no hover. -
A data-import preview lists every value that will not be imported, per column, with its row and the reason, before anything is written.
-
admin.filledByServeron a property lets a record be created without a required field that a server hook fills, such as a slug computed from the title. The field stays required when editing. The reference app’sposts.sluguses it, so its auto-slug runs from the panel. -
The admin shows a “Live updates paused” banner while the realtime connection is down. Open records and lists keep their data instead of turning into errors.
-
useCreateFormex({ onBaselineConflict })andrebaseEditsare exported from@rebasepro/forms, for a form that needs to know when someone else changed a field it also touched. -
CollectionWindowandisPropertyFilterableare exported from@rebasepro/app. Empty-state overrides now also receiveisFiltered. -
The API keys panel speaks scopes. A key shows its scopes grouped into data, admin and app, with the collections, buckets or functions each is narrowed to, and the RLS roles it runs as — a key running as
adminis marked as reading every row. The create dialog offers only the scopes you hold, narrows data, storage and function scopes to chosen targets, and says in words what the key will be able to do. A My keys tab manages your personal keys when the backend enables them. -
The users table resets two-step verification. Reset two-step verification calls
DELETE /admin/users/:uid/mfa, and is offered only to whoever holdsusers:write, as the route is. The auth controller now reads the scopes the signed-in user holds (heldScopes). -
The users table switches an account off and on. Disable or enable account shows whether the account is disabled and flips it (
PUT /admin/users/:uid { disabled }); it is offered to whoever holdsusers:write, and never on your own account. -
The CMS signs in from a magic link.
<frontend>/auth/magic-link?token=…opened the sign-in screen and dropped the token; it now signs in, through the code step on an account with a second factor, and opens the app at its own address. A visitor already signed in is sent on without spending the link. -
Account settings change the email address. The Profile tab has an email field: it mails the confirmation link and says which address is waiting. The link opens its own step on the sign-in screen, signed in or not, and says which sign-in providers it detached.
-
Remote MCP gets
count_documents(REST’s/count) andsearchStringonquery_collectionandcount_documents. -
Remote MCP’s
list_collectionsreturns each collection’srowandcreateJSON Schemas (required fields, enums, foreign keys) and itssoftDeleteField.
Studio
Section titled “Studio”-
@rebasepro/typesexportsdiffCollections,applyCollectionPatch,nestCollectionPatchPaths,collectionPatchProblems,isCollectionPatch,runtimeOnlyKeyInandRUNTIME_ONLY_COLLECTION_KEYS, behind the live schema editor’s new patch-based writes (see Changed). -
SQLAdmin.runSqlScript(sql, { database, role })returns rows as Postgres’s own text, plus each column’s source table and column, the source tables’ primary keys, the command, the row count and any database notices. It is also available over the socket asEXECUTE_SQLwithmode: "script", and asRebaseWebSocketClient.runSqlScript. -
The Backups panel shows how the scheduled backup is going. A backup job that failed every night left the panel saying “No backups found yet”. The listing (
GET /api/admin/backups,client.backups.list()) now carriesschedule: the job, its cron, whether it is paused, its next run, and its last run with the error when it failed. The panel shows a failed run as an error, a paused schedule as a warning, and otherwise the last and next run.
Storage & email
Section titled “Storage & email”-
STORAGE_MAX_FILE_SIZE(bytes,__<KEY>per source) sets the largest file a storage source accepts; refused at boot unless it is a whole number of bytes. -
STORAGE_DOWNLOAD_TOKEN_TTL/storageDownloadTokenTtlsets the lifetime of private download URLs (default 300 seconds, at most a week). -
resolveStorageReferences,storageReferenceandparseStorageReferenceare exported from@rebasepro/clientand@rebasepro/types, for readingrebase-storage:references out of a markdown or text field.
Realtime
Section titled “Realtime”-
The realtime socket honours
impersonate, sent with the socket’sAUTHENTICATE, under the same rules asx-rebase-impersonate: an administrator’s session only, never an API key or the service key. The decision is re-checked before every frame like the rest of the identity, so demoting the administrator or disabling the user ends the session. Each grant is logged as[Security Audit]. -
client.ws.stateandclient.ws.onStateChange()(RealtimeConnectionState). After an outage of about 15 seconds,CONNECTION_LOSTis reported once to every live subscription’s and joined channel’sonError. The subscription is kept, and its nextonUpdateis the recovery signal. -
REALTIME_MAX_SUBSCRIPTIONS_PER_SOCKET/realtime.maxSubscriptionsPerSocket(default 1000) refuses the next subscription past the ceiling withTOO_MANY_SUBSCRIPTIONS; an invalid value stops the server at boot.
UI kit & plugins
Section titled “UI kit & plugins”-
@rebasepro/uiexportsisKeyHandled(event), for a component that needs to know whether a keypress was already claimed by something above it.DialoggainsdismissOnBackdrop,initialFocusandonCloseAutoFocus;SheetgainsinitialFocus,onOpenAutoFocusandonCloseAutoFocus;BooleanSwitchacceptsaria-label,aria-labelledbyandid. -
SelectandMultiSelectaccepttriggerContent, a node that replaces everything the trigger draws: the value, the box, its padding, its height and the chevron. It is for a caller that frames the select itself, as a table cell does.RelationSelectorandUserSelectoralready took it.
Server & REST
Section titled “Server & REST”-
REBASE_HSTS_INCLUDE_SUBDOMAINSopts back into the oldincludeSubDomainsbehaviour. See the entry under Breaking. -
New docs: “Running more than one instance” and “Backups and restore”.
-
Personal API keys. With
personalKeys: truein the users collection’sauthblock, any signed-in account can create keys at/api/auth/keys(client.personalKeys). A personal key acts as its owner, with the owner’s roles as they are when it is used, and holds no scope the owner does not: demote the owner and the key shrinks, delete the account and it stops. A service key is still created under/api/admin/api-keys. No key may manage keys, and no key is created with a scope or an RLS role its creator does not hold (SCOPE_EXCEEDS_CREATOR,ROLE_EXCEEDS_CREATOR,KEY_MANAGEMENT_SCOPE). A target that names nothing this backend serves is refused (UNKNOWN_SCOPE_TARGET). -
Apps declare their own scopes and roles.
auth.scopeson the users collection names app scopes such asproject:deploy, with a label and an optional target, andauth.rolesgives a role its admin-plane and app scopes. Boot refuses a declaration that reads as a grant it is not: a role listing a data-plane scope, an app scope reusing a built-in resource name, or a declaredadmin. A function checks an app scope withrequireScope,hasScopeorgetScopesfrom@rebasepro/server/functions; a person always holds app scopes, so they narrow keys.GET /api/auth/scopeslists every scope with its wording and the ones the caller holds. -
API keys work on the realtime socket and at
/mcp. A key authenticates the socket with the same check as a request, and its data scopes decide which collections it reads and writes there; it cannot use channels. An MCP client configured with a header can present a key instead of going through OAuth.verifyCredentialgives an app’s own socket or tunnel the same answer for a session token or a key: who it acts as and its scopes. -
Composite primary keys. Mark several properties
isIdand they form one key:rebase db push, the schema check at boot and the generated Drizzle file all write onePRIMARY KEY (a, b), and reads, writes, nested paths andviarelations address a row by every key column (company_translation/1:::en_US). 0.23 refused a secondisIdwhen the config loaded. A relation or reference into such a collection is still refused, by the schema generators and at boot, because a foreign key is one column; reach those rows with aviarelation.rebase schema introspectnow generates a table keyed on several columns, withisIdon each key column. It still skips the table when a key column cannot carryisIdor another table points at it. The live schema editor treats re-keying an existing collection as a migration and names the new key.rebase doctorand the boot warning tell you to mark every key column. -
A server assembled by hand can install the access model a booted backend uses.
@rebasepro/serverexportsconfigureAccess, andaccessModelFromCollectionsto build the model from the users collection, besideconfigureJwt, so a server or test built withoutinitializeRebaseBackendanswers for a person the way a booted backend does.
CI & tooling
Section titled “CI & tooling”pnpm check:tsconfig-entriesfails a tsconfiginclude/paths/extendsentry that resolves to nothing.pnpm typecheckreadstests/e2eagain.
-
AuthAdapter.resolveUser(uid)returns the identity a request from that user would carry now, without a token. The built-in adapter implements it. A custom adapter without it answersx-rebase-impersonatewith501 IMPERSONATION_UNAVAILABLE.@rebasepro/typesexports the header name asIMPERSONATE_HEADER. -
sidandexpon access tokens, andisCurrentSessionin the sessions list. Signing one device out now ends that device’s access token too. -
auth.requireEmailVerification(AUTH_REQUIRE_EMAIL_VERIFICATION, off by default): registration sends a confirmation and password sign-in answers403 EMAIL_NOT_CONFIRMEDuntil the address is verified;/registerthen no longer says whether an address has an account. Registration now mails the verification link either way;POST /auth/verify-emailis the new door the SDK uses. -
auth.refreshTokenReuse("reject", the default and today’s behaviour, or"revoke-session") andauth.refreshTokenReuseIntervalSeconds. -
auth.magicLinkCreatesUsers(AUTH_MAGIC_LINK_CREATES_USERS, off by default): magic links and email codes may create the account, under the registration policy. -
Disabling an account:
PUT /admin/users/:uid { disabled },admin.updateUser({ disabled }), answered withACCOUNT_DISABLED; stored in the newusers.disabled_atcolumn, whichdb pushplans. -
Admin MFA reset and new recovery codes:
DELETE /admin/users/:uid/mfa(users:write, never on an account that outranks the caller; it ends the user’s sessions),client.admin.resetMfa(uid), andPOST /auth/mfa/recovery-codes/mfa.regenerateRecoveryCodes(). -
RealtimeSocketOptions.identityRecheckIntervalMs(default 30000). -
Users change their own email address.
POST /auth/change-email { newEmail }mails a link to the new address and a notice to the old one;POST /auth/confirm-email-change { token }moves the account onto the new address, verified, detaches the sign-in providers that vouched for the old one and keeps its sessions. The link lives 24 hours; an account with a second factor asks from anaal2session. The address is not reserved while the link waits, so whoever holds it when the link is followed keeps it (409 EMAIL_EXISTS). SDK:client.auth.changeEmail(newEmail)andconfirmEmailChange(token);/auth/meandUsercarrypendingEmail. NewbeforeEmailChangehook andemail.templates.emailChange/emailChangeNotice. Postgres gainsusers.pending_email,email_change_tokenandemail_change_sent_at, added at boot and planned bydb push. An X account can now replace its placeholder address, which also no longer receives the welcome mail. -
Platform tokens on the admin gate. A server with
REBASE_PLATFORM_TOKEN_KEY(one or more PEM EC P-256 public keys) andREBASE_PLATFORM_TOKEN_AUDIENCE(the project’s id) set accepts anrpt_bearer token signed by the hosting platform: ES256,iss: "rebase-cloud",audequal to the audience, asub, and at most ten minutes betweeniatandexp. The caller acts asplatform:<sub>with no roles, and holds only the scopes in the token’sscopeclaim that the server allows a platform to hold. Today that iscron:readalone, whatever the token asks for, so it reads/api/admin/cronand its logs and gets403from every other admin route. Each accepted request logsPlatform token acceptedwith thesubandjti. With neither variable set the feature is off, which is every self-hosted server. With only one set, or a key that is not a P-256 public key, the server logs a warning, boots, and refuses everyrpt_token with401 PLATFORM_TOKENS_OFF.
Client SDK
Section titled “Client SDK”-
impersonateoncreateRebaseClientruns every request as another user, named by uid. It is sent asx-rebase-impersonateon each HTTP request and asimpersonateon the realtime socket’sAUTHENTICATE. Only an administrator’s own session may use it. The data API, custom functions and realtime run as that user, and every other route refuses the request with400 IMPERSONATION_UNSUPPORTEDrather than answer as the administrator. -
client.offline.pending({ orphaned: true })andclient.offline.clear({ orphaned: true })(OfflineQueueOptions). Writes queued while nobody was signed in are never replayed as whoever signs in; these list them so the app can re-issue or discard them.
Changed
Section titled “Changed”Server & REST
Section titled “Server & REST”-
Realtime and SQL admin:
EXECUTE_SQL_SUCCESScarrieseffectiveRolewhen a statement asked for a role, andSqlScriptResulthaseffectiveRole.BranchInfohas a requireddatabasefield. -
compileRulePolicies(collection, rule, options)in@rebasepro/commonis the one compiler of a security rule into Postgres policies. The schema planner delegates to it.
Admin (CMS & app)
Section titled “Admin (CMS & app)”- The home page’s collection cards show the favourite star on hover or focus, and keep it once the collection is a favourite, instead of an empty star on every card; a touch screen always shows it. Group labels use the UI kit’s micro type and line up with the cards’ left edge.
Studio
Section titled “Studio”-
The translation key
studio_api_keys_admin_reads_every_rowis renamed tostudio_api_keys_admin_bypasses_rls. An app that overrides it must rename its override. The keysstudio_policy_help_role_authenticatedandstudio_policy_help_role_anonare removed, with the policy help that named roles no request runs as. -
POST /api/admin/schema/plan|applyand/api/admin/schema-editor/collection/saveaccept apatch(operations on key paths) for an existing collection, instead of only a whole collection object;/applyacceptssourceOnly: trueandremove: true. A wholecollectionposted for an existing collection is now applied as the patch of what differs from it, so handlers, shared properties and anything else defined in code are left alone instead of being overwritten. -
Renaming a property’s key while keeping its column is a safe, no-prompt change; changing or dropping a property’s default is applied with
ALTER COLUMN … SET/DROP DEFAULTinstead of being reported as requiring a migration. -
validation.maxon atextcolumn is no longer reported as a column change in the live schema editor’s plan. -
useLiveSchemaEditing’swriteSourceOnlyoption is removed. “Edit source only” is now a committed/applycall withsourceOnly: true(see Added/Studio and Fixed/Studio).
-
REBASE_BRANCH_PARENT_DATABASE:rebase dev, and the other commands that start a child process, export it on a switched checkout. It names the database the branch was taken from, so the server keeps the branch registry and the default source there. -
rebase schema introspectwrites more explicit property keys than before:columnType,precision/scale,defaultValue,required, the key’s real strategy,onDeleteand thesearchblock. A uuid key with no database default is now written asisId: "manual"(it wrote"uuid", which madedb pushadd a default that was never there); a serial key is written asisId: "manual"withcolumnType: "serial"/"bigserial"(it wrote"increment", an INTEGER identity). Re-introspecting overwrites only with--force, as before. -
A boot-provisioned database whose bundle listed a two-sided many-to-many’s “later” collection first gets one junction primary-key rebuild on its next
rebase db push, then none. -
rebase db pushtreats a primary key change as destructive. Changing a collection’s key, such as(id)→(id, locale)or moving it to another column, gives every row a new id, and push applied it without asking, even in CI. It now asks on a terminal, refuses elsewhere unless you pass--allow-destructive, and says what changes: URLs, foreign keys and ids stored elsewhere stop finding their row.
Client SDK
Section titled “Client SDK”-
client.cron.listJobs()returns aCronJobListing, which carries the jobs the server refused (rejected) and the files it could not load (skipped,note) besidejobs. -
Generated
database.types.tsentries carry aSlugfield (regenerate withrebase generate-sdk; older generated files still work). -
onSyncErrorreports a write discarded because an earlier write to the same row was refused, with codeDEPENDENCY_REJECTEDand the earlier refusal ascause. Before, the discarded write carried the refused write’s own error.
Storage & email
Section titled “Storage & email”-
An upload into a property’s
storagePaththat breaks the property’smaxSize/acceptedFilesis refused even when the request names no property. -
The TUS upload URL (
Location) is now a path, not an absolute URL, so an upload behind a TLS-terminating proxy is no longer sent to anhttp://address the browser blocks. -
getSignedUrl/deleteObjectgiven ans3://bucket/keyorgs://bucket/keyaddress the object by key, so a file stored withincludeBucketUrlon S3 or GCS can be previewed and deleted.
CI & tooling
Section titled “CI & tooling”-
./tooling/scripts/verify-quality.shruns every end-to-end suite CI runs, through the newpnpm ci:e2e(lanesvitest,cli,selfhost,admin). No suite stops the ones after it, and nothing is retried. Needs Docker. -
pnpm check:api-surfacepasses an additive export change and banks it intocontracts/server.api.txt. Only a removed export or a lost member still fails it. The canary release runs it with--strict, where an unbanked addition fails. -
The unit test suites run under
TZ=America/Los_Angelesin CI and inverify-quality.sh. A test that needs a specific zone pins it withtooling/scripts/jest/zone-environment.cjs.
-
change-passwordanswers with a fresh session, which the SDK adopts, so the CMS stays signed in after a password change. -
Linking a provider to a guest turns it into an account, with the registration policy and hooks.
-
A refusing auth hook answers 400
HOOK_REJECTED(or the 4xx its error carries) instead of 500; malformed JSON on an auth or admin route is 400INVALID_JSON. -
onAuthenticatedalso fires for OAuth, refresh, password reset and MFA;beforeLogin("oauth")andbeforeUserCreaterun on OAuth sign-in and sign-up;afterLogoutfires for SDK and CMS sign-outs. -
Sessions record the client address the rate limiter resolves (
TRUSTED_PROXY_HOPS) instead of a rawX-Forwarded-For. -
The seeded admin is stored verified; the MFA key falls back to
auth.jwtSecret; the welcome email’s button links to the app’s root. -
A database failure while judging a token is 503 on every door; the watermark-only check failed open.
Server & REST
Section titled “Server & REST”-
A record opens from a
viarelation’s tab. Opening a row through ajoinPathrelation (an order from a product’s Orders tab, throughorder_items) failed with “Could not load data” while the tab listed it fine. The check that the row belongs to the parent counted through a join builder that read a step’s tables only from atable.columnspelling, and its columns only by Drizzle property key: a step written asJoinStepdocuments it ({ from: "order_id", to: "id" }) named no table, and on a generated schema (orderId: uuid("order_id")) no column either. A step’s tables now come from its position in the path, its columns resolve by SQL name or property key everywhere a join path is built, and the count uses the same condition as the listing. One-to-oneviawrites had the same table lookup and refused every write; they resolve by position too. -
The OpenAPI spec says a delete on a
softDeletecollection moves the row to the trash. It documents?hard=trueon DELETE and bulk delete, and?deleted=include|onlyon list, single read, count and aggregate. A column nameddeletedis no longer offered as a filter, because the server reads that name as the soft-delete parameter. -
The SQL console’s “current database” is the database the app’s connection is on, not the one
ADMIN_CONNECTION_STRINGnames. A statement sent under the admin URL’s database name no longer runs on the app’s connection. “Default (main database)” in branching copies the app’s database. -
A live schema edit now reminds a project that keeps migrations to run
rebase db generate. The reminder only appeared when migrations were indrizzle/migrations/at the project root.rebase db generatewrites them next to the backend, inbackend/drizzle/migrations/, so in a scaffolded project the reminder never appeared. The change reached the database and the repository, but the next environment built from migrations did not get it. Both places are checked now. -
A static app nested inside another’s path serves its deep links. With one app at
/adminand another at/admin/beta, a link such as/admin/beta/settingsanswered 404 instead of/admin/beta’sindex.html. Every app declined the paths of all the others, including the app it sits inside, so both apps turned the request down. Each app now declines only the apps nested beneath it. -
Editing a row in the trash, other than restoring it, is a
404through the socket, MCP andrebase.data, as it already was over REST. An update of a missing row no longer runsbeforeSavebefore the404. -
Restoring a soft-deleted row is recorded in history as an
update. A trashed row’s history can be read, and reverting it to a version from before the delete restores it. A delete’s history entry now holds the row as REST serves it, not the admin view model, so reverting to the version before a delete works, and entries recorded earlier can still be read. -
?include=together with?fields=returns the included relations on REST and the SDK’sfind(), aslisten()already did. Naming the relation infieldsis no longer a400. -
A REST list answer always carries
meta.offset. It is0when the request named none; before,meta.offset + meta.limitwasNaNon a first page. -
Sorting a list by an aggregate over a relation (
?orderBy=count(orders):desc) works again, and pages by cursor. The list path refused every such sort with400 ORDER_BY_FIELD_NOT_SORTABLE, and nometa.nextCursorwas ever issued for one. -
storage.putObject({ file, public: true })with nokeystores a public file. Before, the upload was stored as a private one. -
The self-hosting VPS systemd unit boots. It sets
ALLOW_LOCALHOST_IN_PRODUCTION=truefor a database on the same machine, and the production refusal of a loopback address now names that variable. -
Scheduled backups run in the official image.
rebasepro/serverships the PostgreSQL 18 client tools (pg_dump,pg_dumpall,pg_restore); every scheduled backup used to fail with “Could not find the ‘pg_dump’ binary”. -
Backup listing reads every page of an S3/GCS listing, so backups past the oldest thousand objects are listed, and pruned, again.
-
Backup download from object storage is held to the backup prefix; any other
.dumpobject in the bucket is refused. -
An unknown
GETunder/apianswers the JSONNOT_FOUNDenvelope when a static app is served at/, instead of a plain-text 404. -
CORS_ORIGINS/FRONTEND_URLmatch the browser’sOriginwith a trailing slash, a path, different case or a default port. A refused origin is now logged once in production too. -
A full database pool answers
503 DB_POOL_EXHAUSTEDwithRetry-After(and a connect timeout answers503 DB_CONNECT_TIMEOUT) instead of500 INTERNAL_ERROR;/healthreports the underlying cause. -
An open Logs Explorer tab no longer holds a shutdown until the force timeout.
-
LOG_LEVEL=DEBUG, in any case, is accepted at boot, as the logger already read it. -
A static-only bundle refuses a non-numeric
PORTand honoursPORT=0, as the backend does. -
What a cron job logs reaches the server log. Lines a handler wrote with
ctx.log()were kept only with the run, inrebase.cron_logsand the in-memory history, which only the admin-gated/api/admin/cron/:id/logsreads. The process log got one line per run,✅ [cron] "<id>" completed in Nms, so nothing a job said about itself showed up inrebase cloud debug logs. A managed app’s AI step that fell back withmodel unavailable (quota_exhausted)left no trace there, and the app wrappedlogin all ten of its crons to copy each line to stdout itself; that wrapper can go once the app upgrades. Each line is now also logged atinfowith acronfield holding the job ID. That copy is redacted like any other log line: the values of keys such aspasswordorapiKey, and the SQL and bound parameters of a logged query error, are replaced. The lines stored with the run are unchanged.
Postgres
Section titled “Postgres”-
Queues and topics declared in
config/resources.tsnow run. The worker used to start with 0 tasks and everyenqueue/publishthrew “no queue runtime is installed”: the server’s inlined copy of@rebasepro/typesand the project’s own copy each kept their own registry. A third-party driver’sregisterDataSourceCapabilitieshad the same split, and is fixed too. -
afterSave,beforeDelete,afterDeleteand history now receive the row as stored, notafterRead’s view of it. A masked field is no longer recorded in history or written back by a revert, and a computed field no longer makes a version un-revertable. -
On a fresh database, the first boot grants the request role
rebase_userUSAGE on therebaseschema, and on collection schemas, as soon as provisioning creates them. Writes tohistory: truecollections, and job enqueues inside a write, no longer fail with “permission denied for schema rebase” until a restart. -
A
42501“permission denied for schema/table/function …” is now reported as a missing GRANT, not as a row-level security policy. -
A hook can catch a
context.datadelete the database refused, as it already could a create or update. Relation unlinks and many-to-many pivot writes are contained the same way. -
A hook whose
context.datawrite re-runs itself without end is stopped at depth 16 with500 CALLBACK_RECURSION, naming the hook and the collection, instead of holding the transaction until the process dies. Siblings, such as the rows of a bulk write, are not counted as nesting. -
A cron
timeoutSecondsof0orNaNis refused and listed underrejected, as documented, instead of silently running with the 300 second default. -
A collection that declares no key can take a row. It used to get an
id TEXT PRIMARY KEYwith no default, and no door fills a key that is not a property, so creating the quickstart’s first row failed in the panel, over REST and through the SDK withMissing required field: "id". The implicit key now defaults togen_random_uuid()::text; a table created before this gets the default at its next boot, and a key you send is still used. -
A
hasOnerelation no longer stops the Postgres backend from booting. A collection declaring one (a company with its billing config, the foreign key on the child) crashed at startup with “Cannot read properties of undefined (reading ‘reduce’)”. The generatedschema.generated.tsbooted, but reading the relation withincludefailed with “There are multiple relations between…” once the child had two links back to the parent, and for everyhasOneto its own collection. Both now name the join columns, and the related row is typed as possiblynull.
-
A flag written before an action is no longer read as the action. The CLI named the action as the first word after the group that was not a flag, and a flag’s value is such a word. So
rebase cloud deployments --limit 5was refused as an unknown command5,rebase cloud debug --host staging.example.comasstaging.example.com, andrebase cloud projects --name shop createasshop. The same happened at the top level:rebase db --collections ./config/collections push,rebase api-keys --name ci createandrebase skills --agent claude installwere each refused as an unknown subcommand. All of these lines now run the action they name.--helpon such arebase cloudline now prints the named action’s page. A mistyped action is still refused by name. -
rebase cloud debug logs,rebase cloud debug errors,rebase cloud debug requestsandrebase cloud debug bootsay what window their lines cover, and say when a limit cut it. The control plane returns at most 2000 lines per pod and lowered a larger--tailwithout saying so, and itstruncatedflag covers only its byte budgets. So--since 30d --tail 200000returned 35 minutes of lines marked"truncated": false. Each view now ends with the start of the window asked for and the first and last timestamps returned, per pod and overall (window,pods[].firstTimestamp/lastTimestampunder--json). A pod that returned the full line limit and whose earliest line is later than the start of the window is markedtruncated, with atruncatedReasonsaying how much of the window was not read. A pod that started recently returns fewer lines than the limit and is not marked. A--tailabove 2000 is lowered to 2000 before the request, and the output says so (tail.note). A--tailthat is not a positive whole number is refused. -
rebase cloud debug bootreads each pod’s log from its start, with no time window and 2000 lines per pod instead of the newest 500 from the last 7 days, which missed the startup lines of any pod that had served traffic since. When a pod has logged more than 2000 lines since it started, its startup lines are out of the control plane’s reach. The command then names the pod (reachedStart: false,unreachedPods) instead of reporting that nothing matched. -
rebase cloud debug --helplists--since,--tailand--previousunder the actions that take them, with each action’s own default. The help said “Default: 500” for every action, butrequestsreads 1000 lines, and it offered--previoustorequests, which rejects it. -
rebase db pushstops before a column type change that can lose values —numeric(5,2)tonumeric(4,1),biginttointeger,jsonbtotext— as documented. It never fired on the plan Atlas actually prints, so these changes applied without a prompt, even in CI. Each destructive change is also counted once instead of twice, and a plan the CLI cannot read is refused rather than called safe. -
rebase db pushno longer fails forever after you change the type of a column asearchblock reads (“cannot alter type of a column used by a generated column”): the search column is dropped and rebuilt around the change. If a push fails for another reason, the search columns it moved are put back, and a generated column of your own that blocks a retype is named with the way past it. -
rebase db push --dry-runon a synced database prints “No changes”. -
rebase schema introspectfollowed byrebase db pushno longer plans type changes, dropped defaults, dropped NOT NULLs, a phantomidkey or a dropped search column on the database it just read. Tables keyed on more than one column are skipped with the reason, and columns no property can describe are listed with what a push would do. -
rebase doctorchecks primary keys (type, columns, a missing key) and junction key columns, finds enum types whose names Postgres truncated to 63 bytes, and its type-mismatch remedy is now a statement that actually clears the finding. -
A many-to-many that both collections declare gets the same junction primary key from
rebase devandrebase db push, so a boot-provisioned database no longer has its junction key rebuilt on its first push. -
rebase init .works in a freshly cloned orgit init-ed directory. A directory may already hold.git, other repository metadata, OS litter files or aLICENSE; any other entry is refused by name, with the ways forward.--gitreuses an existing repository instead of re-initialising it. -
Saving a collection during
rebase devrestarts the backend once, after the schema and SDK are regenerated. It used to restart twice, and the first boot told you to runrebase schema generate. -
rebase startwarns at the top when it is not running as a production server (NODE_ENVfrom.envor unset), and says what that means: the first account to register becomes the admin. -
rebase buildbuilds each static app once. It used to run the frontend’svite build && tsctwice: once to fold it into the backend bundle, once for its own bundle. -
rebase build --helplists--output,--no-staticand--skip-static-build.rebase db url --helplists--database-urland--docker.rebase normalize-imports --helpprints its own help instead of the global one. -
rebase apps config <app>prints an emptyVITE_API_URLfor an app the backend bundle serves on its own origin. It no longer prints the address of arebase devthat has stopped. -
Printed next steps now work for the reader they target:
rebase init --yesends with an--agentcommand;rebase startexplains how to reach an app that answers only on its own hostname; a failedrebase upgradeinstall names the command that puts the pins back. -
The docs, the scaffold and
rebase-server --helpno longer say boot leaves junction-table RLS todb push. Boot applies it. -
rebase skills install --agent codexwrites where Codex reads. It wrote.codex/skills, which Codex never opens: Codex reads repository skills from.agents/skills. It now writes there, the directory Gemini CLI and Antigravity read too, and installs it once when both are named. -
The scaffold’s
.gitignorekeeps.vscode/mcp.json. It ignored.vscode/whole, so the MCP server registered for GitHub Copilot never reached the first commit. Other editor settings in.vscode/are still ignored. -
rebase upgradeleaves files git ignores alone. It read everypackage.jsonandpnpm-workspace.yamlunder the project, so a gitignored copy — a stale build context underscratch/, a vendored package — was listed by--dry-runand rewritten with the rest. In a git repository it now reads only whatgit ls-fileslists: tracked files, and untracked ones no.gitignoreexcludes.node_modules,dist*and hidden directories are skipped either way. Outside a repository, or run inside a copy its repository ignores as a whole, it walks every directory. -
A new project’s .env lists the REBASE_MIGRATE_ON_BOOT values the boot accepts. It offered
migrateandoff, and either one stopped the backend from starting. It now listsensureandnone, says any other value refuses to boot, and points migration files atrebase db migrate. -
The published rebase-collections skill no longer offers a cuid id. The 0.23.0 skill listed
isId: "cuid", which config load refuses, so an agent following it wrote a collection whose first boot failed. The skill now offersmanual,uuidand a SQL strategy, and sayscuidis refused.
Admin (CMS & app)
Section titled “Admin (CMS & app)”-
useBackendStorageSourceaddresses a file under a folder named “default” by its own path, instead of the root file of the same name. It now shares the SDK’s path rule, which is exported from@rebasepro/types. The rule’s bucket check also requires a slash after the bucket name now. -
Selecting a table cell no longer moves its value. An enum’s chip dropped 3px when its cell was selected, and an enum array’s chips 2.5px, because the select inside the cell kept the height of a form field, which is taller than a row’s line. A number jumped 4px and switched from the mono, tabular figures it shows at rest to the text font. A markdown paragraph moved 2px, because its source was edited on a shorter line than the one it is drawn on. All four now stay exactly where they were, at every row height. Markdown with headings or lists still shows its source while it is edited.
-
The admin panel no longer sends requests on page load that it knows will be refused. For a user without the
schema:readscope, every load calledGET /api/admin/schema/statusandGET /api/schema-editor/status, and both answered 403. On a deployment without storage, every load also calledGET /api/storage/sources, which answered 501 and was logged at ERROR. These failures filledrebase cloud debug errorsand hid the real ones. The panel now asks for the schema status only whenGET /auth/scopessays the user holdsschema:read. It asks for the storage sources only whenGET /auth/configdoes not reportstorage: false. A customAuthControllertakes part by reportingheldScopesandstorageEnabled. One that reports neither keeps asking, as before. -
The list view keeps its scroll position when you come back from a record opened full screen. Full screen replaces the collection view, and the list view, unlike the table and card views, never saved or restored its offset. Going back landed at the top with only the first page loaded. It now returns to the same rows. The card view also stops jumping back slightly the first time more rows load after a fresh visit.
-
Collection views scroll past row 1,000. The table, list and card views used to stop at row 1,000 with the API’s “limit above the maximum” error, and each scroll page re-read every row before it. Board columns said they were finished at 1,000 cards while their header counted more, and the relation picker’s “load more” failed the same way. Every view now reads one page at a time, never above the read ceiling, and realtime still updates every loaded row.
pagination: falsenow loads every row, as documented, instead of the server’s default 50. -
A scrolled collection view keeps its rows while realtime reconnects. The paged views turned a
CONNECTION_LOSTinto an error about 15 seconds into an outage, replacing every loaded row, even though the subscription survives and its first push after reconnecting carries what changed. Rows already on screen now stay there, under the connection banner, as they already did elsewhere in the admin. -
Filters on a collection with a
fixedFilterwork. Filters applied in the dialog or the header combine with the fixed filter instead of being silently dropped, and the fixed fields stay locked. -
Bulk removal on a linked (many-to-many) tab no longer says “Delete … This cannot be undone”. It now says the rows are removed from this record, which is what the server does.
-
A failing
collectionsorviewsbuilder is reported. The home page and a deep link show “Error loading navigation” with the cause and a retry button, instead of an empty page or “Collection not found”. -
A record deleted by someone else while you edit it keeps your form open. A banner explains what happened and offers “Save as new”, instead of the form being replaced by “Entity not found”.
-
An empty collection with a default
sortoffers “Create your first entry” instead of “No results with the applied filter/sort”. A read-only viewer sees “No entries found”. -
The filters dialog and the table header agree on which fields can be filtered. An array of maps, booleans or geopoints no longer shows an empty, unusable filter row.
-
Duplicating a record no longer moves the original’s
hasMany/hasOnechildren, or a one-to-oneviatarget, to the copy. The copy keeps its ownbelongsTokey and itsmanyToManylinks. -
Property
conditions(required,hidden,disabled,readOnly,min,max,defaultValue,acceptedFiles,maxFileSize) are applied by the record form, and adynamicPropsvalidation is checked on save. A required field the form keeps hidden, read-only or disabled no longer blocks the save. -
A record stored before a validation rule existed can still be edited from the panel. Untouched fields that already broke a rule are listed as a warning instead of blocking the save.
-
Emptying a text field or text cell writes NULL, not an empty string.
-
A list item or map key deleted in the side panel stays deleted after “open full screen”, a split-view toggle, or a restored draft.
-
A
columnType: "time"property reads and writes its time of day; it no longer reads as null. -
A CSV the admin exported imports its geopoints, key-value maps and arrays of maps back. A cell that is not one of those is reported before anything is written.
-
An open record form no longer saves back the old value of a nested map field someone else changed while you edited a different field of the same map.
-
A collection whose slug contains slashes can have subcollections that open. A versioned or locale-partitioned collection such as
medico/v2.0.0/jointsopened, but nothing under it did: its subcollection tabs rendered empty, because every path reader took the first segment as the collection and alternate segments as ids. The admin now finds the longest leading run of segments that names a collection, so breadcrumbs, the side panel, subcollection tabs and data routing reachmedico/v2.0.0/joints/j1/movements. A reference filter in the URL into such a collection keeps its whole path, andusers/abc123is no longer read as the collectionuser. -
A Firestore collection that declares its own
pathis read, written and referenced at that path. A collection registered asfs_diagnosiswithpath: "diagnosis"(beside a Postgres collection calleddiagnosis), or asmedico_jointsfor documents atmedico/v2.0.0/joints, was read at its slug: the list, the side panel and every subcollection under it came up empty. A stored reference todiagnosis/abcopened the Postgres collection, and a reference picked in a form stored the slug. The admin still addresses the collection by its slug, and the driver is now handed the declared path on every read and write, subcollections included. References are stored under the declared path and read back preferring the data source of the record they were read from, or the reference’s owndriver. Form actions copy and delete by the slug, so a delete can no longer reach the Postgres collection of the same name. -
A JSON file exported from a collection imports its geopoints, key-value maps and vectors back; before, they were dropped without a word. A nested key in a JSON file is mapped under the map its parent matched, such as
Home Address→home_address.street_name. -
A CSV exported from a collection imports back its vectors, its block lists written one cell per item, the dates inside arrays of maps and fixed lists, and times of day exported as timestamps.
-
In the import’s mapping step, a key-value map can be chosen as a column’s target.
-
A column mixing numbers and text is imported as text. Creating a collection from a CSV voted such a column into a number, so the zip code 02134 became 2134, 20-digit SKUs were rounded, and
N/Aor1,234in a price column became empty. Text that spells a number exactly, such as10.00, still counts as one. -
A file imported into a new collection keeps every column, whatever its headers.
First Nameandfirst_namebecame one property, and so didPrice ($)andPrice (€); a header row in Japanese, Cyrillic or Greek collapsed to a single property with an empty key. A header that slugs to nothing is nowcolumn_<n>, and a taken key gets_2,_3and so on.
Studio
Section titled “Studio”-
SQL console:
- An inline edit that changes no row says “No row was updated” instead of “Row updated successfully”, and the grid keeps the stored value. This happens when an update policy hides the row from the chosen role, or the row changed after it was read.
- The console says when a query ran as the connection owner rather than the role picked (
DISABLE_DB_ROLE_SWITCHING), and greys out the role picker. - “Open
#id” is offered only on rows read from the app’s own database. Before, a row read from a branch or another database opened, and saved, the main database’s record with that id. - An empty-string cell is shown as empty, not as NULL. Opening a cell and leaving it unchanged no longer writes NULL.
- “Format SQL” leaves string literals, quoted names and comments alone. It no longer joins a
--comment to the code after it, or splits>=,<=and!=. - “Limit 1000” now limits a SELECT that ends in a comment or uses an aggregate (
count(*) OVER ()included).
-
Branches: Create Branch’s “Source Database” copies the chosen branch’s database. Before, it sent the branch name, so it failed, or it copied an unrelated database of that name. On a
rebase devrunning on a branch, the pane lists the branches recorded in the main database, the current one included, and records new ones there. -
RLS editor:
- On a table Rebase manages, where the editor writes straight to the database, a banner says each server start (and
rebase db push) turns RLS back on and re-creates the declared policies. - Saving a policy with nothing changed says so, instead of “Policy saved successfully”. The “Unapplied” tooltip names what applies the policy.
- The policy help no longer points at the
authenticatedandanonroles, which Rebase never creates.
- On a table Rebase manages, where the editor writes straight to the database, a banner says each server start (and
-
The schema visualizer draws the table
db pushcreates (blog-postsbecomesblog_posts), with that table’s RLS marker. It says it is drawn from your collections, and shows the declared property types instead of guessed SQL types. -
Storage browser:
- Deleting a folder deletes all of it: every file past the first thousand per level, and on S3 and GCS the marker left by “New folder”. The browser reads the folder back after a delete and says what is still there.
- Listings show more than a thousand entries.
- Switching source or folder while a listing is loading no longer shows, or acts on, the previous one’s files.
- The preview’s link is minted when the file is opened, and Download and Copy URL each mint a fresh one, so they keep working past the token’s five minutes. The preview says whether a link is temporary (anyone who has it can open the file until it expires) or public and permanent.
-
Cron Jobs:
- Run Now reports how the run went; a failed run shows its error instead of a green “Job triggered”.
- The panel names cron files that failed to load and jobs the scheduler refused, with the reason.
- When the run history cannot be read, the panel says so (
GET /api/admin/cron/:id/logsanswers 503) instead of “No executions yet”. - On more than one replica, a job’s card shows every replica’s runs, not only the serving replica’s.
-
Backups:
- The panel lists and downloads
s3://andgs://destinations through a controller for that bucket, as the backup cron does, instead of through the app’s file storage, which showed “No backups yet.” for a bucket full of backups. A destination it cannot read answers 503 with the reason. - “Last scheduled backup” counts scheduled runs only; a run started by hand is shown on its own line.
- A job declared
enabled: falsereads as off, not paused. - A refused job is reported with the reason, and the history is shown as unreadable when it cannot be read.
- The panel lists and downloads
-
Logs Explorer: the Source filter files sign-ins under Auth and uploads under Storage; before, every request was filed under API. The explorer says it shows the log of the one server instance serving it, and names that instance.
-
A list that fails to load because the session ended (401), or because of a server fault, shows the error and a Retry button. It no longer says the account lacks a scope. This applies to API keys, Backups, Cron Jobs, Branches and Storage.
-
“Run as” and the JS editor:
- The picker says when it lists only the first page of users (“50 of 500 users”).
- The JS editor’s “Admin: List users” snippet returns one page and its
total, and says so. - Result rows offer “Open
#id” only when the script names a single collection outside comments and ran as you.
-
The JS editor’s “Run as” runs the script as the user you pick. It built a client with your own token and nothing naming the user, so a script run “as” someone ran as you. The picker also offered only you. It now lists your users, and a run as one of them uses
impersonate: data, functions andlisten()run as that user, and calls to other routes (client.admin, storage, auth) are refused instead of answered as you. -
The API explorer’s “Run as” runs the request as the user you pick. It sent
x-rebase-impersonate, which no server code read, so the request ran with your own session and the response showed what an administrator sees, labelled as the other user’s. The data and functions APIs now honour the header on a signed-in administrator’s session: the request runs as that user, with their uid, current roles and claims, under row-level security. Each such request is logged as a[Security Audit]event, and its request log line carriesimpersonatedBy. An API key, the service key or a non-administrator gets403 IMPERSONATION_FORBIDDEN. A uid with no active user gets404 IMPERSONATION_TARGET_NOT_FOUND. Every other route refuses the header with400 IMPERSONATION_UNSUPPORTED. None of these runs as the caller. The picker also lists your users now; before, it offered only you. -
The source-only schema editor no longer overwrites edits made on disk since the server started; it re-reads every file before each edit.
-
A schema commit refused by a git hook, identity or lock leaves the tree as it was, and names
git commitwith the hook’s output; retrying is no longer refused as a dirty tree. -
A DDL failure part-way through a schema change reports how many statements ran, instead of claiming the database was not changed.
-
Saving a collection in the editor writes only the keys you changed: handlers (
onClick, a computedvalue), properties shared from another module, imported enums, comments and formatting are kept. An edit that reaches into something defined in code is refused, naming where to make it instead. -
“Save default filter” no longer writes the runtime view of the collection into its file, which stopped the file type-checking; failures are now shown instead of silently dropped.
-
The live schema editor classifies changes from the schema each side actually produces: an integer toggle, string↔enum, an array’s element type, a relation’s target/kind/key/
onDelete, a unique toggle, and ahasManyover a column nothing creates are no longer reported as “safe, no change”. -
Re-adding a property over a column left behind says it re-uses the column, and refuses one of another type instead of silently reusing it.
-
“Edit source only” says, change by change, what it leaves in the database (including a NOT NULL column that will reject every insert), and is committed with a message naming it.
-
A security-rules edit is planned and committed as one change (“change the security rules of posts”), not reported as “no change to make”.
-
Deleting a collection removes it from
index.tstoo, is refused while another collection links to it, is committed, and keeps the table and its rows, as the confirmation now says. Previously the project stopped loading its collections. -
The SQL console refuses to edit a JOIN result whose key column name is shared, or where the edited value has no traceable source column (a computed or literal column). Editing a cell is now refused with the reason when Postgres cannot say which table and column it came from; before, a JOIN edit could silently update the wrong row, or write a computed column’s value into a real column of the same name. Identifiers in the generated
UPDATEare now quoted. -
A SQL console run that leaves a transaction open (a bare
BEGIN) is refused and rolled back, instead of letting a later run’s statements land inside it. SplittingBEGIN, anUPDATEandROLLBACKacross three separate runs used to commit theUPDATEand report three successes. Database notices such as “there is no transaction in progress” are now shown. The role picked in the console now holds for the whole run; aCOMMITinside the script used to drop it, after which later statements ran as the connection’s own role. -
A multi-statement script in the SQL console shows its last statement’s rows instead of “no results”. Values round-trip exactly — arrays,
bytea,json,numeric, timestamps — because the console now reads and writes Postgres’s own text form. -
A folder created on S3 or GCS no longer lists a nameless file. The zero-byte marker that stands for the folder was returned as an item of its own listing.
-
The storage browser is translated. Its buttons, tooltips, empty and refused states, dialogs and notifications were English in every locale; they are
studio_storage_*keys in all seven now, with plural forms for counts. The icon buttons’ labels in the collection editor and the storage preview are translated too.
Realtime
Section titled “Realtime”-
Realtime socket requests are no longer sent twice after a dropped connection. A request already in flight when the socket closed now fails with
CONNECTION_LOSTinstead of being replayed on reconnect — onesave()used to make two rows, and the Studio SQL console could run a statement twice. A request queued while disconnected fails withREQUEST_TIMEOUTafter 30 seconds instead of waiting forever. -
Live views survive a server outage longer than a minute. While anything is subscribed, the client keeps reconnecting (at most every 30 seconds, with jitter) instead of giving up after five attempts, and every subscription and joined channel is re-subscribed and refreshed once the server is back.
-
Signing in over the realtime socket on a project with two realtime-capable data sources no longer fails with
INTERNAL_ERROR, and re-scopes subscriptions on every source. -
Subscribers asking the same question as the same user now share one refetch per write. One write to a list held by 1,000 identical subscribers now costs 2 queries instead of 2,000, and a large statement under change capture no longer stalls the event loop.
-
Realtime subscription ids belong to the socket that chose them; two clients that happen to pick the same id no longer steal each other’s subscription.
-
A realtime LISTEN connection that goes half-open (an idle-flow timeout on a load balancer or NAT) is detected by a heartbeat, replaced, and its subscribers resynced.
isCdcActive()and/healthreport it, and/healthturns degraded after 60 seconds down. -
Retained channels on the
postgresbus deliver in sequence order across instances. Readers could previously see message N+1 before N, and the SDK would drop N. -
Without change capture (
REALTIME_CDC=off, or a database role that cannot create triggers), the cross-instance LISTEN connection is checked by a heartbeat. A connection that goes half-open — an idle-flow timeout on a load balancer or a NAT — is replaced within about 45 s and every subscription is refetched; a database that cannot be reached at startup is retried./healthlists the connection ascross-instancewhile it is down and fails after 60 s down. Before, writes made on other instances silently stopped arriving. -
The server pings every realtime socket every 30 s and drops one that has not answered by the next ping, and drops a socket with more than 16 MiB of unread frames queued. A dropped socket’s subscriptions, channels and presence are cleaned up as on a normal close. Before, a client that vanished without closing (lost signal, sleep) kept its subscriptions refetched on every write for about two hours. Clients that speak the protocol directly must answer pings, which every standard WebSocket library does.
Client SDK
Section titled “Client SDK”-
A file under a folder named “default” is the file the SDK reads and deletes. For a key like
default/photo.png,getSignedUrl,getObjectanddeleteObjectused to name the root file of the same name. So the storage browser previewed the wrong file, and its Delete removed the root file while the intended one stayed. A key underdefault/public/was also served as the root public file, without a token. -
With
authFlowMode: "cookie", the client no longer sends a refresh token in the body of/auth/refreshor/auth/logout. The cookie carries it. The client used to send back the""the server returns in cookie mode, and servers since 0.10.0 refused that with a 400, so an open tab could not renew an expired session. In cookie modesession.refreshTokenis"", and the client never sends it as a token. A refresh refused with400is now logged withconsole.error, including the server’s message, and is not retried, because the same request gets the same answer. It used to be retried silently six times. The session is kept, as it is when retries run out. -
A generated SDK lints clean. A collection with no relations was emitted as
Relations: {}, which typescript-eslint’s recommendedno-empty-object-typerefuses in any project that lints its generated types; it is nowRecord<never, never>, the same type forinclude. -
upsert()withoutonConflictupdates the row already at that key, instead of answering409, as documented; it now matches what it already did offline and in a function. -
A typed
client.batch()takes the collection accessor (orderItems) and sends the slug (order_items); every snake_case collection was unusable in a typed batch. Batch values are now typed per collection, so a misspelt column, or a create missing a required one, is a compile error. -
A browser on another origin can read
ETag,Retry-After,X-Request-ID,X-RateLimit-*andPreference-Applied: the runtime and the ejected backend now expose them. Before,etagOf(row)was alwaysundefinedcross-origin, andifMatchwrites silently went out unconditional. -
A grouped
aggregate()says when its groups were cut off (result.meta.hasMore; the HTTP route defaults to 50 groups) and takesoffsetto page past them. -
Generated types no longer let you write a field nobody may write; a non-required column can be written as
null, andhasMany/manyToManymemberships (with_pivot) are writable. -
A typed client refuses an unknown collection accessor and an unknown
includerelation at compile time, typesreturning: falseresults as possiblyundefined, and typescollection("snake_case_slug")andclient.collection(slug). -
An offline create refused with a 409 (a unique value already taken) is rolled back and reported to
onSyncErrorinstead of being quietly dropped from the queue as synced. -
With
offlineon,find({ where: { field: undefined } })and the bare PostgREST string form no longer throw aTypeError. -
A request answered after a sign-out/sign-in no longer writes the previous user’s rows into the new user’s offline database, and a replay acknowledged after the switch is no longer sent again when the first user comes back.
-
With
offlineon,listen()shows the user’s queued edits instead of the raw server row, andlistenById()removes a row deleted elsewhere. -
One refused field in a merged offline edit no longer rolls back the user’s other edits in that write.
-
With
offlineon, a write made while the session is still being restored on load is queued for the user the session turns out to be. In cookie mode every page load has this window, and a write made in it was queued as the signed-out user’s, where no sign-in ever replayed it; reads in that window showed the signed-out local database instead of the user’s. Offline reads and writes now wait forauth.isInitialized(). -
With
offlineon, a stored session whose refresh is refused on load no longer leaves that user’s cached rows readable, or their outbox writable, by the now signed-out client. -
The offline queue replays when the browser comes back online, also with automatic retries off. With
syncIntervalMs: 0the queue waited for the app to callsync(), although the documentation says the browser’s online event triggers a replay. It now does.
-
Searching the admin user list also matches roles and uids (
GET /api/admin/users?search=, Postgres), so the “Run as” picker’s “search by name, email, or role” works. MongoDB still matches email and name only. -
Under
cookieAuth, a tab whose access token has expired can renew it again./auth/refreshanswered{"refreshToken": ""}with400 INVALID_INPUTbefore reading the cookie, and@rebasepro/clientsends exactly that from any open tab, because""is what cookie mode returns in the token’s place. Any tab left open past the access token’s lifetime (one hour by default, so every laptop that slept) showed its login screen, and a reload signed the user back in. An emptyrefreshTokennow counts as no token on/auth/refreshand/auth/logout, so the server reads the cookie. This also fixes apps already deployed with the older client, with no rebuild: a managed runtime serves them its own copy of@rebasepro/server. -
A wrong password or MFA code is no longer answered with a token refresh and a resend. A mistyped step-up code used to spend two of the challenge’s five attempts instead of one.
-
The CMS verifies a verification link opened while signed in; it rendered a not-found page.
-
Expired auth tokens are deleted.
deleteExpiredTokenshad no caller, so every expired reset link, magic link, email code, abandoned refresh token and MFA challenge stayed in the database. The server now sweeps them once an hour, on the process that owns the timers and on one instance of the fleet: the hour is claimed inrebase.cron_claimsasrebase:auth:expired-tokens. The Postgres repository’s sweep now covers magic links, email codes, refresh tokens and MFA challenges as well as reset links. -
providerIdkeeps saying how the session was signed in after a refresh./auth/refreshand/auth/meansweredpasswordfor every session, so a Google user readgoogleat sign-in andpasswordan hour later, and a guest readanonymous, thenpassword. The method is stored with the session (refresh_tokens.method, added at boot) and carried across every rotation; sessions signed in before the upgrade readpassword.
-
Remote MCP reads return REST’s rows (ISO dates, a
belongsToas its foreign key), not the admin view model. A row that was read can be sent back inupdate_document. -
An update or delete of a row in the trash over remote MCP is refused, as REST refuses it with a 404.
-
Remote MCP no longer tells the model a failure inside the server is “usually a permission rule” when it is not.
UI kit & plugins
Section titled “UI kit & plugins”-
A dialog or side sheet opened from the keyboard now takes focus, so it is announced and a second Enter no longer fires the button behind it. Closing it returns focus to the button, or menu, that opened it.
-
A focused primary, secondary or destructive button, any switch, and a multi-select now show a visible focus ring.
-
Escape on an open dropdown or menu inside a record closes only the dropdown, not the record or the inspector.
-
A disabled date field can no longer be cleared, and its calendar button no longer throws.
-
Expandable field groups and home-page groups open and close with Enter and Space.
-
A boolean field is announced by its label, as one switch.
-
Pressing Enter in the property editor’s Name/ID fields, or the collection details form, submits what is typed, not the value from before typing.
-
Icon buttons with a tooltip are announced by the tooltip’s text.
-
AI autofill never offers or applies a suggestion for a locked field, or for a key that is not a property of the form.
rls-check
Section titled “rls-check”-
unqualified-column-in-subqueryfires on a live database, instead of being silently skipped. -
security_invoker = 1/yes/'t'is no longer reported as a bypass. -
A write policy with no clause is no longer reported as accepting every row.
-
Printed REVOKEs revoke every grant that reaches a role.
-
A table no exposed role can reach is no longer reported.
-
An UPDATE policy with only
WITH CHECK (true)is now graded high, not critical. -
A 20,000-table catalogue is scanned in about a second, down from about 34 seconds.
CI & tooling
Section titled “CI & tooling”- The CLI end-to-end suite passes again: it looks for
db generate’s output in.rebase/sqlanddrizzle/migrations, and it no longer leaves the backend it starts running after the suite exits.
Runtime
Section titled “Runtime”-
Google sign-in works on the managed runtime. The image did not ship
google-auth-library, which the server loads to verify a Google ID token. Every One Tap or rendered-button sign-in failed, and so did every authorization-code sign-in, the admin’s included, because Google returns an ID token with the code. The server logged “google-auth-library is required for Google OAuth”, and the user saw “Invalid google credentials”. Only the access-token flow worked. Adding the package to a project did not help: the server resolves its imports from the image, never from the bundle. The image now installs it. -
Live schema editing works on the managed runtime. A deployed server has no collection source, so it edits through
liveSchema.repository, and it rewrites the file withts-morph, which the image did not ship. The editor reportedSCHEMA_EDITOR_MISSING_DEPENDENCYand said to runpnpm add -D ts-morph, which a managed project cannot use to reach the server. The image now installsts-morph28.0.0, the version the server pins.
Storage & email
Section titled “Storage & email”-
GET /api/storage/sourceson a deployment without storage answers200 {"data": [], "configured": false}instead of501 STORAGE_NOT_CONFIGURED. Storage being off is a configuration choice, not a server error, so the request is no longer logged at ERROR. Uploads and every other storage route still answer 501. With storage configured, the answer carriesconfigured: true.GET /api/auth/confignow includesstorage, which says whether the backend serves file storage, so a client can know before it asks. -
A production deployment that configures no storage logs “File storage is off” once at INFO on boot, instead of an ERROR. With no
STORAGE_TYPEand no declared bucket engine, the default source falls back to local disk. Production drops local disk, and that was reported as a misconfiguration on every boot, though the project simply uses no storage. Local storage asked for withSTORAGE_TYPE=local, or passed in code, is still an ERROR in production. -
A storage path with a
%that begins no escape (/api/storage/metadata/100%-done.txt) answers400 INVALID_STORAGE_KEYinstead of 500. -
/api/storage/file/DEFAULT/public/x,public//xand.%2Fpublic/xare served anonymously likepublic/x. The route already served that object; the public check refused it. -
A re-sent resumable-upload chunk is written once. Two TUS
PATCHes at the same offset, which is how a client retries a stalled chunk, both appended, so the object held the chunk twice. APATCHthat arrives while another is being received now answers423 UPLOAD_LOCKED, which tus-js-client retries, and each chunk is written at the offset it declares. -
S3 and GCS objects keep one version across reads. Every read of an unchanged object had a new
ETagand aLast-Modifiedof now, so nothing ever answered304, every?width=request re-encoded the image, and withstorageRenditionCacheon each such request wrote another rendition into the bucket. Objects are now dated by their last write, on local disk too. -
Reading a folder as a file is a 404 on local storage, as on S3 and GCS.
GET /api/storage/file/<folder>answered 500 with the filesystem’sEISDIR, and/metadata/<folder>answered 200.
Security
Section titled “Security”Realtime
Section titled “Realtime”-
A retained channel’s messages no longer travel in the Postgres bus’s NOTIFY payload. The notification names the message (
channel,seq) and each instance reads it back fromrebase.channel_messages. Before, any database login couldLISTENand read every retained channel’s traffic. Ephemeral broadcasts and presence still travel in the payload. -
Change capture no longer broadcasts changed rows. The
rebase_cdcNOTIFY payload now carries only the changed row’s key (plus a junction’s two ids). Previously any database login couldLISTENand read every changed row of every instrumented table, including the auth table’s password hashes and verification tokens. Existing databases are updated on the next boot, including boots withREALTIME_CDC=off.
Studio
Section titled “Studio”-
Studio’s JS editor runs “No Auth” scripts anonymously. Its client restored the session the app keeps in
localStorage, so requests and the realtime socket carried the signed-in administrator’s token. The “No Auth” and “Run as” clients now start with an empty session of their own and are closed after the run. Under “Run as”,client.auth.getSession()isnull, andcontext.user(nullunder “No Auth”) says who the script runs as. -
The RLS editor lists a policy that is in both your code and the database as the database holds it. It shows the database’s TO list, command, mode, USING and WITH CHECK, and a “Differs from code” badge where they differ. Before, it showed the code’s version, so Rebase’s own policies (the admin baseline, the tenancy gate, the auth write gate) appeared with an empty USING. Saving one straight to the database in the hosted console recreated it without its condition, which removed a restrictive gate. Editing a Rebase-generated policy directly in the database is now refused.
-
The SQL console’s audit log line no longer contains the literals a statement carries.
ALTER ROLE … PASSWORD '…', user-mapping passwords and connection strings were written verbatim to the logs.
Admin (CMS & app)
Section titled “Admin (CMS & app)”- The admin’s browser bundle no longer ships server-only callback code written as a method, by shorthand, by reference, by spread, or imported from a server module. Before, the build-time strip recognised only arrow functions, so any other way of writing a server hook shipped its source, and anything that code alone imported, to the browser.
Storage & email
Section titled “Storage & email”-
SVGs are served as
image/svg+xmlunderContent-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandboxon every serving path, so an SVG opened directly can no longer run script on the API’s own origin. Before, it downloaded asapplication/octet-stream. -
Private file reads (
?token=URLs, the panel’s thumbnails) are charged to the user who minted the token, not to the reader’s IP at the anonymous allowance; the server’s ownrebase.storagecalls are never rate-limited; and a refused bearer token is no longer partly written to the error log. -
A private file whose key contains
<word>:/public/can no longer be read anonymously asGET /api/storage/file/<word>://public/…. The “is this public?” check read the URL path and stripped what it took for a scheme, while the route served the canonical key, which is private: a hook that denied every read was never asked, and/metadataminted a download token for it. The check now runs on the key the route serves.storageAuthorizehooks should testkeywith the newisPublicStorageKeyfrom@rebasepro/types;isPublicStoragePathstays for client-side paths and URLs. -
A private file under a folder named
default(keydefault/public/x) is no longer served withCache-Control: public, and new projects’config/storage.tshook no longer treats it as world-readable. -
storageAuthorizeand policy predicates receive the storage source the request is served from.?storageId=%20privatereached the hook as" private"while theprivatesource served, deleted or took the write. The default source is now alwaysundefined, including for?storageId=and?storageId=(default).
- The remote MCP endpoint fences the row data it returns in the same untrusted-data envelope the local MCP server uses, and says so in its instructions and tool descriptions.
rls-check
Section titled “rls-check”- rls-check grades
auth.jwt() IS NOT NULLandauth.role() IS NOT NULLpolicies on Supabase as critical. They were graded low and described as excluding anonymous callers, which they do not.
Server & REST
Section titled “Server & REST”Strict-Transport-Securityno longer includesincludeSubDomainsby default; setREBASE_HSTS_INCLUDE_SUBDOMAINS=trueto opt back in (see Breaking). Static apps, the CMS included, now sendContent-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'unless the response already set its own.
-
The API keys panel says what the admin role on a key does. Row-level security does not limit its reads, writes or deletes, and it passes the admin checks in custom functions and storage. The panel used to say only that such a key “reads every row”.
-
A key’s rate limit holds on the realtime socket and on /mcp. A key’s data frames and MCP requests count in the same
api-key:<id>bucket as its HTTP requests, at the key’s own limit. Before, those doors counted it at the per-user default, separately. The panel says what the limit covers, and that admin operations are not rate limited. -
A
users:writerole cannot reach past itself. Nobody edits, resets the password of, or deletes an account holding a scope they do not hold (ACCOUNT_OUTRANKS_CALLER), and nobody grants a role holding more than they do (ROLE_EXCEEDS_CALLER). Only an administrator grantsadmin. Without this, a support role could reset an administrator’s password and sign in as them. -
The registration default role may hold no admin-plane scope. Every registrant receives it, so boot refuses a
defaultRolethat isadminor a declared role holdingusers:write,schema:writeor any other admin scope. -
“Admin” is decided one way on every door. Storage, the upload handler and both realtime sockets compared against
"admin"by hand while the REST admin gate also acceptedschema-admin; they all read the same roles and scopes now. -
A deleted account’s tokens are refused at once on every door. The revocation watermark lived on the user row, so deleting the row brought back tokens the user had revoked. One check now reads existence, watermark and live roles together, and a source guard fails if anything reads the watermark alone.
-
The email-verification pre-hijack is closed. Registering someone’s address with a password and getting them to click a genuine verification link left the attacker’s password working on the account they later joined by OAuth.
-
A second MFA enrolment no longer destroys the printed recovery codes.
-
Password sign-in no longer reveals by timing whether an address has an account.
-
A new password voids outstanding reset links, and verification links expire after 24 hours.
-
The CLI no longer writes a refused password straight into the database: it falls back to the direct-database reset only on a network error.
-
rebase auth reset-passwordends the account’s sessions when it resets in the database directly. With the backend unreachable it wrote the new hash and nothing else, so an operator recovering a phished account left the attacker’s refresh token minting access tokens. It now deletes the refresh tokens and outstanding reset links and stampstokens_valid_afterin the same transaction, as the API reset does.
Postgres
Section titled “Postgres”-
A
rebase db pushthat fails part-way no longer leaves the tables it just created open to every caller. Row-level security was switched on by the policies step, last, whilerebase_useralready held read and write on a new table through the schema’s default privileges; a push stopped by a refusedsearchblock change, a vector or trigger error, or a policy that would not compile left the table readable and writable by every authenticated request — for good withREBASE_MIGRATE_ON_BOOT=none. Push now turns RLS on for every collection and junction table straight after the schema change, before any step that can fail, so a failed push leaves new tables refusing requests until their policies arrive. Tables created at boot and by the live schema editor have RLS on from the moment they are created. -
A collection table boot could not switch row-level security on for stays closed. Boot revoked the role’s access to such a table and reported it as closed, then its last step granted every authenticated request read and write on every table again, with no row filtering. The table now stays closed, and boot refuses to start if it cannot keep it closed.
[0.23.0] - 2026-09-27
Section titled “[0.23.0] - 2026-09-27”Breaking
Section titled “Breaking”Server & REST
Section titled “Server & REST”-
An API key on a nested path needs
readon every parent collection in the path. A key scoped only topoststhat calls/authors/1/postsnow gets a 403 until it can also readauthors. The route’s operation is checked against the relation’s target collection, not the relation name in the URL. See the API-key entry under Security. -
A numeric environment variable that is not a number fails the boot, naming the variable. This covers
PORT,SMTP_PORT,REBASE_MAX_BODY_SIZEand theDB_POOL_*variables. A blank value now means unset and takes the default, and an explicit0still counts. See the entry under Fixed.
-
A session lifetime the token signer cannot read refuses to boot.
auth.accessExpiresInandauth.refreshExpiresIn(JWT_ACCESS_EXPIRES_IN,JWT_REFRESH_EXPIRES_IN) take a positive number and a unit, such as15m,1h,30dor2 weeks. A bare number, zero, a negative value or any other value the signer cannot read now stops the boot and names the setting, where it used to fall back silently. See the entry under Fixed. -
A custom auth repository needs the new optional
unlinkUserIdentity, or some sign-ins are refused. When someone first proves an address by magic link, email code or password reset, and the unverified account behind it has a linked identity whose provider did not vouch for that address, the identity is removed. A repository withoutunlinkUserIdentityanswers that sign-in with 409UNVERIFIED_IDENTITIESinstead.updatePasswordmust also acceptnull, which removes the password. The Postgres store implements both; the MongoDB store has nounlinkUserIdentityyet. See the account-takeover entry under Security. -
An unverified account loses its password, its unproven linked identities and its other sessions the first time its address is proven. That proof is a magic link, an email code or a password reset, and anything put on the account before it may be someone else’s. Someone who registered with a password, never verified, and then signs in by magic link or code has to set a password again, and is signed out on their other devices. See the account-takeover entry under Security.
-
With captcha protecting
register,POST /auth/anonymous/linkneeds a solved challenge too.registeris one of the default captcha routes. An app that turns guests into accounts must now send the token, in thecaptchaTokenbody field or thecf-turnstile-response/h-captcha-responseheader, as it does for/auth/register. See the guest-upgrade entry under Security.
Storage & email
Section titled “Storage & email”- Email refuses to boot without an absolute password-reset base URL.
Verification and magic links fall back to
auth.email.resetPasswordUrl, and nothing falls back toverifyEmailUrlormagicLinkUrl. A config that set only those booted and sent dead relative links in its password-reset emails. SetFRONTEND_URLorauth.email.resetPasswordUrlto an absolute URL. See the entry under Fixed.
Jobs & cron
Section titled “Jobs & cron”- A custom job store must take a claim when it completes or fails a job.
JobStore.complete(id, claim)andfail(id, error, retryAt, claim)now receive aJobClaim({ workerId, attempt }) and must change the row only while that claim still holds it: running, locked by that worker, on that attempt. The new optionalheartbeat(id, claim)renews a running job’s claim; a store without it lets a handler that outlives the visibility timeout run a second time beside itself. The built-in store implements both. See the entry under Fixed.
Server & REST
Section titled “Server & REST”-
The cron scheduler and store expose the shared job state.
CronSchedulergainspersistJobEnabled(),fetchJobs()andfetchJob();CronStoregains optionalsaveEnabledOverride,fetchJobStates,tryAcquireRunLease,releaseRunLeaseandfetchRunSummaries— a custom store without them keeps the per-process behaviour — andCronJobPersistedState,CronRunLeaseandCronJobRunSummaryare exported. -
A grouped aggregate can be paged, and its order is stable.
/aggregateused to dropoffset,pageandorderBy, and its query had no ORDER BY, so every page was the same arbitrary set of groups. It now acceptsoffset/pageandorderBy(agroupByfield or a result key such ascount), always ends its ordering on the group keys, and returnsmeta.hasMore. Paging options on an aggregate withoutgroupBy, or a cursor, get a 400INVALID_AGGREGATE_WINDOW.
Postgres
Section titled “Postgres”SecurityRuleaccepts a rawwithCheckwithout ausing. The newRawSQLCheckOnlySecurityRulevariant is the shape of an INSERT policy, such as{ operation: "insert", withCheck: "{owner_id} = rebase.uid()" }. The policy generator already compiled this pair correctly; the type didn’t allow it.
Client SDK
Section titled “Client SDK”-
auth.mfa.challengeandauth.mfa.verifyChallengetake{ mfaToken }to finish a sign-in that answeredMFA_REQUIRED. Before, the only way to send the pending token wasclient.setToken(mfaToken), which put it on every request the app made and left it there. The token is now sent on those two requests only, and a verified challenge adopts the session and emitsSIGNED_INlike any other sign-in. -
admin.listUsers()takeslimit,offsetandsearch, and returnstotal. The route returns 25 users by default plus a total, but the method took no options and was typed as{ users }. Callers, Studio’s own snippet included, got the first 25 with nothing saying there were more.
Admin (CMS & app)
Section titled “Admin (CMS & app)”-
An account with a second factor can sign in to the admin. The server answers such an account’s password with
401 MFA_REQUIREDand a pending token, but the login view had no second step. It showed the refusal as an error, so any account with TOTP enrolled was locked out, by email, Google or OAuth alike. The login view now asks for a TOTP or a recovery code, with a factor picker when there are several. It says when a code is wrong, when a challenge is used up (the next code opens a new one), and when the five-minute sign-in has expired. The auth controller gains optionalstartMfaChallengeandverifyMfaChallenge. -
Password-reset, invitation and email-verification links open a step of their own in the admin. The links the server emails (
/reset-password?token=…,/verify-email?token=…) opened the ordinary sign-in screen, so a forgotten password couldn’t be reset and an invited user could never sign in. They now open a set-new-password step or a verification step, under any base path, and the auth controller gainsverifyEmail.
Changed
Section titled “Changed”Admin (CMS & app)
Section titled “Admin (CMS & app)”- A table cell looks the same selected as at rest, and opens its editor in one
click. Selecting a picker cell (a relation, an enum, a multi-select, a user)
swapped its value for the editor’s own boxed field, so the value moved and a
relation’s title stopped being a link. The cell now keeps showing its value.
Hovering it reveals one control at its trailing edge: a chevron for a list or
a selection dialog, a calendar for a date, an expand icon for values edited in
the popup. A click on it opens the editor straight from rest. On a selected
cell, Enter opens it and Escape closes the list before it clears the
selection, and the list opens under the cell rather than under whatever part
of the value the cell clips. A relation’s title opens the record in the side
panel on the first click. Empty date cells no longer show the
dd/mm/yyyymask, and a to-many relation in a text row lists on one line with a+N. In the taller rows (l,xl) a reference or relation is a line of the row — its image, a title that opens it, one or two supporting lines — instead of the form’s card, which was taller than the row and was cut off by it. The UI kit’sSelectandMultiSelectgainchevron,MultiSelectgainsanchorRef, andDateTimeFieldandVirtualTableDateFieldgainpickerButtonandinputRef.
Postgres
Section titled “Postgres”-
An upsert that lands on a stored row updates it instead of resetting it to create-time values. Fields the request leaves out keep their stored values rather than going back to their
defaultValue,created_byandcreated_atkeep the original creator,beforeSave/afterSavegetstatus: "existing"with the previous values, and history records an update. This covers?on_conflict=, bulk and_batchupserts, the SDK’supsert()andcreateMany(rows, { upsert: true }), and a socketSAVE { upsert: true }. An upsert on the key of a row outside the caller’sbeforeQueryscope is a 404 instead of a 500, and a natural-key upsert with nothing to update returns the stored row instead of a 403. If your code relied on an upsert resetting omitted fields to their defaults, it now has to send those fields. -
rebase db pushasks before a column type change that can lose data. The destructive gate only looked for DROP and TRUNCATE, soALTER COLUMN … TYPE dateorTYPE integerwas approved automatically, even in CI: every timestamp lost its time of day, and 19.99 became 20. Type changes are now gated like drops unless every value survives: a wider modifier on the same type,smallint→integer→bigint(or anumericwith room for their digits), orvarchar→text. Push reads each column’s current type from the database to decide, and a column it can’t find is gated.
Jobs & cron
Section titled “Jobs & cron”-
A manual trigger of a cron job that is already running answers 409
CRON_JOB_ALREADY_EXECUTING, not 200 with a skipped log. That holds whether the run is on the same process or on another one holding the job’s lease.client.cron.triggerJob()and the MCPcron_trigger_jobtool now reject; the skip is still written to the run history and is indetails.log, naming the process running the job.PUT /api/admin/cron/:idalso acceptsenabled: null, and answers 503 without changing anything when the change cannot be saved. -
A job worker claims only the tasks it has a handler for. A worker claimed jobs whose task it could not run, and each claim spent an attempt. During a rolling deploy, pods still on older code dead-lettered the new code’s topic and queue jobs with “No handler registered”, often before an updated pod got to them. Such jobs now stay pending, their attempts untouched, until an instance that runs them claims them, and a task that no instance implements stays pending rather than being dead-lettered.
JobStore.claimtakes an optional list of tasks.
-
Signing in to a control plane that only a repository’s link file names asks first, and is refused without a terminal.
rebase cloud loginin a checkout whose.rebase/cloud.jsonnames a host you have not signed in to confirms that host at a terminal, and answersunknown_control_planein JSON mode or without one. A script that signs in from such a checkout passes--urlor setsREBASE_CLOUD_URL. See the entry under Security. -
rebase cloud domainsworks on the project’s list of hosts, as the console does. It wrote the project’s single domain field, which the control plane ignores once a project has host rows, soremovereported success while the host stayed served, andadd+verifycalled a host live that was never served.listnow shows every host,addregisters one, andverify [domain]andremove <domain>act on that host; naming the domain is required when the project has more than one.domains list --jsonreturns{ projectId, tenantHost, domains: [...] }, andadd,verifyandremovereturn that host’s fields. A project whose one domain lives only in the old field, and a control plane without the domains function, still work through that field. -
rebase build --outputrefuses a build that produces more than one bundle. Every app was built into the one directory, and each bundle build empties its directory first. On a stock scaffold the admin’s static bundle deleted the backend bundle, and the build still printed “✓ Build complete.” The refusal names the apps, and--outputis now resolved once, against the directory it was typed in.
rls-check
Section titled “rls-check”-
policy-always-truesoftens aUSING (true)policy only for a RESTRICTIVE policy that gates the same roles and command. Supabase’s documented MFA gate (AS RESTRICTIVE TO authenticated) next toUSING (true) TO anoncame out medium while anon read every row, so the default--fail-on highexited 0. A restrictive gate now has to cover the permissive policy’s command (ALLneedsALL) and every exposed role it reaches. -
rls-enabled-not-forcedcounts roles that are members of the table owner, and can be high or critical. Postgres exempts them from RLS just as it exempts the owner, so a NOLOGIN owner with a LOGIN member was reported medium with “No caller bypasses policies”. It is now high when a login role inherits the owner, and critical whenanon,authenticatedor a--roleowns the table or inherits the owner.
Server & REST
Section titled “Server & REST”-
Restoring a soft-deleted row with a
PATCHworks. Setting the soft-delete field back tonull, the documented restore, answered 404 onPATCH /:id,PATCH /bulkand_batch, because the update looked the row up without the trash. A restore now finds the trashed row and honours anIf-Matchtaken with?deleted=include; any other edit of a trashed row is still a 404. -
A
_batchcreate or upsert, and a create over the realtime socket, that misses a required field is refused naming the field. It is a 400VALIDATION_CONSTRAINTbefore anything is written, where it used to fail on the database’s NOT NULL error partway through the transaction. -
A request body that is not a JSON object is a 400. A body of
nullproduced a 500, and42,true, a string or an array were saved as a row’s values. -
Nested writes honour
If-Match,Idempotency-Keyand field-operation checks. A nested PATCH or DELETE with a staleIf-Matchwrote anyway, a retried nested create inserted twice, and a mistyped field operation reached the driver instead of a 400. Nested writes now go through the same code as root writes,Prefer: return=minimalincluded, and a nested update of a missing row is a 404 naming it.?on_conflict=on a nested create is now refused, because an upsert through a parent would move the matched row under that parent. -
An
Idempotency-Keycovers the query string.DELETE /posts/5?hard=truesent with the key of an earlier soft delete got that delete’s 204 back and never purged the row. This also applies to?on_conflict=. A request with no query string hashes as before, so keys claimed before the upgrade still replay. -
A nested
/counthonours?deleted=. It counted the live rows while the trash listing beside it showed the deleted ones. -
An
If-Matchtaken from aGETis accepted on a collection with anon_updatedate. The write side hashed the admin-view row, where the date is a{ __type: "date" }object that renders as[object Object], so every conditional PATCH or DELETE with a fresh ETag got a 412. The tag in that 412 was also the same for every version of every row, so a client retrying with it overwrote concurrent changes. A GET with?fields=or?include=handed out a tag no write would match, and a conditional purge (?hard=true) of a trashed row always got a 412. Both sides now hash the same row, and nested single-row GETs send the ETag too. -
Pushed elements and merged keys follow the property’s own rules. Field operations skipped value validation:
$pushcould store a value theenumdoes not define, or push more elements thanmaxallows;$mergecould write map keys the map’s properties reject;- a fractional
$inccould land on an integer property.
These are now the same 400
VALIDATION_CONSTRAINTa plain value gets, at every write entry point. -
GET /api/admin/logs?offset=0is the first page. It used to be a 400INVALID_PARAM. -
The OpenAPI spec says a
distinctread has nometa.total. It used to saytotalcounted distinct rows, but the listing leavestotalout on distinct reads. -
A blank numeric environment variable means unset, not zero.
REBASE_MAX_BODY_SIZE=""became 0, which switches off the body limit on every/apiroute, the unauthenticated auth routes included. A blankPORTmade a production server listen on a random port, and a blankSMTP_PORTorDB_POOL_*became 0 too. A compose file withVAR=${UNSET}is the usual way to hit this. Blank now takes the default, an explicit0still counts, and a value that isn’t a number fails the boot naming the variable.configureMiddlewaresalso refuses amaxBodySizethat is not a finite number. -
Backup downloads stream.
GET /api/admin/backups/downloadread the whole dump into memory, twice for a local destination. A 1 GB dump cost about 2 GB of heap in the API process, and a dump over 2 GiB answered 500 (ERR_FS_FILE_TOO_LARGE). Local backups now stream from disk with aContent-Length, and object-storage backups no longer make a second in-memory copy. -
The scheduled RLS audit refuses a bad
intervalMsand never runs two scans at once. AnintervalMsof 0, a negative number orNaN, or anything over about 24.8 days (the longest delay a Node timer can hold), made the timer fire every millisecond. Each tick started a scan with its own database connection, so a monthly audit or a typo meant about a thousand overlapping scans a second. An invalid interval now leaves the audit off with the reason in its status, and long intervals are honoured. A tick that lands during a running scan is skipped, andrunNowjoins the scan in progress. -
backend.shutdown()stops the metrics sampler. The stop returned bymetricsHistory.start()was thrown away. After shutdown the one-minute sampler kept writing to the closed pool, and the event-loop histogram stayed enabled. Anything that boots and shuts a backend down in-process gained one more of each per boot.
Runtime
Section titled “Runtime”-
A shutdown with a realtime client connected finishes and closes the database pool. An open WebSocket held the HTTP server’s close until the force timer, and the process exited 1 without running its cleanup. Realtime clients are now sent a close (1001) and dropped a second later if they do not answer, and a drain that has to be forced still runs the cleanup and exits 0.
-
A dependency install killed partway is redone on the next start. An install cut short by an OOM kill or a probe restart was taken for a finished one, so functions importing a missing package failed behind a healthy probe for the life of the pod, and a failed re-fetch could fall back to the half-installed bundle. The install now leaves a marker until npm returns; a tree carrying it is reinstalled, and never served as a fallback.
-
A bundle download that stalls partway through its body is abandoned and retried. The timeout was cleared once the response headers arrived, so a body that stopped arriving hung the boot until the startup probe restarted the pod. It is now a deadline on progress: a slow download that keeps receiving data still finishes.
-
REBASE_RLS_AUDIT=trueruns the scheduled RLS audit on a bundle boot. On the runtime image,rebase startandrebase devit did nothing, and/api/admin/rls-auditkept saying the audit was off. The project must declare@rebasepro/rls-check; without it the boot refuses and names the package. -
A data or storage source whose key does not uppercase letter for letter boots with the variables the CLI and the control plane set. For a key such as
straße,rebase devand the control plane writeDATABASE_URL__STRASSE, while the runtime asked forDATABASE_URL__STRA_Eand refused to boot. It now reads the written spelling first and its old one second, so no deployed variable changes name.
-
Custom token claims reach the database on backends with
config.auth. On those backends a claim added bycustomizeAccessTokennever reachedrebase.jwt()or claim-based tenancy, over HTTP or WebSocket. A collection withtenant: { from: { claim } }failed every create withTENANT_REQUIREDand read nothing. -
Changing a user’s email to one another account holds answers 409.
PUT /admin/users/:uidreturned 500, because neither engine translated the database’s duplicate-email error on update./auth/anonymous/linkcould hit the same 500 when it lost a race. -
Auth rate limits count in the store
REBASE_RATE_LIMIT_STOREnames. Every auth limiter kept its own in-memory store: login, reset, email sends, OTP and MFA. So on N replicas each limit was N times looser than configured, while the docs said the counts were shared. -
A sign-up checks whether it is the first user with two rows, not the whole users table. Every successful
/registerand first OAuth sign-up loaded every user, password hashes included, just to compare the count with one. -
Session lifetimes are read the way the token signer reads them, and an unreadable one refuses to boot. Values like
1w,2 weeks,1.5hor30 dayssilently became 400-day refresh tokens.15 minutesproduced a 15-minute token whileaccessTokenExpiresAtsaid an hour, so clients refreshed 45 minutes too late. Bare numbers, zero and negative values are now refused, and every documented example still parses. A lifetime passed asundefinedin code no longer replaces the configured one; it used to sign access tokens with no expiry at all. -
A stored password key of the wrong length is a wrong password, not a 500. Users imported with a different scrypt key length got a 500 at login and change-password.
-
Invitation and admin-reset emails link to
/reset-password, not//reset-password. A frontend URL with a trailing slash produced a double slash that a React Router/reset-passwordroute does not match. The self-service reset from the same config already linked correctly. -
A user created through
POST /users/bulk, a_batchcreate or a realtime socketSAVEis created asPOST /userscreates one. The password is accepted and hashed (or generated), the email is stored normalized, the account is verified,onCreateUserruns, and the invitation or temporary password is delivered once the write commits. These doors wrote the row directly: a user with no password, no invitation, and the email as typed, so sign-in, password reset and magic links never found it.
Postgres
Section titled “Postgres”-
A write whose callback caught a failed statement is refused, not reported as saved. A lookup or a refused job enqueue that failed inside a callback aborted the transaction even when caught, and the write answered 200 or 201 while nothing was stored. It is now a 500
TRANSACTION_ABORTED, with the failed statement in the log. A failedcontext.datawrite is still undone on its own and leaves the save intact, and a failed inverse-relation update or tenant lookup now reports its own error. -
A job, queue message or webhook enqueued from
afterSaveErrorsurvives the failed write. On a request the hook ran inside the transaction being rolled back, so what it enqueued was rolled back with it. It now runs after the rollback, with acontext.dataof its own, and a hook that throws is logged instead of replacing the save’s own error. -
Creating a row under an explicit id inserts it under that id. In-process
create(values, id), MCPcreate_documentwith anidand a socketSAVE { id, status: "new" }answered 404 for a new id and overwrote an existing row with create-time values. A duplicate id is now a 409. -
Deleting through a many-to-many path unlinks a soft-deleting target.
DELETE /posts/1/tags/5, wheretagsusessoftDelete, soft-deleted the tag for every post instead of removing post 1’s link to it. -
A save or delete made through a nested path appears in the row’s history. A write to
/owners/1/docs/10, or a socket frame with that path, was recorded under the path, soGET /docs/10/historynever showed it. A copied row’s history entry now says create, not update. -
Provisioning the RLS helper functions releases its advisory lock on the connection that took it. With other queries on the pool, or behind a transaction-mode pooler, the unlock ran on another connection and left the lock held, so the next replica’s boot waited on it until its statement timeout and failed.
-
A field operation can no longer take a value past the property’s declared bounds.
{ stock: { $inc: -1000 } }onstock: { validation: { min: 0 } }stored-995while{ stock: -5 }was a 400; a$pushpastvalidation.maxonto a non-empty array and a$pullbelowvalidation.minalso went through. The operation’s result is now a condition on the UPDATE that applies it, so a decrement that stays in range still succeeds, one that would cross the bound is the same 400VALIDATION_CONSTRAINTnaming the field and the bound, and two concurrent decrements cannot both get past the floor. Numbers getmin,max,moreThan,lessThan,positiveandnegative, arrays theirmin/maxitem counts, and it holds for in-process writes throughrebase.datatoo. MongoDB does not check an operation’s result yet. -
A
beforeQueryscope now refuses a write to a row it excludes, instead of reporting a 500. An update addressed at a row outside the hook’s scope reached the write and then failed on the post-save read-back — which is narrowed like every other read — withCould not fetch row after save.: a 500 naming an internal step, for a row the caller was never allowed to address. On the request path the surrounding transaction rolled the write back, so the data was safe and only the report was wrong; an in-process save through the base driver has no such transaction, and there the write stayed. The row is now checked before the write and answered with the same404deletehas always given. Collections that declare nobeforeQueryare unaffected: the pre-read there stays best-effort history enrichment. -
The driver’s
fetchCollectionhonourslogical,include,fields,distinct,withDeletedandsearchExplain. It passed on only ten of its props. Over the socket, MCP, or the SDK without a REST fetch service, anor(...)read returned every row whileCOUNTanswered 1, awithDeleted: "only"trash view showed live rows, andincludereturned no relation. -
A composite-key row is read by every key column. The REST single get, the socket’s single reads,
beforeDelete,previousValues, history and the uniqueness check matched only the first key column. Onmembers(project_id, user_id),p1:::bobread whicheverp1row came first, so abeforeDeleteprotecting the owner judged alice and let bob be deleted. -
A composite-key listing orders and pages on the whole key. Ties were broken on the first key column only, and the cursor carried only that column, so three members of one project, paged one at a time, came back as one. A cursor naming only part of the key is now a 400
INVALID_CURSOR. -
Cursor paging over a timestamp column returns every row exactly once. Postgres stores microseconds, but the cursor carried the served millisecond value. Six rows written in one transaction and paged
createdAt:desctwo at a time stopped after page one; ascending paging repeated a row, and atlimit=1it never ended. This hit REST?after=, socketstartAfterand the SDK’siterate(). -
A vector search reports no
nextCursor. Its pages got a cursor that seeked by id under a distance order, so page two was not the next page. A vector listing now pages by offset, andstartAfterwith a vector search is a 400VECTOR_CURSOR_UNSUPPORTED. -
A
beforeSavehook’s result is saved key by key, as returned. Hook results were deep-merged into the values, so a hook that filtered an array of objects had the dropped elements merged back in, and an order was stored with its last line duplicated. A key the hook returns now replaces the value outright; a key it leaves out is kept. -
A foreign key references the target’s primary-key column, and the linking column has that key’s type. The planner named the key by its property key, so a key like
authorId, and every table adopted withrebase schema introspect, producedREFERENCES "authors" ("authorId").db pushcould not build its schema, and at boot the constraint failed with 42703 on every start, junction keys included. Linking columns were also typed fromisIdalone: a key withcolumnType: "uuid"got TEXT columns whose foreign key could never be created, and abigintkey got INTEGER ones. The next boot creates the missing constraints. A column an older release created with the wrong type is reported as drift, with the ALTER that fixes it. -
An enum label added after the first deploy lands even when the type’s name is longer than 63 bytes. Postgres keeps the first 63 bytes of the name, and the plan compared the full name. So every boot after the first tried CREATE TYPE instead of ADD VALUE, the error was taken for a race with another instance, and writes using the new label were rejected. Two columns whose enum type names come out the same (
orders.item_statusandorders_item.status) are now refused at plan time, naming both properties; before, the second column silently got the first one’s labels. -
A unique property added to an existing collection is UNIQUE in the database, or is reported as not applied. Boot applied
validation.uniqueonly to tables it created in the same run, so a new unique field on a live managed collection accepted duplicates andonConflictupserts returned 500. The constraint is now added when the table is empty or the column has no default. Otherwise it is reported as withheld (kind: "unique"), with the statement to run once the rows are backfilled. -
A date default on a
timeordatecolumn no longer breaks the schema. The default was always written as a full ISO timestamp, so atimecolumn failed with “invalid input syntax for type time”. It is now written in the column’s own shape. -
Boot can close off a mixed-case table when it cannot enable RLS on it. The fallback REVOKE named the table without quotes, so a table such as Prisma’s
"User"becamepublic.user, the revoke failed, and boot refused to start. -
Deleting a parent row fails as a foreign-key refusal when its tenant link is optional. An optional
belongsToused astenantbecame NOT NULL but keptON DELETE SET NULL, so the delete failed with 23502, naming the child’s column. It now defaults to RESTRICT, and anonDeletethe author wrote is kept. The same applies when a required property owns the relation’s column. A tenant column owned by a property declared after its relation is now actually NOT NULL. -
Deleting a row other rows still reference answers 409 and names them. An
ON DELETE RESTRICTrefusal (23001), or aNO ACTIONone (23503), came back as500 INTERNAL_ERROR. It is now a 409 naming the referencing table and constraint, with no key values in production. -
Two many-to-many relations that would share one junction table with different key columns are refused.
posts.tagsandposts.featuredTagsboth resolved toposts_tags, andfeatured_tag_idwas never created, so the second relation failed on every read and write. Planning now names both relations and suggests a separatethroughtable. The two sides of one link still share their junction. -
Querying a many-to-many from either side through
schema.generated.tsworks. The generated junction relations used a name the far side never declares, sodb.query.tags.findMany({ with: { posts: true } })threw, and a self-referencing link failed to load at all. The runtime builds its own relations and was not affected. -
Boot no longer refuses to start over an unmanaged table with an unusual name. Boot checks which tables already hold rows, and it ran that check on every table in the collections’ schemas, including ones Rebase doesn’t manage. It refused names like
2024_archive,order-itemsorSales Data, so on an adopted database one such table stopped the server from starting. Policy setup and the live schema editor ran the same check and failed the same way. The names are now quoted the way introspection quotes them. -
rebase schema introspect --schema <name>writes the schema into each collection. Collections introspected from a non-public schema had noschema, so the runtime served (and boot created) an empty table of the same name inpublic, and the real rows were never read. A junction-shaped table outsidepublicis now generated as its own collection rather than amanyToMany, because Rebase only reads and creates junction tables inpublic. -
rebase doctoranddb pushlook for a many-to-many junction inpublic, where it is created. The doctor looked in the declaring collection’s schema, so a correctly provisioned non-public collection with a many-to-many reported its junction missing and failed CI.db pushlisted the junction under the target’s schema, which hid the realpublicjunction from Atlas, and Atlas planned to create it again. -
db migrate,db generateanddb restoreignore the flags the CLI relays.--docker,--database-url <url>and--debugended up as positional arguments.rebase db migrate --dockerfailed with Atlas’sunknown flag: --dockerafter it had already written the auth schema,db generate --debugnamed its migration--debug, anddb restore --database-url X latestlooked for a backup called--database-url. -
Boot reports a
numericcolumn whose precision differs from its property’s. The check meant to catch aprecision/scaleproperty sitting on an unboundednumericcolumn never ran, because it compared against the wrong name for the type. It now compares the actual precision and scale, readingNUMERIC(10)as(10,0)the way Postgres does. -
A scheduled local backup that fails validation is deleted. Only object-storage temp files were removed. On a local destination the corrupt dump and its sidecar stayed under a valid backup name:
db backups listshowed it, and retention counted it as one of the newest backups to keep while pruning a real one.rebase db backupalready deleted it; the scheduled job now does too. -
db push --dry-run,db generateanddb migrateno longer leave<db>_dev_diffbehind. Only a push that actually applied changes dropped Atlas’s scratch database, anddb migratecreated one for a step that never uses it. The scratch database is now created only for the Atlas steps that need it, and dropped after any db command that created it succeeds. A failed command still keeps it for inspection, and one made by hand is left alone.
Realtime
Section titled “Realtime”-
An
afterReadthat writes is refused on subscription refetches too. Refetches now read in a READ ONLY transaction, like every request read. Such a hook used to commit once per row, per subscriber, on every change. -
Subscriptions refetch after the CDC or cross-instance LISTEN connection reconnects. Changes committed while that connection was down never reached subscribers.
-
With CDC on, an app write no longer hides another writer’s change to the same row. A save that fired no trigger left its echo mark standing for five seconds, and the next change psql, a cron or another instance made to that row was dropped as the echo. The mark now lasts the 300 ms refetch debounce.
-
A realtime socket that comes back re-sends its subscriptions and re-joins its channels. Whether an open was a reconnect was judged by the backoff counter, and a fresh budget resets that to zero. After retries ran out (a laptop lid, a wifi handover, a rollout longer than about a minute), the socket that returned on
onlineor on the next subscribe looked like a first connect: live views froze and channels went quiet with nothing reported. The same happened on the next socket after a sign-out. -
Subscribes no longer reach the server twice or orphaned. A listener that left before the socket opened, such as React StrictMode’s double mount, still had its queued subscribe sent. A subscribe made during reconnect backoff was sent both from the queue and by the resubscribe pass. Either way the server kept a subscription the client never read or cancelled, and refetched it on every write.
-
Realtime reaches every subscriber of a row, whichever path it was written through. Without CDC, a post saved via
authors/1/postsnever refreshedpostsor aposts/43subscriber, and a post saved viapostsnever refreshedauthors/1/posts. With CDC on, the root-table echo of a nested write is no longer delivered twice. -
Writes made outside the server reach single-row subscribers under CDC. A key declared apart from its column, or any composite key, was not found on the captured row, so a change made through psql, another service or the SQL editor reached only list subscribers. A row too wide for
pg_notifynow keeps every primary-key column instead ofidalone. Existing databases pick this up on their next boot. -
A visitor with no account can
listen(),listenById()andobserve(). The socket treated the empty token as a refusal and failed every subscription client-side with “user not logged in”, although a server withrequireAuth: falseserves anonymous subscribers andfind()next to the samelisten()worked. On arequireAuth: trueserver, the server’sUNAUTHORIZEDnow reachesonErrorat once instead of a 30-second “Subscription timed out”. -
listen()refuses an undefined filter value and a cursor. The socket sends filters as JSON, whereundefinedbecomesnull, so["!=", uid]withuidunset subscribed to every row whose owner is NOT NULL, widening an ownership filter thatfind()refuses.afterwas silently dropped, so page one streamed to a caller who asked for page two. Both now arrive throughonError, asINVALID_FILTERandCURSOR_NOT_LIVE, andobserve()reports such a query once.
Storage & email
Section titled “Storage & email”-
A metadata read on GCS no longer signs a URL.
GET /metadata/*asked the controller for a signed URL and threw it away. On GCS without a key file (Cloud Run, GKE, GCE) signing goes through the IAMsignBlobAPI, which the runtime account may call only with Token Creator on itself, so every private object’s metadata read answered 500 and everygetSignedUrlon the client failed with it. Controllers can now describe an object with the optionalgetMetadata, which GCS implements without signing. -
A named bucket keeps serving when production drops the local default. A project that declared
bucket()next tobucket("media", { engine: "s3" })and bound only media in production got the whole-storage 501 stub, so every upload to media was refused. Storage routes now mount whenever any source is live, and a request that names no source answers 501STORAGE_NOT_CONFIGUREDand says which sources exist. -
Concurrent first emails over SMTP are all sent. The service marked itself ready before nodemailer had loaded, so a second send in that window threw “Email service not configured”, and the first burst after a boot delivered one message. A failed transporter build was also remembered for the life of the process; the next send now retries.
-
GET /storage/listanswers 400INVALID_LIST_OPTIONSfor a badmaxResultsorpageToken.maxResults=0,-1orabcreturned an empty page whose next token was the same one, so awhile (pageToken)loop never ended.pageToken=-1returned 500 on local storage, and a negativeMaxKeysreached S3. -
Email refuses to boot unless password-reset, verification and magic links all have an absolute base URL. A config with only
verifyEmailUrlbooted and sent dead relative links in every password-reset and magic-link email. -
The invitation email says its link lasts 24 hours, which is how long it lasts. It used to say 1 hour for a 24-hour token.
Jobs & cron
Section titled “Jobs & cron”-
A queue handler that runs past the visibility timeout keeps its claim. The worker renews the claim while the handler runs, so a slow job is no longer reclaimed and run a second time beside itself, and an outcome reported by an attempt that lost its claim no longer overwrites the job’s (a finished job could be sent back for another run). A custom job store needs changes; see Breaking.
-
A cron job’s catch-up looks for its missed slot as of now, not as of boot. When an earlier job’s catch-up took a long time, a later job ran a stale pre-boot slot after newer slots had already run.
-
Pausing a cron job reaches every process and survives redeploys, a manual trigger no longer runs beside a run on another process, and Studio shows real run counts on the api role. Enable/disable, the trigger’s “already executing” guard and the run counters lived in one process’s memory: with several replicas a
PUT /api/admin/cron/:idwith{"enabled": false}stopped only the replica that served it, and on a split deployment the api process never starts its scheduler, so a pause there stopped nothing. The state now lives in a new internal table,rebase.cron_job_state, created at boot (an existing database gains it on its next deploy). Every scheduler reads the override —true,false, ornullto follow the job’s ownenabled— before claiming a slot, and catch-up does too; if it cannot be read, the job runs as its code declares, with a warning. Every run takes a lease there that lapses aftertimeoutSecondsplus 30 seconds (an hour forInfinity), so a crashed holder frees the job on its own. MongoDB has no cron store, so there a pause stays per process. See the new page, Cron across instances. -
A cron catch-up no longer runs when the claims table can’t answer. The cron store answered “claimed” on every error except a unique violation, so the catch-up path could never fail closed. With
rebase.cron_claimsmissing or unreadable, or a database blip at boot, every boot of every replica re-ran the job’s last slot. Scheduled runs still run when the store can’t answer. -
A deploy no longer re-runs a monthly cron’s last slot. The boot-time claim sweep deleted every claim older than seven days, and a claim is the only record that a slot already ran. A job with a catch-up window longer than seven days (say a 31-day monthly job run on the 1st and redeployed on the 10th) had its slot re-run by the boot that deleted the claim. Each job’s latest claim is now kept regardless of age.
-
One job that never finishes no longer stalls the job queue or shutdown. The worker waited for its whole batch before claiming again, and the reaper only ran inside that poll. One hung handler idled every other slot, stopped stranded jobs being reclaimed, and held
backend.shutdown()until the process was force-killed with exit code 1. Jobs are now tracked one by one, the reaper runs on its own timer,jobQueue.stop(timeoutMs)is bounded, andshutdown()gives in-flight work two-thirds of its timeout. -
Shutdown waits for a cron run in flight.
backend.shutdown()stopped the cron timers and moved on, closing the pool under a running handler without aborting itsctx.signalor recording the run. It now waits within the shutdown budget, then aborts the run and records it as failed with the reason. A run whose handler ignores its signal also ends on a timeout abort, instead of staying “running”. -
A cron
timeoutSecondslonger than about 24.8 days, orInfinity, no longer fails every run at once. Node clamps timer delays past 2^31−1 ms to 1 ms, so such runs “timed out” a millisecond after starting. Long timeouts now hold,Infinitymeans no timeout, and 0, negative orNaNvalues are refused when the job loads and listed with the rejected jobs. -
Cron schedules no longer skip or loop across a daylight-saving change. On a host whose zone has DST, the schedule search stepped in local minutes. When clocks fell back it skipped the repeated hour, and inside that hour it could compute a slot an hour in the past and re-arm every 5 seconds. A job with a
timezoneon such a host was affected too. The search now steps in real minutes. -
A queued webhook delivery that can never succeed is marked failed. A delivery refused by the outbound guard, or answered with a redirect, returned normally, so its
rebase.jobsrow endedsucceededwith no error. It is now dead-lettered after one attempt, with the reason inlast_error.
-
An MCP tool call the server refuses tells the model why. A collection callback’s veto (
CALLBACK_REJECTED) or another declared 4xx on an MCP write reached the model as a generic failure that blamed a permission rule, so it went looking for the wrong fix. The declared message is now passed on, as REST passes it; a 5xx and any undeclared error stay masked. -
/mcphas the body limit and per-person rate limit the data API has./mcpis mounted outsidebasePath, so the server-wide body limit and data rate limiter never applied to it, and JSON-RPC batches were unlimited: one POST could run a thousand tool calls. It now appliesmaxBodySize(413) and the data API’srateLimit, counted per person the token acts for, since counting per IP would put every user of a hosted client like Claude.ai into one bucket. A batch carries at most 20 messages; a larger one is refused before any of it runs. -
An MCP refresh that asks for a scope it doesn’t hold no longer kills the grant. The token endpoint used up the refresh token before checking the scope. The client got
invalid_scopewith its only token already spent, and its retry looked like a replay and revoked the whole grant. The scope is now checked first. -
The MCP destructive-tool gate reads
.envfiles the way the CLI does, and spawned commands no longer inherit the server’s startup.env. The gate parsed.envwith its own regex and stopped at the first value, so abackend/.envwith a localhost line above a production one, or a root.envwithexport DATABASE_URL=<prod>, was cleared as local whilerebase db pushconnected to production. It now parses every candidate file with dotenv, refuses if any of them names a non-loopback database, and names that file. Separately, every child process inherited the startup project’s.envas if it were shell environment, which outranks both the branch pointer and the active project’s own.env: afterrebase_db_branch_switchthe next push still went to the main database, and afterrebase_project_switchproject B ran against project A’sDATABASE_URL. -
The MCP destructive gate no longer treats DNS names starting with
127.as loopback.127.0.0.1.db.example.comcounted as local. -
The MCP
rebase://collections/…resource can no longer read a sibling directory.../collections_private/xgot past the path check. -
rebase_auth_reset_passwordfinds an existing user reliably and reports “not found” as an error. It searched withlimit: 1on a substring match, soann@x.comwas “not found” whenjoann@x.comsorted first, or when the case differed, and that message came back as a success.
Client SDK
Section titled “Client SDK”-
A retained realtime channel that reconnects more than one replay page behind catches up fully. It fetches every page before delivering live messages, and messages the server no longer retains are reported as a
CHANNEL_HISTORY_GAPerror instead of being skipped in silence. -
With offline mode on, a lost update or delete replays under the idempotency key of its first attempt. A delete that committed but lost its answer replayed into a 404, so the row was restored locally and the delete reported as failed. A field operation whose answer was lost now rejects with
NETWORK_ERRORand the key to retry it under, whereOFFLINE_FIELD_OP_UNSUPPORTEDadvised a retry that applied it twice. -
A filter value wrapped in parentheses matches that string. A
find()value such as(deleted)or(none)was read over REST as a list, so==matched nothing and!=matched everything. -
A filter on a column named like a query parameter is applied. A
find(),count()oraggregate()condition on a column calledselect,page,hard,fields,whereor another reserved name was ignored in silence, or refused with a 400. -
Offline queries compare two digit strings as text, as Postgres does. A text column holding zip codes or SKUs matched
"01234"to"1234", and cached pages were sorted differently from the server’s. -
A tab holding a stale refresh token takes its sibling tab’s session instead of signing everyone out. In json mode all tabs share one stored session, but each refreshes with the token it holds in memory. A background tab would present an already-rotated token, get
TOKEN_ALREADY_USEDon every retry, sign out, and delete the shared session, and the tab that had refreshed was signed out on its next load. Refreshes now re-read storage and adopt a newer session for the same user, and a tab that gives up clears storage only if it still holds its own session. -
An app opened offline keeps its session. On load, a stored session with an expired access token was deleted if the refresh failed for any reason, including no network or a restarting backend, although a running app treats those errors as transient. Load now drops the session only when the refresh token itself is refused; otherwise it keeps it and retries. Scheduled refreshes also no longer sign out after five transient failures.
-
With offline mode on, a create whose response was lost is no longer inserted twice. The online attempt carried no idempotency key. After a dropped connection the write was queued under a new key, and its replay created a second row;
createManyduplicated the whole batch. Writes are now sent under a key the replay reuses, and the caller’s write options are passed through. An offline natural-keycreateManythat was never sent is now refused withOFFLINE_UPSERT_UNSUPPORTED, asupsertis. -
With offline mode on, a projected read no longer strips columns from cached rows.
find({ fields }), andlisten/observewithfieldsordistinct, stored the narrowed rows in place of the full rows, in memory and in IndexedDB, so lists showing the whole row lost their other columns because some other query asked for less. Projected answers are now cached with their query and only refresh the columns they carry on rows already held. -
An upload retried after a token refresh keeps its multipart boundary. Only the first attempt stripped the JSON Content-Type from a FormData body. The retry after a 401 sent
application/jsonand the server answered “No file provided”, so every upload made just after an access token expired failed. -
iterate()andfindAll()continue from the caller’s cursor or offset. The caller’safterwas sent next to the walk’s ownoffset=0, which the server rejects with a 400 (CURSOR_WITH_OFFSET), and.offset(40).iterate()quietly started over from the top. A caller’safternow starts a cursor-based walk from that point, and a caller’soffsetis where an offset walk starts, over HTTP and in-process. -
A
Datein a REST filter is sent as its ISO instant. It went out as the viewer’s local time string, without milliseconds and with a zone name Postgres cannot parse. Over the socket and in-process the same filter was ISO, so one query compared different instants depending on the path it took. -
backups.download()uses the client’s configuredfetchand headers. It called the globalfetchwith only anAuthorizationheader, so a client with a customfetchor default headers had every call but this one go where it was told. A refusal is now aRebaseApiErrorcarryingstatus. -
call(endpoint, payload)sendsfalse,0,""andnull. It tested the payload for truthiness, so those valid bodies were dropped, althoughfunctions.invoke()sends them.
Admin (CMS & app)
Section titled “Admin (CMS & app)”-
Editing one key of a spread map in the table keeps a sibling key edited just before it. The cell saved the map as it was when the row first rendered, reverting the other key.
-
A
clearOnDisabledproperty is no longer written asnullby every table row that renders. The table shows the stored value, and the value is cleared only when a form saves, with inline editing on or off. -
Typing a date into a table cell saves it once, when it is complete. Every keystroke was saved, so typing a year stored 1902, 1920 and 0202 on the way. The UI kit’s
DateTimeFieldalso no longer reads a typed year below 100 as 19xx. -
The clear button of a relation or reference table cell cannot be hit while it is hidden. On a touch screen a tap on the cell could land on it and empty the relation.
-
Importing data with a column named like a built-in object property works. A CSV or JSON column called
constructor,toStringorvalueOfcrashed the import’s type inference; it is now inferred like any other. -
Signing out while
defineRolesForis still resolving keeps you signed out. A slow roles lookup could finish after the sign-out and restore the old user with their roles, with no session behind it and nothing left to fix it. -
<Rebase client>without anauthControllerwaits for the session restore. A returning visitor whose refresh token had been revoked was shown as signed in, and every request failed with 401. The app now waits for the SDK’s restore and reads the session again. -
After the first admin signs out, the login view offers sign-in. The auth config was read once, so on a fresh install the bootstrap form stayed on screen until a reload.
-
The Google sign-in button works when Google’s script loads late. With a stable auth controller, the button stayed dead and said Google couldn’t be loaded.
-
Changing your password signs you out once. Sign-out and
onSignOutused to fire twice. -
The Vite plugin finds an absolute
collectionsDirand ignores test files. An absolute directory produced an admin with no collections and no error. A*.test.tsfile beside the collections was bundled and blanked the admin withdescribe is not defined. On Windows the collection transform never ran, so server callbacks shipped to the browser. -
useCollectionwith inlinefilterValuessubscribes once. Filters written inline, as the hooks guide shows, re-subscribed on every render: an endless loop against the server.useRelationSelector’sfixedFilterhad the same bug. -
Changing
<Rebase locale>no longer locks in the language. The first change of the prop was stored as the user’s own choice and overrode every later value, even after a reload. -
Plugins’
lifecycle.onAuthStateChangefires. It was never called; a sign-out unmounted and remounted the plugin instead. Separately,useRebaseContextreturned the previous render’s context. -
<Rebase onAnalyticsEvent>calls the current handler. The first handler was frozen, so a consent-gated handler kept sending after an opt-out, or never sent after an opt-in. -
The collection table reads its own URL back as the filter, sort and search it wrote. Opening a record copies the list’s query string onto the record URL, and the table parsed it back into its live filter.
false,0and""were written asnull, so “Active is false” became an IS NULL filter, and “select all matching” then acted on those rows. A two-bound range lost its upper bound, a field whose name contains_op(shop_open) was dropped on reload, and a search containing%crashed the view on reload. A default filter the user had cleared came back on the next record click, and closing a record dropped the default sort to no sort at all. -
Browser Back from an edited record asks before discarding the edit. The table rewrote the address bar with
replaceState({}, …)on every mount, and the login view did the same when it cleaned?code=out of the address after a GitHub or LinkedIn sign-in. Either one erased react-router’s bookkeeping for that history entry, so Back skipped the unsaved-changes prompt and the edits were lost without warning. -
Closing or switching a record keeps the list’s search, filters and sort. The split view’s close, Escape, tab changes, edit and save kept only the view mode, and so did the detail view’s breadcrumb and the compact list’s close; each of them emptied the search box and reset the filters. Records opened in a side panel (board, custom views) reset the list to the query it was mounted with.
-
Leaving a full-screen record for one whose id starts with its own asks first. Going from an edited
/c/products/1to/c/products/12discarded the edits without a prompt. -
Dismissing the bulk-delete dialog stops the deletes. Pressing Escape or clicking outside closed the dialog but left the delete queue running, so a select-all delete of thousands of rows kept going with nothing on screen.
-
Deleting a record from its own menu closes the record. The split view, side panel and dialog stayed open on the deleted record (“Entity not found”, or a stale editable form).
-
The collection count is read again after a delete, and after a record is created in the side panel. The toolbar count is fetched once and re-fetched only when the view is told rows changed. After deleting every matching row the toolbar still showed the old total and offered “All N” over an empty list. A record created or copied in the side panel or a dialog didn’t tell it either, so the toolbar and “select all matching” kept the old total, and without realtime the table didn’t show the new row.
-
A table or board without realtime shows the newest search, and re-reads after a delete. An older, slower search result could replace a newer one (“ch” rows under “chair”), in the table and in each board column, and board column counts raced the same way. After a delete or “add existing”, the deleted row stayed on screen.
-
A refused kanban move goes back to its column, and the board’s refresh reloads it. A drag whose save was refused (RLS,
beforeSave) left the card in the target column permanently, with the error only in the console, and the board’s refresh actions, including the error banner’s refresh button, did nothing. The card now returns and an error message appears. -
An inline edit of one key inside a map saves the whole map. Editing an
address.streetcolumn of aspreadChildrenmap sent onlystreet, and the save erasedcity. The popup editor for the same cell saved an empty value and reported success. An inline edit of an array item (tags[1]) also sends the whole array. -
The popup editor of a spread map’s child validates it. The popup for an
address.streetcolumn looked the property up as a top-level key, which doesn’t exist, so it checked nothing. A value breaking the child’smaxormatcheswas saved and left to the server to refuse. It is now checked, and the error shows on the field. -
A number-enum filter shows the value it filters by. After picking a value the select went blank, and a value of
0lost its clear button and showed a stray “0”. -
An entity action’s
isEnabledalso disables it on table and list rows. It was honoured only in the record form, so a “Publish” limited to drafts could still be clicked from a published row. -
The relation picker shows the record its value holds after an undo. Pick A, discard (back to B), then undo (A again): the chip kept showing B while the form saved A.
-
“Add existing” on a junction tab links the rows picked when Done is pressed. Every checkbox tick linked a row immediately, and unticking a row or pressing Clear removed nothing.
-
The history panel scrolls back to a record’s oldest revision. A pagination check counted the loaded pages twice: 25 revisions stopped at 20, and at 100 only 60 were reachable or revertible.
-
A date edited on an existing record is saved. The form’s diff walked into objects, and a
Datehas no keys of its own, so a date-only edit saved nothing and the form snapped back to the stored date without an error; autosave never fired for it. The same diff sent a map, key-value map or geopoint as just the sub-key that changed, and an update replaces the whole property. So editingaddress.cityerasedaddress.street, removing a key-value entry emptied the map, and moving one geopoint coordinate got a 400. A changed relation went out as a bare{ id }. Every changed top-level property is now sent whole. -
A record form no longer saves over a change made elsewhere. If someone else saved the record while you had an edit open, the form received their change but kept its own copy of every field, and your next Save wrote the stale values back, reverting their change without a word. Autosave sent the whole record every time. Fields you haven’t edited now take the new stored value. Fields you did edit keep your value and show a translated “This value has been updated elsewhere”. Autosave sends only what changed.
-
A custom-shaped array (
of: [a, b]) can be saved. Every item was checked against every position’s schema, so a mixed tuple like["x", 3]always failed and Save stayed blocked. Each item is now checked against its own position, and the error shows on that position’s field. -
trim,lowercaseanduppercasechange the value that is saved, in the form and in the table, andlengthis checked. They were applied only inside validation, so “ My-Slug “ passed the check, was sent as typed, and the server rejected it; the table’s inline cell editor and popup editor did the same.validation.lengthwas not checked in the form at all. The form and both table editors now save the transformed value, a spread map’s child included, and the cell keeps what was typed until it is left. -
The form checks
emailandurlthe way the server does. They were checked only on strings that also had avalidationblock, and then an empty string was rejected. So bad emails got through, and clearing an optional email or url blocked Save. -
Number
lessThan/moreThanmessages and the datemaxmessage name the right bound. The number messages were swapped, and the datemaxmessage printed the minimum date. -
Typing a markdown field back to its stored text restores it. Type “!” then Backspace: the editor showed “Hello” but the form kept “Hello!”, stayed dirty, and Save wrote “Hello!”.
-
A disabled relation or reference field that uses a picker dialog can’t be changed. The picker still opened, and the many-valued fields kept Edit and reordering working. A property with no name showed “EDIT UNDEFINED”; the label is now the translated
Edit {{name}}, falling back to the property key. -
clearOnDisabledgives back a 0,falseor empty string when the field is enabled again. A falsy value used to be cleared and never restored. -
A date-only property (
columnType: "date") keeps its day in every timezone, and a property’stimezoneis honoured. In UTC+1, picking the 15th stored the 14th, and a filter for the 15th searched from the evening of the 14th. In UTC-5, a stored 15th was shown as the 14th. The form, the preview, the table’s date cell and the date filter now handle date columns in UTC, and all four use a property’s documentedtimezone, which they ignored. -
An import picks its id column only when the column says it is the id. The import pre-selected the first column as the row id whenever its header merely contained “id” or “key”. A file starting with
width,videoorproviderthen upserted each row over the record whose id matched that value, and creating a collection from such a file dropped the column and merged rows that shared a value. Now only a first column namedid, or one that maps onto the collection’sisIdproperty, is picked. A newly uploaded file also no longer keeps the previous file’s id column. -
An import skips a column marked “Do not import” and honours a remap. The mapping was read back by path and with a
??fallback, so an excluded column was imported anyway, a column remapped ontopricelost to the file’s ownpricecolumn when that came later, and nested remaps were ignored. A column namedtoStringcrashed the preview. -
A CSV import reads each cell against the property it lands in. Cells were
JSON.parsed before the target was known. String columns lost their exact text (1.10became1.1, twenty-digit SKUs and ids lost digits, and the wordnullbecame null), and Excel’s and Sheets’TRUEimported as false. Blank cells overrode the chosen defaults and sent""to enums, and list items kept stray spaces. String columns now keep their text, booleans accept true/false, yes/no and 1/0 in any case, a blank cell sets nothing so the default applies, and list items are trimmed. Creating a collection from a file now infers a hand-typed[1, 2]or{"a": 1}cell as a string. -
An export writes a relation as its id and a geopoint as JSON. Every relation and geopoint cell in a CSV export read
[object Object], and the JSON export wrote the full relation object, which the import can’t read back. Relations, including to-many relations and relations inside arrays, now export as ids in both formats. Any other object in a CSV cell is written as JSON. -
An export with both kinds of additional field keeps one row per record. When
exportable.additionalFieldsandadditionalFieldswere both set, their per-row values were concatenated: two rows became four, half of them with no id. They are now merged row by row. -
A re-imported export drops the apostrophe its formula escape added.
@john,+1 555 0100and-- draftcame back with a leading'. The import now strips an apostrophe that comes before a formula trigger, andunescapeCsvFormulais exported next toescapeCsvFormula. -
BasicExportActiondownloads the format, dates and rows its dialog shows. Its handler kept the first render’s options and data, so JSON and timestamp dates were ignored, and every file was namedexport.csv.csv.
Studio
Section titled “Studio”-
The SQL console refuses to edit a JOIN result whose key column name is shared. Editing a cell where two joined tables both return, say,
idupdated the wrong row. The console now asks for an alias instead. -
The SQL console asks before a DELETE or UPDATE without its own WHERE. The word WHERE anywhere else, in another statement or a comment, skipped the confirmation. TRUNCATE, DROP and an ALTER TABLE that drops a column or constraint ask too.
-
Editing a SQL console result writes to the database and role it was read from. After switching database or role, an edit sent the old rows’ keys to the newly selected one; it is now refused until the query runs again.
-
Switching the storage browser to another source closes the preview and clears the selection. Delete could otherwise remove a same-named file from the other source.
-
Deleting a file from a row of the storage browser’s list view asks first. It deleted at once.
-
The SQL console’s Explain no longer executes the statement. It ran
EXPLAIN (FORMAT JSON, ANALYZE)on the whole tab, and ANALYZE executes the query: explainingDELETE FROM postsdeleted the rows without Run’s confirmation, and a multi-statement tab ran every statement after the first. Explain now plans without executing, uses the selection when there is one, and refuses more than one statement. -
The SQL console’s automatic LIMIT applies only to a single SELECT. The “Limit 1000” toggle, on by default, appended
LIMIT 1000to any text containing the word SELECT.INSERT INTO … SELECTandCREATE TABLE … AS SELECTquietly copied only 1000 rows, andSELECT 1; DELETE …became a syntax error. The limit is now added only when the buffer parses as one top-level SELECT without its own limit. -
The SQL console’s Run button runs the selected text. Cmd+Enter inside the editor ran the selection, but the Run button, and Cmd+Enter pressed outside the editor, ran the whole buffer, so highlighting one statement and pressing Run executed the entire script.
-
Inline cell edits in the SQL console update the table the query named, schema included. The generated UPDATE dropped the schema, so editing a row from
SELECT * FROM archive.ordersupdatedpublic.ordersand reported success, and the primary key was looked up in whichever schema had a table of that name first. The UPDATE now targets"archive"."orders", and the same check stops a table in another schema from being offered as a same-named collection’s records. -
RLS rules saved from Studio are filed under the collection’s slug. Saves were addressed by the table name, because Postgres collections have no
id. When slug and table differ (slugblog, tableposts) the save missed the collection and wrote a newpostsfile containing only the rules, which broke the next boot. -
Editing a Studio RLS policy that comes from code updates its rule, or says why it can’t. Edits matched rules by
name. Unnamed rules (<table>_<op>_<hash>), rules covering several operations, and the admin baseline policies matched nothing: the rules were saved unchanged and the editor reported success. Renaming anownerFieldoraccessrule also dropped its condition, leaving a deny-all rule. The rule is now found by the policy names it compiles to and only the changed fields are written. Multi-operation rules, generated policies and database-only policies get an explanatory refusal. -
An INSERT policy created or imported in the collection editor’s RLS tab keeps its WITH CHECK. The tab kept
withCheckonly when the policy also had ausing, and an INSERT policy never has one. So it saved as a rule with no condition, which compiles toWITH CHECK (false): creating an INSERT policy in the tab, or importing one from the database, blocked every insert on the table. -
Editing a rule in the collection editor’s RLS tab changes only what was edited. The edit dialog rebuilt the rule from its form, which has no field for
ownerField,access, a structuredcondition,operationsorpgRoles, so renaming an owner rule saved a rule that denies every row. Rules were also matched by name, so editing an unnamed rule was silently discarded and deleting one unnamed rule deleted all of them. -
The RLS screens read a collection’s live policies from its own table, in its own schema. The collection editor’s RLS tab loaded no live policies for a collection that leaves
tableto its slug, and listed a same-named table’s policies from another schema as importable. Studio’s RLS editor attributed an unmapped table named like a collection’s slug to that collection, and “Import to codebase” filed that table’s policies under the wrong collection. Both screens now usegetTableNamein the collection’s declared schema, orpublic. -
The live schema editor can plan a change to a collection that has a relation. The panel posts the collection as JSON, which drops every relation’s
targetthunk, and a relation picked in the property form names its target by slug. So/api/admin/schema/plananswered 500 for any change to such a collection, even a new column or an RLS policy, and the dialog could only offer a retry. Targets are now restored from the current relation or looked up by slug, and a target that names no collection is a 400 that says so. -
A schema change that needs a migration is shown as one, not as a 500. The Postgres planner rejects an unapplicable change, and the live editor’s routes treated that rejection as a server error. Removing a property or moving a table now comes back as a
needs-migrationplan, which the panel can offer to save to source only. A request body that isn’t JSON is a 400. -
Picking a relation target on a property writes a file that boots. The schema editor wrote a property relation’s target as the string literal
target: "authors". The panel reported success, and the next boot failed for every collection in the directory. It now writestarget: () => authorsCollectionwith its import, as it already did for the collection-levelrelationsarray. A refused save no longer ends up on disk with the next unrelated save. -
A relation property built in the property form passes the boot validator. The form wrote a top-level
relationName: "", stored nokindwhile showing “Belongs to”, and kept the old kind’sthroughorlocalKeyafter a kind switch; each of these stopped the project from booting. Clearing a key field now falls back to the default column name instead of a column named “”. -
Changing an existing collection’s table no longer orphans its rows. Editing the table name read as “no change” and was applied as
CREATE TABLEfor a new empty table, leaving every row in the old one. The change is now classifiedneeds-migration, and the table field is locked once the collection exists, like its ID. -
Renaming a property in a new collection can’t overwrite another property. The side panel applies edits as they are typed, so typing an existing property’s ID replaced that property without warning. Changes with a taken or invalid ID are now held until the ID is fixed, and the error shows right away.
-
CollectionsStudioViewasks before discarding unsaved edits. Used on its own, selecting another collection or “+” remounted the editor and dropped whatever had been typed. Studio’s routed version was already guarded by its navigation blocker. -
Try-it requests in the API explorer no longer send an empty
rebase-branchheader. Every new panel pre-filled a header that nothing on the server reads. -
“New folder” in the storage browser creates the folder in the selected storage source. It sent no
storageId, so with a second source selected the folder was created in the default backend and never appeared in the listing. -
A cron job whose execution history can’t be read says so, instead of “No executions yet”. The failure used to appear only as a snackbar that disappeared after about four seconds. Separately, the API keys “Revoked / Expired” heading was built by gluing English onto a translated verb, which produced “Revocard” in Spanish and “Widerrufend” in German; it is now a single translated string in every locale.
UI kit & plugins
Section titled “UI kit & plugins”-
A disabled boolean in the collection table can no longer be flipped with a click. The table’s switch cell dropped the
disabledflag, so one click on a boolean markedadmin.disabledorconditions.disabledtoggled it and saved it. The text and date cells had already been fixed; this was the switch they missed. -
A disabled multi-select can no longer be opened or changed.
MultiSelectuseddisabledonly for its dimmed background. An array-of-enum field marked disabled could still be opened, edited and saved from the form, and its chip remove icons and clear icon still worked. -
A disabled multiline text field can’t be typed in. Only the single-line field was natively disabled.
-
A disabled icon button no longer fires from the keyboard. A button that disabled itself on click kept focus, so Enter or Space fired it again; the autofill Send button, for one, started a second run.
-
A number cell holding 0 shows 0 while being edited. A selected table cell showed 0 as an empty box, and a disabled one could still be typed in.
-
Text fields keep the last keystrokes when you save or close right after typing.
TextFielddropped callers’onFocus/onBlur, soDebouncedTextFieldnever flushed on blur. In the collection editor, typing and then saving or closing within 150ms lost the last keystrokes. Form fields built on it also never marked themselves touched on blur, so their validation messages may now appear on blur. -
A select whose chosen option has the value
""shows that option. Radix treats""as nothing selected, so an “inherit the default” option left the select blank while it was the selected one. -
The avatar preview shows the finished photo URL. After one image failed to load, every later URL kept showing the initials. That included the live preview in user settings, which fails on the first keystroke.
-
Times typed near a daylight-saving switch in a date field with
timezoneare stored correctly. They were off by an hour; no form setstimezoneyet. -
A number text field accepts a callback
inputRef. It crashed the render; no caller passes one yet. -
A range slider with two default values shows both handles. It showed one; nothing in the repo uses
Slideryet. -
Autofill no longer fills, or sends the values of, fields the form won’t let you edit. Children of a disabled map were sent as fillable while their values were stripped, so the service saw them as empty and overwrote them. Fields with a literal
conditions.readOnly,conditions.disabledorconditions.hidden, and dates withautoValue, were fillable too, and their values were sent to the service. Autofill now uses the same rules the form uses to lock a field. -
Discarding an autofill run cancels it. A discarded run kept streaming into the next run’s review, added its own fields to it pre-ticked, and marked the review ready while the new run was still writing. Each run is now cancelled when it is discarded, applied, replaced, or its form closes.
-
rebase cloud deployrefuses, and exits non-zero, while an earlier deploy of yours is still running. It followed that deploy and reported its success as its own, so the code just uploaded was never built. It now answersdeploy_in_progress, names the running deploy and says nothing was deployed. -
rebase cloud db backup listandbackup createname each backup by its filename and size. They printedundefined … NaN MBand “(unknown)”. -
rebase cloud db connectkeeps accepting connections after the first hour. Each new connection signs in with a refreshed token rather than the one the tunnel started with, and a failed refresh closes that connection with a hint to runrebase cloud login. -
A deploy whose build outlasts the session uploads with a refreshed token. The bundle,
--sourceand rebuild-source uploads used the token read before the build and failed with a 401 once it expired. -
A bare
rebase cloud deploynames the uploaded source the platform will rebuild. After a rollback or a bundle deploy it said there was none, because it read only the newest deployment. -
A deploy or
logs -fkeeps following through a failed status read. One 502 or dropped connection while the control plane restarted exited 1 on a deploy that went on to succeed. It now retries, and saysstatus_unreadableonly if the status stays unreadable for over a minute. -
rebase cloud deploy --sourcerefuses when it cannot read the project. It went ahead and could eject a managed project; pass--ejectto go ahead anyway. -
rebase cloud deploy --jsonprints a JSON error for a managed deploy refused before the build. A brokenrebase.json, a declared-resource problem or a--bundle-dirwithout a manifest left stdout empty. -
rebase cloud rollback <id>reaches a deployment older than the newest 100. It said “not found” for a deployment the control plane would roll back to. -
rebase cloud env setsays where each deploy path reads a build-time variable. Refusing aVITE_…key, it told you to commit a.envthat a--sourcedeploy never uploads. Its help also said--forceoverwrites a variable that is already set; it only lets a build-time key through. -
A followed deploy prints its build log whole. The control plane rewrites a heartbeat line at the end of the log every 30 seconds. The CLI treated the log as append-only, so every new chunk lost about 70 characters and a failed managed deploy printed only the second half of its reason.
-
rebase cloud deployin JSON mode keeps stdout to one JSON value. Vite,tsc, the build’s warnings and the static bundle summary went to stdout ahead of the result, so every piped or CI run was unparseable. A failed static or bundle build now answers with a JSON error. -
rebase cloud deployno longer spends 35–55 seconds installing dependencies it does not upload. A deploy that builds its own bundle ran the build’snpm installinto it, then packed the bundle withoutnode_modules, so the time bought nothing. It now builds without installing. The upload still leavesnode_modulesout on purpose, even from a prebuilt--bundle-dir(with a warning when that bundle had a vendored tree): the control plane reads a bundle into memory on every pod start, and a vendored one is tens of MB instead of a few hundred kB. Pods install their dependencies when they start, as they always have. -
rebase cloud deployrefuses dependency ranges no single version satisfies, asrebase builddoes. The deploy built its own bundle without the check and shipped what the build refused. -
rebase cloud deployaccepts--no-staticand--skip-schema. Its own error remedies named them, and both were rejected as unknown options. -
rebase cloud logs --jsonprints one JSON object. It printed the human page (emoji header, raw log), so| jqalways failed. -
Telemetry
cli.deployis recorded for bundle deploys and for failed deploys. Bundle deploys, the default for every scaffold, recorded nothing, and a failed followed deploy exited before recording on either path. -
rebase auth reset-passwordresets on the project’s own database and exits 1 when it cannot. When no backend was running, the fallback never resolved the managed database: on a stock scaffold it connected to localhost:5432 as the OS user, and any failure printed a stack trace and exited 0. It now resolves the database the way thedbcommands do, and a failure exits 1. -
rebase db pullno longer prints a success tick over a partial or empty database.--cleanempties the local database beforepg_restoreruns, and a failed restore was swallowed and followed by “✓ Local database now holds a copy”. It also never checked thatpg_restorewas installed. Both tools are now checked before anything is dumped, and a killed, unstartable or erroring restore ends in a red “incomplete copy” line and exit 1, after--anonymizehas run over whatever arrived. -
rebase init --database-urlwrites a password containing$exactly as given. The URL was used as aString.replacereplacement string, sopa$$w0rdreached.envaspa$w0rdand the firstrebase devfailed to authenticate, and a$'spliced the rest of the file into the URL.rebase ejectsubstituted the package name the same way and is fixed too. -
rebase cloud env pullwrites values that read back unchanged. Values were quoted withJSON.stringify, but dotenv only unescapes\nand\r, so JSON configs, quoted strings and Windows paths came back with stray backslashes. The command now chooses single quotes, backticks or double quotes to suit the value. A value no quoting can carry is skipped and named, as secrets already are. -
rebase doctorexits 1 when a connection string can’t be parsed by PostgreSQL’s own tools. It printed the ✗ finding and exited 0, so a CI gate passed a project whose backups had never worked. -
rebase upgrademoves pnpm catalogs, and no longer claims “already on” when nothing moved.catalog:pins were reported as dist-tags, and thecatalog:/catalogs:blocks were never bumped. When every pin was skipped, the command still printed “✓ This project is already on X”. Catalog entries are now moved, a reference to a catalog entry the command cannot find is reported, and “already on” is printed only when nothing was left alone. -
rebase dev --helpno longer documents adevDatabasekey in rebase.json. The key was never read, so setting it had no effect;--dockeris the way to choose the compose database.
rls-check
Section titled “rls-check”-
rls-check --schemawith a name that does not exist is an error (exit 2). A typo or wrong case scanned zero tables, printed “No findings” and exited 0. -
rls-checkjudges views and junction tables against tables in schemas it does not scan.CREATE VIEW public.files AS SELECT * FROM storage.objectsgranted to anon produced nothing on a default scan, and a junction table pointing atauth.userswas never considered either. -
rls-checkcounts column-level grants.GRANT SELECT (id, email) ON users TO anonon a table with RLS off, and Supabase’s column-level UPDATE pattern, produced no finding. -
rls-checkreports foreign tables granted to exposed roles or to PUBLIC. Postgres cannot put RLS on a foreign table, so a grant exposes everything the remote server returns (Supabase lint 0017). These tables were read and then ignored. -
Without
sslmode,rls-checktries TLS before plaintext, as libpq does. Servers that accept both, such as RDS withforce_ssloff, were scanned in cleartext. -
The
rls-checkreport’s “Exit code” line matches the actual exit code. A degraded scan printed 0 or 1 and then exited 2.
Firebase
Section titled “Firebase”-
An update on the Realtime Database keeps the fields it does not name. The driver saved every write with
set(), which replaces the whole node, while an update sends only what it changes, both the SDK’supdate(id, { name })and the admin form. So editing one field of a record in the admin wiped the rest. A save of an existing row now usesupdate(), and anullremoves only that one field. -
A Firestore save with an empty field inside a list item is accepted. An
undefinedvalue becamedeleteField()even inside a map within an array, which Firestore rejects, so any record whose list items had an unset optional field failed to save with “deleteField() is not currently supported inside arrays”. Inside an array the key is now left out; a field of the document or of one of its maps is still deleted as before. -
An ordered Realtime Database read comes back in the order it asked for. Rows were built from
Object.entries(snapshot.val()), which lists children by key, not in query order, soorderBywas lost on every read and live list, and an offset page dropped the wrong rows. Rows are now read in query order before the offset is applied. -
A Realtime Database uniqueness check matches the value, not everything after it.
checkUniqueFieldusedstartAt(value), so any value that sorted below an existing one was reported as taken: with bob@x.io stored, alice@x.io could not be saved in a unique field. It now usesequalToand ignores only the row being edited, so a second row with the same value can no longer hide behind it. -
A Realtime Database row that is gone reaches its listener as
null, not an error.listenOnecalledonErrorfor a missing row, so a record deleted while it was open showed an error instead of disappearing, and a read the database refused never reachedonErrorat all. Now a missing row arrives asonUpdate(null)and a refused read asonError. -
A Firestore search stays live, respects the page window, and one-shot searches are applied. Four problems are fixed:
- Cancelling a search before the index answered leaked its document listeners.
- Edits to rows already in the results were never delivered, and a deleted row came back on the next update.
- The SDK’s
.search("x").find()and its count ignored the search and answered with the whole collection. They now run the configured search, and refuse by name when none is configured. - Live lists and search results now respect
offsetandlimit, and search hits are read from the collection’s own database.
-
docsToCollectionTreenests subcollections instead of overflowing the stack. Thesubcollectionsfunction it put on a parent called itself, so calling it on any parent with a child ended in “Maximum call stack size exceeded”.
MongoDB
Section titled “MongoDB”-
A
not(...)filter on MongoDB returns the rows it negates. Anotgroup was compiled as anand, so?not=(status.eq.draft)returned only the drafts. It now negates as Postgres does, with SQL’s null semantics. -
Field operations on MongoDB change the stored value instead of being stored.
PATCH {"views":{"$inc":1}}storedviews: {"$inc":1}, and$push,$pulland$mergedid the same. They now apply inside one update, as on Postgres; an operation on a create is a 400, and a misspelled operator is refused.
Security
Section titled “Security”Server & REST
Section titled “Server & REST”-
A row’s history no longer serves a withheld field in
previous_values. Onlyvalueswas stripped by the caller’s read rules, so a caller withouthrread the old salary off any update entry. Both stored snapshots are stripped now. -
A field operation on a field the caller cannot read is refused.
{ score: { $inc: -n } }on a hiddenscorewith aminanswered with the bound whenever the value was below n, so about forty requests recovered it, over REST, MCP and the socket alike. An operation on such a field is now a 400FIELD_NOT_READABLE, as a filter on it is. -
A write through a parent may not set a relation the caller may not write.
POST /bands/7/members,PATCHorDELETE /posts/1/tags/5, a_pivotedit, or the same over the realtime socket, is refused withFIELD_NOT_WRITABLEwhen the parent’s relation property, or the parent key on the child, is closed to the caller. -
The project contract and the private API docs check the caller’s admin role and session revocation live. A demoted admin, or one signed out everywhere, kept reading
/api/meta/contractand the non-public/api/docsfor the rest of the token’s life. Both now sit behind the gate every other admin surface uses, which also accepts the service key and admin API keys. -
A nested path is checked against the collection it names, whatever its spelling. The nested routes found their parent by its exact slug, but the driver also accepts the kebab-case slug and the table name. Through those spellings,
POST /blog-posts/1/commentswrote fields the caller may not write or that are excluded from the API, and filters on unreadable fields were answered. Nested paths now resolve by the same rule as the driver, and a path that names nothing, an unregistered root collection included, is a 404. -
An API key on a nested path is checked against the collection it reaches, and must be able to read the parent. The check used the relation name from the URL, so a key scoped to
notesreadinternal_notesthrough/projects/1/notes. The route’s operation is now checked against the relation’s target, and the key needsreadon every parent in the path; see Breaking. -
A history revert no longer writes fields the caller cannot write.
POST /api/data/:slug/:id/history/:historyId/revertpassed the stored snapshot straight todriver.save, skipping theaccess.writeandexcludeFromApichecks every other write runs. A caller refusedPATCH { discountPercent: 50 }could set it by reverting to a version holding 50, and server-owned columns went back to their old values. A revert that would change a field closed to the caller now answers the PATCH’sFIELD_NOT_WRITABLE/VALIDATION_EXCLUDED_FIELDS. Closed fields that are unchanged, or that the caller can’t read, are left as they are. -
An
includecan no longer filter or sort by a related field the caller may not read.?include={"staff":{"where":{"salary":[">",100000]}}}andorderBy/limitinside an include answered questions about a field that?salary=gt.…onstaffitself refuses. Each include level’swhere,logical,orderByandfieldsare now checked against the target collection’s read rules, and a forbidden field is a 400FIELD_NOT_READABLE. -
A vector search on a vector the caller may not read is refused.
?vector_search=was accepted on anexcludeFromApioraccess.read-restricted vector, and_distance/vector_thresholdlet a caller locate the hidden vector with repeated probes. It is now a 400FIELD_NOT_READABLE, likeorderByon that field. -
A query cannot filter or sort on the foreign key of a to-one relation the caller may not read.
?bandId=7(orband_id), awhere, anorderByor a socket filter on it was accepted even whenbandwas read-restricted, which let a caller find which rows point at band 7 one filter at a time. It is now refused withFIELD_NOT_READABLEby REST, the aggregate route, the socket and MCP, and history and the MongoDB read strip withhold the key too. The row side of this leak is the foreign-key entry under Postgres. -
A caller who may not write a to-one relation can no longer set it through its foreign key. A
belongsTodeclaredaccess.write: ["hr"]refusedband: { id: 7 }from everyone else but wrotebandId: 7(orband_idunderstrictWrites: false), and a relation markedexcludeFromApihad the same hole. The foreign key now follows the relation’s write rule under both spellings, answeringFIELD_NOT_WRITABLE(orVALIDATION_EXCLUDED_FIELDSfor a relation closed to everyone) at every write door: REST, the socketSAVE, MCP and history revert. A revert that would move such a relation used to answer 200 and quietly leave that one change out; it is now a 400 naming the field.
-
Writing the users collection through the data API holds to the admin user routes’ rules. Single, bulk and
_batchupdates and deletes, and the realtime socket, could delete or demote the last administrator, skippedbeforeUserDeleteandafterUserDelete, left a deleted user’s sessions alive and stored an email unnormalised. They now answerLAST_ADMINandEMAIL_EXISTSas/admin/usersdoes, andPUT /admin/users/:uidchecks a demotion before it writes the email or password. A custom auth adapter can implement the new optionalprepareUserUpdates,prepareUserDeletionsandfinalizeUserDeletions. -
An account made for someone else’s email is no longer handed to its owner with the maker’s way in still on it. Nothing verifies the address an account is made with. An attacker could sign in with Spotify (which never vouches for an email) as a victim’s address, and the victim’s later Google sign-in was auto-linked onto that account, with the attacker’s Spotify login still working. Or the attacker registered the address with a password, and the victim’s magic-link sign-in marked the account verified while the password kept working. OAuth sign-in now refuses to auto-link onto any unverified account, with the new reason
local-account-unverified-passwordlessfor one without a password. The first magic link, email code or password reset now removes the password and every linked identity whose provider didn’t verify that email, and ends all sessions, before verifying the account. Accounts created withPOST /admin/usersare now stored as verified, so invitees can still use “Sign in with Google”. A custom auth repository needs the new optionalunlinkUserIdentity; see Breaking. -
A guest session can no longer claim the first admin role, or block the developer from it.
POST /admin/bootstraprecognised guests only by the reserved uidsanonymous/anon, but/auth/anonymouscreates guests with random ids. Outside production a guest could make itself admin, and a guest created on page load locked out the developer who registered next. Bootstrap now uses the user row’sisAnonymousflag. -
Upgrading a guest to an account requires the register captcha and runs
beforeUserCreate. Calling/auth/anonymousthen/auth/anonymous/linkcreated a password account that passed neither check./anonymous/linknow requires theregisterchallenge and runs the hook on the email and password hash being set, storing what it returns as/registerdoes; guest sign-in itself stays challenge-free. See Breaking. -
The MFA challenge routes refuse a revoked session.
POST /auth/mfa/challengeand/mfa/challenge/verifyskipped the check that a session was ended by “sign out everywhere” or a password change. A stolen access token could still complete a challenge and get a new aal2 session that the revocation couldn’t reach. Both routes now answerSESSION_REVOKEDfor an access token or pending MFA token issued before the revocation. -
An MFA challenge checks at most five guesses, however many arrive at once. The attempt cap was check-then-act, so guesses sent in parallel, the shape a brute force takes, all passed it. The attempt is now claimed before the code is judged, and a claim past the cap answers
CHALLENGE_EXHAUSTED. -
Magic link, email code and guest sign-in set the refresh cookie in cookie mode. With
cookieAuthon,/magic-link/verify,/otp/verify,/anonymousand/anonymous/linkreturned the refresh token in the JSON body, readable by page scripts, and set no cookie. The next page reload then signed the user out with 401NO_SESSION. They now behave like login. -
Linking an OAuth provider checks
allowedRedirectUris.POST /auth/link/<provider>skipped the redirect allowlist the sign-in route applies, and echoed the provider’s error text, which could include the client_id. A code leaked through a stale redirect URI could be linked to an attacker’s account. Both routes now share the check and return a generic 401. -
A magic-link request no longer reveals whether an account exists, and is limited per address.
POST /auth/magic-linkwaited for the email to send only for real accounts, had no minimum response time and no per-address limit, and calledbeforeLoginonly for existing addresses. It now matches/otpand/forgot-password: it sends in the background, never answers faster than 400ms, allows 5 sends per address per 15 minutes, and calls the hook for every request. -
A refresh in flight during sign-out no longer brings the session back. A logout or device revoke that landed mid-refresh was undone: the refresh wrote a new live token into the ended session and, in cookie mode, re-set the cookie. The refresh now answers 401
SESSION_REVOKEDinstead. -
The data plane refuses a request whose roles it cannot read. When the role lookup failed, the built-in adapter fell back to the roles in the token, so during a database blip a demoted admin’s token (up to an hour old) got admin RLS on REST and on the socket. It now answers 503
ROLE_LOOKUP_FAILED, as the admin routes already did.
Postgres
Section titled “Postgres”-
An update can no longer set the soft-delete field. On a
softDeletecollection, setting it to a value in an update is a 400FIELD_NOT_WRITABLE: only a delete stamps it, so thedeletepermission andbeforeDelete/afterDeletealways apply. Setting it back tonullto restore a row is still an ordinary update, and an upsert never trashes a stored row. -
An update can no longer rewrite
created_byorcreated_at. Anon_createdate oruser_on_createproperty in an update’s body, or set by abeforeSavehook, was written. The stored creator and creation time are kept. -
The WebSocket and MCP reads no longer serve fields the caller may not read. The driver’s
fetchCollection(and text search) parsed rows without the per-field read strip, and so didfetchOnewhen it fell back from drizzle’s relational query API. A plain user who sent oneFETCH_COLLECTIONforusers, or called MCPquery_collection, received every co-member’s password hash, verification token and anyaccess.read-restricted column, whileGET /api/data/userswithheld them. Both paths now strip against the caller’s roles. -
A single get’s relations respect the target’s
beforeQueryscope and soft delete.fetchOne(socketFETCH_ONE, MCPget_document) filled relations through drizzle’swith, so a tenant-scoped user fetchingowners/1received other tenants’ docs, and trashed ones, that?include=docscorrectly hid. Join-path relations now hide soft-deleted targets as well. -
A read-restricted to-one relation no longer leaks through its foreign key. A
bandrelation withaccess.read: ["hr"]was withheld from everyone else, butbandId: 7was still served beside it on every read. The foreign key is now withheld with the relation. -
Saving a relation list no longer unlinks rows the caller could not see. Membership writes compared the new list against every linked row, whatever its tenant and even if trashed. So saving back the list a scoped user was shown set
owner_id = NULLon another tenant’s rows and on trashed ones, and removed junction links to hidden tags. hasMany, hasOne, manyToMany and join-path writes now leave those links alone.
Realtime
Section titled “Realtime”-
The realtime socket holds to the data API’s body and rate limits. A frame over
maxBodySizecloses the socket with 1009, as does a frame over 64 KiB before the socket authenticates, and data frames count in the same per-person rate-limit bucket as HTTP requests instead of only per connection. -
A socket read, count or subscription cannot filter, sort or project an included relation, or vector-search, on a field the caller cannot read. It is refused with
FIELD_NOT_READABLE, as over HTTP. A subscription stored itsincludeand applied it on every refetch without checking it. -
A socket save no longer takes the collection config from the client. Only the path, id, values and status reach the driver, and an upsert over the socket is refused on a nested path and checked against the collection’s unique keys, as over HTTP. An upsert through a parent could move another parent’s row under it.
-
A socket that signs in again re-scopes its open subscriptions and channel memberships. After a session refresh, a demotion or a switch of account they kept refetching as the old identity. Each subscription now refetches as the new one at once, one whose filter the new roles may not read ends with
FIELD_NOT_READABLE, and channel memberships are authorised again. -
A socket read may no longer filter, sort or project on a field its caller cannot read.
COUNT,FETCH_COLLECTION,CHECK_UNIQUE_FIELDandsubscribe_collectiondid not apply the check REST applies, so aCOUNTfiltered withpasswordHash like '$2b$10$A%'let any signed-in user extract a hash one character at a time. They now answerFIELD_NOT_READABLEwith the field named, using the same rule as REST, now exported from@rebasepro/serverasassertQueryFieldsReadable. -
Signing out no longer hands the next user the previous user’s live rows. A sign-out dropped the socket but kept every subscription’s cached rows marked as loaded, so the next
listen()on the same query got the signed-out user’s rows immediately and sent no subscribe at all. The cache is now cleared when the socket is dropped, and the next listener asks the server. -
Signing in as a different account rebuilds the realtime socket. Signing in over an existing session emits no
SIGNED_OUT, and the socket was re-authenticated in place. The server keeps each subscription’s identity from when it was made, so the previous account’s subscriptions kept pushing that account’s rows into the new account’s page, and a visitor who listened and then signed in stayed anonymous. The socket is now dropped and redialled as the new account, and every live subscription and channel is re-sent. -
The realtime socket no longer authenticates with the token of an account that just signed out. Its token getter refreshes a nearly-expired token first, and when that refresh failed it fell back to the session it had read before. A refresh fails exactly when the user signs out while it is in flight, so the socket signed in as the account that had just left. It now reads the current session after a failed refresh.
Storage & email
Section titled “Storage & email”-
The peer and security floors move past this month’s advisories:
hono4.13.5 (fragment-aware query parsing, boundedparseBody()nesting),nodemailer9.1.1 (a quadratic address parser, allow-list and recipient-domain bypasses,resolveContentfile access) andsharp0.35.4 (libheif). The server’s peer ranges now start at those versions, so a project on an older one gets a peer warning on install; the repository’s own lockfile resolves them all, along withjs-yaml4.3.2,svgo4.1.0 anddevalue5.9.4 in build tooling. -
The resumable upload route refuses the reserved rendition prefix, and a transform URL serves only an image.
POST /uploadrefused keys under_rebase/renditions/, but TUS checked its key separately and never asked. Anyone who could upload could work out the key a transform of a readable image reads from, plant<script>there astext/htmlthrough TUS, and the transform URL served it back inline on the API origin. On macOS and Windows_REBASE/renditions/…also got pastPOST /upload. The prefix check now ignores case and treats\as a separator, and a stored rendition is only served if its type is the image type its extension names. -
A write to S3 or GCS can only name a bucket the source serves. Reads and listings checked
bucketagainst the controller’s known buckets, but uploads,POST /folderand TUS only checked the name’s format. On an object store that meantbucket=prod-db-backupswrote into any bucket the deployment’s credentials could reach, while listing the same bucket answered 404. All three now answer 404UNKNOWN_STORAGE_SOURCE. Local storage still creates a bucket on write. -
With a single storage controller, TUS refuses a named storage source. The REST routes already answered 400 for a
storageIdother than the default. TUS asked thestorageAuthorizehook about the named source and then wrote to the default controller. -
A resumable upload stores the content type its property limits checked. When the metadata carried both
filetypeandcontentType, the check read one and the write stored the other, so a property that accepts onlyimage/*could end up holdingtext/html.
-
The MCP write tools hold the users collection to the admin user routes’ rules, as the data API now does.
delete_documentcannot delete the last administrator, runsbeforeUserDeleteandafterUserDeleteand ends the user’s sessions;update_documentcannot demote the last administrator and stores the email normalized (EMAIL_EXISTSon a taken one); andcreate_documentcreates the user through the auth adapter, so the password is hashed, the email normalized,onCreateUserruns and an invitation or a temporary password is issued. -
Text stored in a row can no longer close the untrusted-data envelope around a tool result. The envelope’s markers now carry a per-response id, and marker-shaped text inside the data is broken up, so instructions in a row cannot land outside the fence.
-
The MCP query tool refuses a filter or sort on a field the caller cannot read.
query_collectiononly checked that a filter ororderByfield existed. A user withoutaccess.readonsalarycould ask for rows with salary above a bound and narrow it down call by call, or sort by it, andexcludeFromApicolumns could be used the same way. REST already answered these withFIELD_NOT_READABLE, and the MCP tool now does too. Its “Known fields” list in errors no longer names fields the caller can’t read. -
The MCP
create_documentandupdate_documenttools apply the field write rules. They passed the model’svaluesstraight to the driver, so a user could set anaccess.writefield their roles don’t allow, or anyexcludeFromApicolumn, by asking a connected MCP client to. Both tools now run the same check as REST and the socket’sSAVEand return its message to the model as the tool error. They also applyPATCH’s field-operation type check andPOST’s refusal of field operations on a create. -
An MCP grant answers to sign-out-everywhere, password changes, role changes and account deletion. The consent page accepted a session token that “sign out everywhere” or a password reset had already revoked. Every refresh re-issued the roles recorded at consent, so a demoted or deleted admin kept admin access through
/mcpas long as the client kept refreshing, and a guest session could consent, after which its tool calls ran as a full account. Now the consent page refuses revoked sessions and guests. Code redemption and every refresh read the account’s current roles and refuse accounts that are gone, and a refresh token issued before the account’s revocation watermark is refused and its family revoked. A failed account lookup answers 503 without using up the token./api/oauth/grantsalso refuses a revoked session.
Client SDK
Section titled “Client SDK”- A token refresh that answers after sign-out no longer brings the session
back. If a refresh was in flight when the user signed out, its answer was
adopted anyway: the session came back in memory, in localStorage and on the
transport, and
TOKEN_REFRESHEDfired afterSIGNED_OUT. The same race could overwrite a sign-in made meanwhile, or sign that new user out when the old token was refused. A refresh whose session has ended now drops its result, and a refused request is not retried as a different user.
Admin (CMS & app)
Section titled “Admin (CMS & app)”-
Signing out clears every cache that holds the last user’s data. Sign-out used to clear only the fetch cache. The table’s scroll cache still held the previous user’s rows, so the next user’s table showed those rows first and kept them if its own read was refused. Relation titles and edits handed between layouts also survived, and
<Rebase client>with noauthControllercleared nothing. All of these are now cleared on sign-out, and when a different user signs in. Local drafts survive a sign-out and are dropped when someone else signs in. -
useBackendStorageSourceno longer puts the session token in file URLs, and can read private files. It wrote the session JWT into file URLs (into<img src>and access logs) and sent it as a Bearer to/storage/file/*, which the server refuses, so every private file got a 401. It now uses the short-lived download token, encodes keys, and lets cached URLs expire.
Studio
Section titled “Studio”-
SQL run in the Studio editor runs on a connection of its own, reset afterwards. A
SET ROLE rebase_userleft that pooled connection running as that role, so later auth lookups, job and cron stores and history writes that checked it out ran restricted until a restart. Session settings leaked the same way. -
Importing a live RLS policy into the codebase no longer turns a restrictive policy into one everyone passes. Both “Import to codebase” buttons (Studio’s RLS editor and the collection editor’s RLS tab) copied a policy’s
TOlist, which holds database roles, intoSecurityRule.roles, which holds application roles. A restrictivetenant_isolation … TO public USING (tenant_id = …)came back asNOT (roles && ARRAY['public']) OR tenant_id = …, so every user passed the tenant gate, and a permissive import matched nobody. TheTOlist is now written topgRoles, and left out when it ispublic. -
Importing an existing table in the collection editor writes security rules that compile back to the table’s own policies. The policy metadata was read from
pg_policy, so the command arrived as a one-letter code the importer didn’t recognise, and every rule compiled to FOR ALL: aSELECT … USING (true)became a write grant.TO publicarrived as-, the TO list was filed as applicationroles, and restrictive policies came back permissive, so a restrictive tenant gate became a permissiverebase.roles()check. An INSERT policy lost its WITH CHECK and blocked every insert. The metadata now comes frompg_policies, a command the importer cannot name is refused instead of widened to FOR ALL, and “Import from table”, Studio’s RLS editor and the RLS tab all build the rule the same way. -
Editing a policy directly in the database replaces it under its old name, atomically. In the hosted console, and for unmapped tables locally, an edit dropped the policy under its new name and then created it, as two separate commits. Renaming
public_read USING (true)left the world-readable policy in place next to the new one, and a typo in the new USING clause deleted the policy outright. The old name is dropped now, and the drop and create run as one statement, so a failed create keeps the original.
UI kit & plugins
Section titled “UI kit & plugins”- Insights never show one user the figures cached for the previous one. The
insights cache survives sign-out, and its key didn’t include the user. Anyone
signing in on the same tab within
cacheTTL(60 seconds by default) saw the previous account’s numbers, computed under that account’s permissions and row-level security.
-
rebase cloud loginno longer sends your password to a control plane named only by a repository’s.rebase/cloud.json. A cloned repository could point the login at any host. A host you have not signed in to before is now named and confirmed at a terminal, and refused without one, or in JSON mode, unless you pass--url. The default host and hosts you are already signed in to go ahead as before. -
A deploy’s source upload respects
.gitignoreoutside a git repository, and never carries database dumps,.rebase/or dev secrets. A project with no repo of its own (whatrebase init --yesmakes) was walked with fixed excludes only, sobackups/*.dump(password hashes included),uploads/and.rebase-dev-secrets.jsonwere uploaded as the project’s source. Git now reads the project’s ignore files, and*.dump,.rebase/and.rebase-dev-*are never uploaded, whatever git tracks. Without git, a tree holding a.gitignoreis not uploaded, and the deploy goes on without its source. A project inside a repository that ignores it uploads the project, not that repository. -
rebase cloud deploy --source <dir>never uploads env files, dumps or anything.gitignorenames. It usedtar .with only the root.gitignoreas tar globs, so.env.productionshipped, and from a monorepo subfolder.envitself did. It now uses git’s reading of every.gitignoreplus the same never-uploaded filter..rebaseignoreis still honoured. -
A failed command’s usage report no longer carries project names or typos.
cli.errorsent the command and subcommand exactly as typed, sorebase init acme-payroll-internal --headlessentsubcommand: "acme-payroll-internal"to the collector, although the consent screen promises project names are never sent. Both words are now limited to commands and subcommands the CLI dispatches,"none"or"other". This only affected users who opted in to usage sharing.
rls-check
Section titled “rls-check”- An
rls-checkcopy-paste fix can no longer be turned into a different statement by a hostile table, role or routine name. A table namedx"; DROP TABLE users; --produced a CREATE POLICY line that ran the DROP when pasted. Names with a line break escaped--comments, some table names went into SQL unquoted, and a routine named with$$ended theDO $$block. Names with control characters are now written asU&"…"identifiers, and every identifier is quoted.
MongoDB
Section titled “MongoDB”-
A MongoDB create that names a taken id answers 409 instead of overwriting the row. A save with
status: "new"and anidwas checked as an insert, then written as an upsert, so over the socket a user could name another user’s row id and take the row over. A create now inserts, an update touches only its row and answers 404 when it is missing, and a duplicate on any unique index is a 409 naming the fields. -
Per-field
accessandexcludeFromApiapply to every row the MongoDB driver serves. REST reads, socket fetches and subscription pushes returned every field, so an anonymous caller received fields likesalaryandapiToken. These fields are now stripped, left out of text search, and refused as a filter or sort key, and the socket’sSAVEchecksaccess.writewith the session’s roles instead of as the trusted server. -
A MongoDB uniqueness check compares one value on one declared field.
CHECK_UNIQUE_FIELDpassed the client’s field name and value straight into a query, so a value such as{ "$regex": "^123" }read hidden values a prefix at a time, and a name of$exprran an aggregation expression. Operators are now refused, the value must be a scalar compared with$eq, and the field must be declared and readable by the caller. -
A guest on MongoDB is a guest. The MongoDB user store never saved or read
isAnonymousormetadata, soPOST /auth/anonymouscreated a full account thatpolicy.registered()let through, and/auth/anonymous/linkansweredNOT_ANONYMOUSto every guest. -
Deleting a user ends their sessions on MongoDB. A deleted or banned user’s refresh token kept producing access tokens for up to 400 days, because only Postgres removed the tokens with the user. The delete endpoints now clear the sessions themselves, and the MongoDB store removes the user’s tokens.
[0.22.0] - 2026-09-21
Section titled “[0.22.0] - 2026-09-21”-
Linking an account returns the provider’s profile picture.
verifyalready fetches a photo from every provider that has one, and both sign-in branches pass it toupdateUser— butPOST /auth/link/:providerasked for the same profile, got the same answer and dropped it. An app whose users link an account after signing up therefore had no route to a picture at all, which is the normal case wherever the account came first. Both branches now answer withphotoURL, the idempotent already-linked one included, so a re-link is also a way to refresh it. Returned rather than stored on purpose: these URLs are signed and expire (LinkedIn’s within weeks), so a column holding one is a column that turns into a broken image later — copy the bytes somewhere you own while the URL is still good. -
beforeQuerynarrows which rows a read asks for.afterReadsees rows after they are fetched, so a tenant scope or a per-role row filter could only be an RLS policy or a condition pushed into every caller. A collection’scallbacks.beforeQuery, or a global one oninitializeRebaseBackend, returns afilteror alogicalgroup that is AND-ed into the read before it is compiled. It can only narrow: nothing it returns can drop or replace the caller’s own conditions. It applies to the listing, the single get, the count, the aggregate, search, vector reads, nested paths, the realtime refetch behind.listen(), and the rows loaded for a relation or an?include=, where the target collection’s hook is the one that applies. A hook filter that names a column the table does not have refuses the request with400 UNKNOWN_FILTER_FIELD, whateverconfigureUnknownFilterFieldsis set to. Postgres only: a MongoDB or Firestore collection that declares one fails at boot rather than serving every row. -
search.modecan be"hybrid", which finds substrings and folds accents in one collection. Full-text search matches whole words, sosebnever foundSebastianandauditnever foundLead Auditor. The plain search finds substrings but cannot fold accents, somunoznever foundMuñoz.mode: "hybrid"ORs the indexed full-text match with an accent-folded substring match over the samesearch.fields, and ranks full-text hits above substring-only ones. It changes the query and not the generated column, so it can be turned on for a live collection without the rebuild a changedsearchblock otherwise needs; it adds theunaccentextension and one helper function if they are missing. The substring half cannot use the GIN index, which is why"fts"stays the default. -
Five plugin slots that rendered nowhere now render.
entity.field.beforeandentity.field.afterrender around every form field,entity.row.actionsin each table row’s hover tools,global.searchin the app bar beside the breadcrumbs, andshell.toolbarat the end of the app bar. All five were declared and documented, and registering for one did nothing.UNRENDERED_SLOTSis now empty, and a test that scans for render sites fails if a slot is declared without one again. -
A component override can be named by module path. An override in a collection’s
admin.componentstook only a component, so declaring one meant importing React intoconfig/collections/*.ts, which the backend loads too.Componentnow takes a path or a lazyimport(), asadmin.Fielddoes, and the collections Vite plugin turns the path into a lazy import. An override that cannot be resolved renders the built-in component instead of nothing.
Changed
Section titled “Changed”-
The record dialog has its Create and Save buttons at the bottom. In a collection opened with
openEntityMode: "dialog", such asusersin a scaffolded project, Create, “Create and close”, Save and Discard sat in the right-hand corner of the top bar. They are now in a footer under the form, where every other dialog in the admin keeps its buttons. The top bar keeps the title, the overflow menu and the window controls. The side panel and the full-screen record still keep Save in the top bar, where it stays in view while a long form scrolls. -
A form of four fields or fewer is one column. The derived layout paired fields by type, so the new-user form put Email beside Name and left Roles alone on the row below. Short forms now stack. A
spanset on a property is still honoured, and longer forms keep their columns. -
The
entity.row.actionsand field slot props describe what their render sites have. InEntityRowActionsProps,entityIdisstring | number,path,collectionandselectionControllerare optional, andparentCollectionSlugsandparentEntityIdsare gone. InEntityFieldSlotProps,collectionis optional. A relation picker renders rows with no collection path, a table can hide selection, and a field can be rendered outside a collection form. A component typed against the old props has to handle the optional ones.
Removed
Section titled “Removed”-
PropertyIdCopyTooltipandLabelWithIconAndTooltipare gone from@rebasepro/cms. UsePropertyKeyHintto show a property’s key beside a label of your own, orLabelWithIconwith its newpropertyKeyprop. -
The
collection.filter-panelanddashboard.widgetslots, withCollectionFilterPanelPropsandDashboardWidgetProps. Neither ever rendered. The admin has no filter sidebar for the first to render into; put filter UI incollection.toolbarorcollection.widgets. The second named no position on the page; the home page hashome.children.start,home.children.end,home.cardsandhome.card.widget.
-
A search for two words finds a row that keeps them in two fields. Searching
sebastian melendezon a collection withfirst_nameandlast_namereturned nothing: the default search compiled oneILIKE '%sebastian melendez%'per column, and no column holds both words. Typingsebastianreturned nothing either — the trailing space was part of the pattern. The search string is now split into terms, OR-ed across the searchable fields and AND-ed across the terms, which is whatwebsearch_to_tsqueryalready did for a collection that declared asearchblock, so both paths answer the same question. Double quotes keep a run together as a phrase, a one-word search compiles to exactly the SQL it always did, and no search that used to match stops matching. Same fix in the Mongo driver and in the offline evaluator, which had the same shape. -
The service key is no longer rate limited. The data API buckets callers as API key, signed-in user, then IP — and
identifysaw the service key’suid: "service"and gave it the ordinary user allowance of 1000 requests per 15 minutes, shared across every service-key caller. It is the deployment’s own credential and already reaches every row, so the limit protected nothing that was not already open to whoever holds the key; what it stopped was the work the key exists for. A backfill of 7,925 rows died at row ~1000 with a 429, and a managed deployment has norateLimitsurface to raise. The service identity now skips the limiter entirely, without spending the bucket of whoever shares its key. API keys keep their ownrate_limit, users theuserallowance, anonymous traffic the IP bucket. -
A refused Google sign-in onto an existing account now tells the visitor what to do, in their language. Signing in with a provider onto an address whose account cannot be linked automatically showed the API’s message on the login screen, word for word: “…Sign in with your existing method, then POST to /auth/link/google to link google to your account.”
LoginViewnow shows “An account with this email already exists. Sign in with your email and password instead.” when that account holds a password, and “Sign in the way you did before.” otherwise, in all seven locales. The server’s response is unchanged for API callers, and theEMAIL_NOT_VERIFIEDerror now carriesdetails: { reason, provider }, wherereasonis"local-account-unverified"or"provider-email-unverified", so your own login screen can do the same. -
Starring a collection on the admin home page no longer draws a star the size of a card. The favourites chip passed
size="small"to a lucide icon, which writessizestraight into the SVG’swidthandheight.smallis not a length, so the browser dropped it and the icon filled the chip. The same mistake made four more icons render oversized: the join-path delete button in the collection editor’s relations tab, the export button, the empty avatar in the user picker, and “Log in anonymously” on the Firebase login screen. All five now passiconSize, and a lint rule (rebase/lucide-icon-numeric-size) refuses a named size on a lucide icon. -
“New user” in the admin saves again when
users.tsdoes not mark the secret columns. A project’s ownuserscollection replaces the default, and a copy scaffolded beforeexcludeFromApiwas in the template leaves it offpasswordHashandemailVerificationToken. The server adds the flag at boot, but the admin built its collections from the file as written, so the new-user form sent both fields asnull. The server then refused the create: “‘passwordHash’, ‘emailVerificationToken’ are excluded from the API on ‘users’ and cannot be written through it”. Neither field is shown in the form. EveryCollectionRegistrynow applies the same rule when it copies an auth collection, so the admin and the server agree on which columns are the server’s, whether or not the file says so. The boot warning asking you to addexcludeFromApi: trueis unchanged. -
Tab moves to the next field in a record dialog or side panel. Tab from a field could land on the dialog itself, so the next Tab started again from the header buttons, and Shift+Tab did the same. Leaving the field closed a tooltip rendered inside the dialog. Radix’s focus trap treats any node removed while focus is between two elements as the focused element being deleted: it focused the dialog, and the browser abandoned the move. The kit’s
DialogandSheetnow complete a move interrupted this way, whatever removed the node. -
The property ID no longer covers the form. Text, date, switch, geopoint, binary and vector fields opened a “Property ID” tooltip over their own label as soon as they took focus, and again on hover. The key now appears in small type beside the label when you rest the pointer on it, and clicking it copies it. It covers nothing, never opens on focus, and is not a Tab stop.
-
Global callbacks run. The
callbackspassed toinitializeRebaseBackend, andexport const callbacksfromconfig/index.tson the managed runtime, were stored on the backend’s own collection registry. No driver reads that one: each builds a registry of its own at boot and looks for global callbacks there. So a globalafterRead,beforeSave,afterSave,beforeDeleteorafterDeletewas accepted and never called, on any data path. The backend now hands them to every driver at boot, and refuses to start if a driver returns no registry that can take them. If you declared global callbacks, they start running with this release — read them again before you upgrade. A globalbeforeQuerybeside a MongoDB or Firestore data source also refuses to start. -
Cloud status no longer tells a serving project to create its database. A managed project whose database the control plane had not recorded showed
blockedOn: no_databasebesidestatus: activeinrebase cloud status, and suggestedrebase cloud db create --type managed, the one command that could provision a second, empty database and point the app at it. A project that has deployed once has a database, so the CLI now reports it blocked on a missing database only before its first deploy. -
A time imported from Excel keeps its minute. The spreadsheet reader turns Excel’s fractional-day dates into milliseconds by rounding down, so 12:30 often arrived as 12:29:59.999 and a field showing minutes displayed 12:29. A date within a millisecond of a whole second is now put back on it; a real sub-second time is left alone.
[0.21.1] - 2026-09-15
Section titled “[0.21.1] - 2026-09-15”-
rebase upgrademoves every@rebasepro/*package to one release. A project pins the framework in severalpackage.jsonfiles, andrebase buildcould only warn that they were behind.rebase upgraderewrites every@rebasepro/*entry independencies,devDependenciesandoptionalDependencies, in everypackage.jsonunder the project, keeping^and~, then installs with the package manager your lockfile names.--totakes an exact version (0.21.0, no registry lookup) or a dist-tag (latest, the default, orcanary).peerDependencies,workspace:,link:,file:, git and tag specs are left alone and listed with the reason. Overrides inpnpm-workspace.yamlandpackage.jsonare bumped the same way. Alink:orfile:override wins over every pin, so it is reported, and--drop-local-overridesremoves it. Only the version strings change: indentation, key order and line endings stay as they were.--dry-runwrites nothing,--no-installskips the install, and--jsonprints one document with every change. The build warning about old pins, andrebase doctor’s version-skew fix, now name the command. -
A bundle deploy uploads the project’s source, so a platform upgrade can rebuild it.
rebase cloud deploynow packs the source beside every backend bundle and names it in the deploy. In a git repository that is whatgit ls-fileslists from the repository’s top level, so.gitignoreis honoured and a package the project links from elsewhere in the repository goes too. Alink:orfile:dependency that reaches outside the project’s repository — a project that is its own repository, nested in another it links packages from — brings the repository holding its target, and the archive is rooted where they meet;@rebasepro/*links are never followed.node_modules,dist-bundle*,.envrcand every.envand.env.*file are always left out, committed or not;.env.example,.env.sampleand.env.templateare kept. TheVITE_*values a production build of your frontend reads travel with it, and no other variable does. If the archive is over 100 MB or the upload fails, the deploy goes ahead without it and says so.--no-sourceskips it for one deploy. A project’s owner can turn platform rebuilds off for good —rebase cloud settings set --platform-rebuilds off, or the project’s settings in the console — and then no source is uploaded, the platform deletes the copy it holds, and upgrades move the runtime only; rebuilds are on until someone does.--allow-downgradedeploys a bundle built on an older framework release than the project runs, which the control plane otherwise refuses asFRAMEWORK_DOWNGRADE.
Changed
Section titled “Changed”- Entity form fields are 48px tall again. 0.20.0 took them to 32px
(
small). The form passeslargeagain, and the field bindings default to it, so a field nested in a map, an array or a table cell’s popup editor is the same height as the rest. The text stays at 14px.
Removed
Section titled “Removed”RealtimeService.registerDataDriverSubscription,addSubscriptionCallbackandremoveSubscriptionCallback(@rebasepro/server-postgres). They registered an in-process listener in two steps and left its first read to the caller, which is how that read came to run unscoped (see Security).startDataDriverSubscription(id, request, callback)registers the listener and delivers its first rows. The request carries the subscriber’sauthContext.unsubscribe(id)cancels it.
-
rebase schema stalereports a generated schema the collections have moved past. It looked for one thing only: foreign keys named the way an earlier release derived them. A file missing a table, with a retyped column or with an enum label the collection had dropped was reported as “Nothing stale”, while everyrebase buildrewrote it. It now compares the file, declaration by declaration, with what the collections generate, asrebase doctoralready did. It names what differs, exits non-zero, and regenerates the file under--fix. When both problems apply, it reports both. -
A realtime listener on Postgres gets its first delivery in order, hears a deleted row, and keeps its whole query.
listenCollectionandlistenOnefetched their first delivery themselves, outside the realtime service that serves every refresh after it. Four faults came with that:- A slow first read could arrive after a newer refresh and put the listener back on the rows from before the change, where it stayed until the next write. It could also arrive after you had unsubscribed. The first read now takes the same delivery slot as every refresh.
listenOnedroppednull. A listener whose row was deleted, or never existed, heard nothing and kept the last row it had. It now receivesnull, as it does on MongoDB.listenCollectionpassed on nine of the query’s fields and dropped the rest. A listener with anor(...)group (logical) was handed every row, on the first delivery and after every change. The whole query is now stored with the subscription,logical,include,fields,distinctandsearchExplainincluded.- A listener on the base driver, with no user, read its first rows as the
server and every refresh as the anonymous user, so its rows changed on the
first write. Both are now read as the anonymous user, as on MongoDB. A
listener on a driver bound to a request, such as
context.datainside a collection callback, had its refreshes read as the anonymous user too. It now reads as that request’s user throughout.
-
A late subscription error no longer overrides newer data. On Postgres, a realtime subscription’s failed fetch always sent its error, even when a newer fetch had already delivered fresh rows. Two fetches for one subscription can overlap, for example the first load and a refresh after a quick write, and the slower one can finish last. So the client’s
onErrorfired, and the admin panel showed an error, for a view that was up to date. A failure from a subscription that had been cancelled, or replaced under the same id, could also reach the new one. An error is now sent only when nothing newer has reached the subscriber, the same rule its rows follow. A subscription whose rows cannot be sent at all, such as a row holding a value that will not serialise, still gets an error, on both databases. -
An in-process realtime listener hears when a read fails. Server code that listens with
listenCollection({ onUpdate, onError })orlistenOnewas not told when the read behind the subscription failed. On MongoDB, neither the first load nor any refresh after a change reachedonError. On Postgres the first load did, but a failed refresh was only logged, so the listener kept its last rows as if they were current. Both now callonErrorwith the error as it was thrown: anafterReadthat throws aRebaseApiErrorarrives as thatRebaseApiError, not masked the way a WebSocket frame is. A failure is not reported once newer rows have arrived, or after you unsubscribe.RealtimeProvider.subscribeToCollectionandsubscribeToOnetake the same optionalonError, and the router used when a project has several data sources passes it on. -
A realtime subscription on MongoDB reports a failed read. When the fetch behind a WebSocket subscription failed, the Mongo server logged the error and sent nothing. That happened on the first load and on every refresh after a change. The client got neither rows nor an error, so the admin panel’s view stayed loading and the SDK listener’s
onErrornever fired. The server now sends an error for that subscription. A refusal keeps its code and message, as it does over REST and on Postgres: anafterReadthat throwsnew RebaseApiError("…", { status: 403, code: "FORBIDDEN" })reachesonErrorwith codeFORBIDDENand that message. Any other failure, a 5xx included, arrives asCould not load data for "<path>". Check server logs for details.with codeINTERNAL_ERROR, and the error itself goes to the log. A refusal is logged as a warning, not an error. -
Date and read-only fields in the entity form match the other fields. A date field ignored the form’s
sizeand always drew at 48px. In 0.20.0 and 0.21.0, where the form wassmall, that put it 16px above the text fields beside it. A read-only field had a fixed 48px box and printed its value in 16px text, a size above the 14px of every input. Both now follow the form’ssize, and a read-only value is printed at 14px. See also Changed. -
A multi-select is as tall as the fields around it (
@rebasepro/ui).MultiSelectstood 2px short of aTextFieldorSelectof the samesize(48px against 50 atlarge), because the field’s 1px border was drawn inside its height rather than around it. A borderedMultiSelectis now the control height plus the border, like the others. Aninvisibleone, in a table cell, is unchanged. -
POST /admin/usersleaves delivery to a create hook, and shows the admin the hook’s temporary password. A collection’sauth.onCreateUseror a backend’sAuthHooks.onAdminCreateUserreplaces Rebase’s own delivery: the hook sends its own invitation or chooses a temporary password, and reports which. APOSTto the auth collection honoured that.POST /admin/usersignored it and treated the hook’s password as one Rebase had generated. With email configured, that meant minting a password-reset token and sending Rebase’s own invitation: a second email when the hook had already sent one, with a link that competes with the password the hook chose. The response then left that password out, so the admin who has to pass it on never saw it. A hook that sent its own invitation and returned no password was reported asinvitationSent: false. Both ways of creating a user now go through one step, so they give the same answer. -
Uploads between 10 MB and 50 MB work.
POST /api/storage/uploadhas its own body limit, the storage config’smaxFileSize(50 MB by default), and the docs said it overrode the globalmaxBodySize(10 MB by default). It never did. The global limit runs first on every route under the base path and answers413on theContent-Lengthalone, so every non-TUS upload above 10 MB was refused before the storage limit was checked. That includes every upload from the admin panel. Now the upload route is taken out of the global limit and meets only its own, whethermaxFileSizeis larger or smaller thanmaxBodySize. Every other route keeps the global limit, the rest of/storageincluded. TUS is unchanged: eachPATCHchunk is still capped bymaxBodySize, and the whole file bymaxFileSize. A proxy in front of the runtime has its own limit, which still applies. For the Helm chart’s, see the next entry. -
The Helm chart’s ingress lets 50 MB uploads through. nginx refuses a body over
proxy-body-sizewith its own HTML 413, before the runtime sees it, so the chart setsingress.maxBodySizeabove the runtime’s limit. It was12m: above the globalmaxBodySize(10 MB), but not above the 50 MBmaxFileSizethatPOST /storage/uploadnow meets (see the entry above). So on chart deployments, uploads between 12 and 50 MB still failed, admin-panel uploads included, and the error was nginx’s page instead of the runtime’s JSON. The default is now64m.pnpm check:chartnow refuses a default that is not above the larger of the two runtime limits. It reads them fromDEFAULT_MAX_FILE_SIZEand from the newRUNTIME_DEFAULT_MAX_BODY_SIZE, which@rebasepro/serverexports with the rest of the pod contract. A release that setsingress.maxBodySizeitself keeps its value. If yours is below50m, raise it. -
A collection-callback veto over the WebSocket is reported as a veto. A
beforeSave,afterSave,beforeDeleteorafterDeletethat throws, or abeforeDeletethat returnsfalse, is answered over REST as a 400 (403 forfalse) with codeCALLBACK_REJECTED, the author’s message, anddetails.stagenaming the hook. Both WebSocket servers answered the same refusal asINTERNAL_ERROR, and in production replaced the message with “An unexpected error occurred”. The admin panel writes through the socket, so an editor whose save or delete a rule refused was told the server had failed, and never saw the rule’s message. The row was still protected. Only the answer was wrong.The socket now answers with the refusal’s own code, message and
details, as REST does. The same applies to anyRebaseApiErrorthat carries a status. Realtime subscriptions on Postgres had the same gap: aRebaseApiError4xx thrown fromafterReadarrived as “Could not load data … Check server logs”. It now keeps its code and message. The client passes a frame’sdetailson to theRebaseApiErrorit throws, soe.details.stageworks over both transports.e.statusstaysundefinedfor a socket error, because a frame is not an HTTP response. Real server faults are still masked in production. REST and both sockets now decide what counts as a refusal through one function,declaredErrorAnswerfrom@rebasepro/server. -
Scheduled backups include your users again, and can be restored.
createBackupCronleft therebaseschema out of every dump unless told otherwise. The aim was to skip Atlas’s revision table, but that schema also holds every user account and the rest of auth, API keys, record history, the job queue and cron logs, and the functions every generated RLS policy and CDC trigger calls. So each scheduled dump was missing all of that. It also could not be restored into an empty database: every collection’s generated policies callrebase.uid()andrebase.roles(), sopg_restorestopped withschema "rebase" does not exist.rebase db backupexcludes nothing, so the manual and the scheduled backup captured different databases.The scheduled backup now excludes nothing either, and
excludeSchemasstill narrows it when you pass one. A scheduled dump made by an earlier version cannot be trusted, so take a fresh backup after upgrading. -
The Studio Backups panel downloads a backup’s roles file. A backup is a
.dumpplus a.globals.sqlsidecar holding the database roles its grants and RLS policies refer to, andrebase db restoreneeds both. The panel, and the/api/admin/backupsroutes behind it, only knew about the.dump, so a backup downloaded from Studio stopped at its firstGRANTwhen restored into a new Postgres. Each row now has a Roles file button, or says No roles file when there is none. The listing carries the sidecar’s key asglobalsKey, and/downloadserves it. -
FORCE_LOCAL_STORAGE=falseswitched the production storage guard off. In production alocalstorage backend is dropped unlessFORCE_LOCAL_STORAGEsays a durable volume is mounted, because a container’s filesystem is erased on the next restart or redeploy. The guard tested whether the variable was set, not whether it was true, so=false— the natural way to say “there is no volume here” — registered the local backend, and uploads succeeded into a disk the next redeploy wiped.rebase statusread it the same way and called that storage ready. Only a spelled yes forces it now, and the guard andrebase statusshare one reader.A deployment that set
FORCE_LOCAL_STORAGE=falsewhile storing uploads on local disk was losing them at every redeploy; its uploads now answer501 STORAGE_NOT_CONFIGUREDuntil a bucket is configured, or untilFORCE_LOCAL_STORAGE=truesays a durable volume really is mounted. -
REBASE_MCP_OPEN_REGISTRATION=0left OAuth client registration open. The switch that turns off dynamic client registration on the MCP authorization server compared against the literal"false", so0,noandoffleft/oauth/registerissuing a client ID to anyone who asked. Any spelling of no closes it now. -
rebase db backupaddressed a MinIO bucket as a hostname. The backup, restore andbackups listcommands passedforcePathStyle: falsewheneverS3_FORCE_PATH_STYLEwas unset, overriding the default the runtime’s own storage gets: path-style for a custom endpoint. Unset now leaves that choice to the endpoint in both, andS3_FORCE_PATH_STYLE__<KEY>=1is no longer read asfalse. -
Boolean environment variables accept one set of spellings.
true,1,yesandonmean yes;false,0,noandoffmean no, in any case; anything else leaves the variable at its default. Seven readers had spelled this seven ways, soDISABLE_DB_ROLE_SWITCHING=1andREBASE_EXIT_ON_UNHANDLED_REJECTION=truedid nothing,REBASE_DEBUG=0turned debug output on in one command and off in another, andCI=0read as a CI run.DO_NOT_TRACK=falsenow reads as a no instead of a refusal; telemetry still sends nothing without your explicit opt-in. The variablesloadEnvvalidates still accept onlytrue,falseor empty and refuse anything else at boot, as before. The parser is exported from@rebasepro/typesasparseEnvBoolean, andverify:docsnow refuses a boolean read that goes around it. -
A collection’s
onResetPasswordhook could not actually reset a password. The admin panel showed thetemporaryPasswordthe hook returned, and nothing saved it: the hook’s context can hash a password but not store one, and the route only passed the value on. The documented example did exactly this, so every reset through it handed the user a password that did not work, and left the old one working. A returnedtemporaryPasswordis now the account’s new password: Rebase hashes it, saves it and signs the user out everywhere before showing it to the admin.onAdminResetPasswordfollows the same rule, so its hook no longer has to write the password throughauthRepoas well; one that already does gets it written a second time, which is harmless. The example now generates the password withrandomBytesinstead ofMath.random. -
A release whose smoke test fails still gets its GitHub Release. The release job created the GitHub Release, and published to the MCP Registry, only when every step before it had passed, including a smoke test that installs the just-published CLI. 0.21.0’s smoke test ran six seconds after the npm publish, before
@rebasepro/server-postgres@0.21.0was visible on npm, so the packages and the tag went out without a GitHub Release. Both steps now run whenever the packages and the tag did. The smoke test now waits for every package at the new version rather than only the CLI, and fails if they never appear. The 0.21.0 GitHub Release has since been created by hand. -
A refused bundle deploy prints the control plane’s reason and remedy. On the managed path every refusal went to the generic error report, so a bundle refused at intake printed
Managed deploy failed to start (400)and dropped the hint. It now prints the message, the hint and the code, as a source deploy already did. AFRAMEWORK_DOWNGRADErefusal also names the two ways past it:rebase upgrade, or--allow-downgrade. -
A bundle deploy run from a subdirectory registers the project’s apps.
rebase cloud deployreadrebase.jsonfrom the working directory, so run frombackend/it found none there and told the platform the project declared no apps. It reads the project root’s.
Security
Section titled “Security”-
A guest passed
policy.registered()on MongoDB, and over the Postgres WebSocket. A guest, a caller from anonymous sign-in, has a real uid. The guest flag,isAnonymous, is the only thing that tells it from an account, andpolicy.registered()(“signed in, and not a guest”) reads it. Several doors dropped the flag and treated a guest as an account:- On MongoDB, the check that decides a single row ignored the flag. So
GET /api/data/<slug>/<id>,POST,PATCHandDELETElet a guest read and write rows aregistered()rule withholds, and so did the WebSocket and the SDK. Lists were filtered correctly. - The MongoDB WebSocket never read the flag at sign-in, whether the client signed in with the platform’s token or through an auth adapter. It served a guest’s lists, reads, writes and subscriptions as an account’s.
- A MongoDB in-process listener, such as
listenCollectionorlistenOneondriver.withAuth(guest), subscribed without the flag. - The Postgres WebSocket read the flag from the token, but ran every request
frame (
FETCH_*,SAVE,DELETE) as an account’s. A policy onrebase.is_anonymous()did not stop a guest there. A session signed in through an auth adapter lost the flag everywhere, subscriptions included. And a client with no session was treated as a guest on request frames only. Over REST and on subscriptions it was not one, and now it is not one anywhere.
REST on Postgres was never affected. The flag now reaches every door, and each WebSocket server builds one identity for both its requests and its subscriptions. If you use
policy.registered(), orrebase.is_anonymous()in a policy, with anonymous sign-in enabled, upgrade. - On MongoDB, the check that decides a single row ignored the flag. So
-
A MongoDB WebSocket client that never signed in ignored every security rule. With
requireAuth: false, a client that connected without signing in was served by the base driver. On MongoDB that driver applies nosecurityRulesat all. So the client read every row of every registered collection, and could create, update and delete in any of them. REST served the same caller as the anonymous user and enforced the rules. The socket now serves such a client as the anonymous user (roles: ["anon"]), as REST does, for both its requests and its subscriptions. If the socket cannot scope a frame, the frame now fails. It used to fall back to the base driver. If you run MongoDB withrequireAuth: false, upgrade. -
On Postgres, a realtime listener on a user’s driver got its first rows without that user’s row-level security. This affected server code that calls
listenCollectionorlistenOneon a driver scoped to a user, such asdriver.withAuth(user)or a function’srequireDriver(c). The first delivery was read on the base driver, the server context, before the user’s identity was attached to the subscription. Only the refreshes after a change read as the user. So on a connection that bypasses RLS, which is whenrlsUserRoleis set (a superuser, aBYPASSRLSrole, or the owner of the tables), the first delivery carried every user’s rows, andlistenOnehanded over a row the user may not read. The next refresh dropped them again.A guest’s refreshes had a gap of their own. The identity attached to them left out
isAnonymous, so a policy that excludes guests did not exclude them after a change.The first read now runs in the realtime service, as the subscriber, like every refresh. The subscriber’s identity is part of the subscription from the start,
isAnonymousand the token’s claims included, and it is built by the same function that scopes a request’s transaction. The WebSocket subscriptions that the SDK and the admin panel use were not affected: their first read was already scoped. So was MongoDB’s. If your server code listens through a user’s driver on Postgres, upgrade. -
The MongoDB realtime socket could read and write the auth store. MongoDB has no row-level security, so on that engine the collection registry is the whole access model: a
securityRuleis enforced only for a collection the registry resolves, andMongoDataServicemaps any path to a physical collection by name. The realtime socket forwardedFETCH_COLLECTION,FETCH_ONE,SAVE,DELETE,COUNT,CHECK_UNIQUE_FIELDand thesubscribe_*frames to the driver without asking whether the path named a registered data collection. For a path the registry did not know,AuthenticatedMongoDriverresolved the collection toundefinedand every rule check short-circuits open there —authorize(undefined)returnstrueand the query filter becomes “match all”. So any authenticated client (or, whenrequireAuthis off, any anonymous one) could name the auth collections as the path and readrebase_usersincluding password hashes, grant itself an admin role by writingrebase_user_roles, overwrite another account’s password hash, or delete refresh tokens — on the one engine where nothing behind the driver would stop it. The REST routes were never exposed: they mount per registered slug and 404 everything else.The socket now refuses, fail-closed, any frame whose path the registry does not resolve, on every data verb and both subscribe types, with a
NOT_FOUNDnaming the path — the same answer REST gives an unknown collection. Registered collections are unaffected, and their row security is unchanged. -
A delete over the WebSocket wrote its own audit record, and could get past a
beforeDeleteveto.DeletePropscarriedrow.values, and both server drivers took them as the row being deleted: they were whatbeforeDeleteandafterDeletereceived and what history recorded as the row’s final state. The REST routes filled them from a read of their own. The WebSocketDELETEhandler forwarded the client’s frame, so a caller allowed to delete a row could record anything as its final state — and could get past abeforeDeletethat refuses on a column, such as the callbacks guide’sif (row.status === "published") throw, by sendingvalues: {}. The in-process SDK sent{}on everydelete(id), so every delete from a function, a cron job or a callback recorded an empty row and gavebeforeDeletenothing to judge. The MCPdelete_documenttool did the same.Both drivers now read the row themselves before any callback runs, under the caller’s own scope — on Postgres, inside the delete’s transaction, as the caller’s role — and that read is what the callbacks receive and what history records. It is the read the REST route already made, so a delete over HTTP sees what it saw before. A row that is not there, or that the caller cannot read, is a 404 before any callback runs, on every path; over the socket and the SDK the callbacks used to run first.
DeleteProps.rowis{ id, path }now:valuesis gone from the type and ignored on the wire, so a published client that still sends it keeps working.The socket’s
DELETEalso stopped forwarding the frame’scollection. The driver merges a caller’s collection under the registry’s, so a key the registry does not declare survived, andsoftDelete: { field: "title" }turned a delete into an update oftitlethat nobeforeSaveand no write validator saw.One more change came with dropping
deleteMany’s own pre-read: a harddeleteManynow purges rows that are already in the trash, as a hard single-row delete does. Its old read hid them and answered 404.If a collection has
history: true, or abeforeDeletethat refuses on the row’s contents, and anything deletes through the socket or the SDK, upgrade. -
An admin password reset left the account’s existing sessions signed in. Resetting a user’s password from the admin panel, or through
POST /api/admin/users/:uid/reset-passwordandPUT /api/admin/users/:uid, changed the credential and revoked nothing. That applied whether the admin set the password directly, emailed a link, or got a temporary one. A stolen refresh token went on minting access tokens for the rest of its lifetime — after the one action an administrator takes when an account has been phished. Only the user’s own reset and change-password signed the other sessions out.Every way of setting a password on an existing account now ends its sessions: the refresh tokens are deleted and, on Postgres, every access token issued before the reset is refused. That covers
userManagement.updateUserwith apasswordtoo. The emailed-link mode now ends them when the link is sent rather than when it is opened, and so does a reset handled by anonResetPasswordoronAdminResetPasswordhook. -
A database error during an admin reset set a new password. Minting the reset token shared a
trywith sending the email, so a failed write took the “email failed” path: a fresh password was written over the user’s and handed to the admin as a temporary one. It now answers 500 and leaves the password alone.
[0.21.0] - 2026-09-14
Section titled “[0.21.0] - 2026-09-14”Breaking
Section titled “Breaking”-
context.client.datano longer compiles in a collection callback. A callback’s queries go throughcontext.data.RebaseCallContext["client"]is nowRebaseCallbackClient, which isRebaseClientwithoutdata. The old spelling used to compile and then, on the server, throwCannot read properties of undefined (reading 'collection')on every call.A server-side callback’s client is the
rebasesingleton, which has had nodatasince 0.18.0: its admin-scoped plane is spelleddataAsAdminand nothing else. The type kept sayingRebaseClientbecause the driver passed the object through a cast. A hook that caught the error failed silently — Rebase Cloud’s own stop and start hooks flipped a project’s status that way and paused nothing.afterSave: async ({ context }) => {await context.client.data.audit_logs.create({ action: "approved" });await context.data.audit_logs.create({ action: "approved" });}context.dataruns with the privilege of whatever triggered the callback, on the write’s own transaction.context.client.dataAsAdminis still there for a callback that must see what an admin may see.In
admin.browserCallbacksthe panel’s client does carrydata, so the old spelling ran there; it stops compiling too, andcontext.data, the panel’s own data source, replaces it.useRebaseContext().clientis unchanged and still hasdata.A driver that was never given the server client — one constructed outside
initializeRebaseBackend, or one it missed — now refuses a callback’s read ofcontext.clientwith a 500 that says so, instead of handing itundefined. -
SelectionController.selectedEntitiesis gone: a selection is either rows or a query. A collection with more rows than a view holds can now be selected in full, and a selection that stands for rows nobody has loaded cannot be anEntity[].selectionController.selectionis anEntitySelectionunion —{ type: "entities", entities }for rows ticked by hand,{ type: "query", query, excluded, count? }for every row matching the filter — andresolveSelection()from@rebasepro/cmsis the one way to turn either into rows: a page at a time, with a progress callback and a 20,000-row ceiling that throws rather than handing back a prefix.const handlePublish = async () => {for (const entity of selectionController.selectedEntities) {const selected = await resolveSelection({selection: selectionController.selection,accessor: data.collection(path)});for (const entity of selected) {await data.collection(entity.path).update(entity.id, { status: "published" });}};Keeping
selectedEntitiesbeside the new field was the dangerous option: in query mode it would have answered with whatever the view had scrolled — 50 rows of 12,480 — and a bulk action written against it would have acted on 50 and reported success.selectedCountreplacesselectedEntities.length, and isundefinedrather than 0 for a full selection against an accessor that cannot count.setSelectedEntitieskeeps its array form; the updater-function form moved tosetSelection.isEntitySelectedandtoggleEntitySelectionare unchanged.
-
cms— say where your admin panel is, and Rebase Cloud will link to it. A static app inrebase.jsoncan now declare the URL path where it mounts<RebaseCMS>. The CMS is a component inside your own frontend, so its address is a client-side route that nothing on the server, in the bundle or in the control plane can observe — and a project whose CMS is not at the root of its host therefore had no link to it anywhere in the Cloud console. Declaring it gives the console an Open CMS button and an address on the project overview, puts the URL inrebase dev’s banner and inrebase apps list.Alongside it, a project’s apps are now linked at their real addresses everywhere the console lists them. They never were: the link was rendered from
apps.publicUrl, a column no deploy path has ever written, so the Apps page showed a project’s apps with no way to open any of them. The address is derived from the project’s live host and the app’s path now, which also means it follows a custom domain the moment one verifies. Frontends folded into a managed backend bundle get their address recorded on deploy too — those rows previously stayed at “registered, never deployed” for the life of the project. -
A remote MCP endpoint that acts as the signed-in user, opt-in with
REBASE_MCP_ENABLED. The MCP server Rebase already ships is a developer tool: stdio, on a laptop, authenticated with a service key — admin-scoped. This one is for the people who use what you built./mcpis an OAuth 2.1 resource server and/api/oauththe authorization server that issues its tokens, so a person connects an AI client to your app by signing in with their own account and approving it on a consent screen. Every database call the tools make runs as that person, under the same RLS as their own requests: the tools do not filter, the database does.It is off unless
REBASE_MCP_ENABLED=true, and it needsREBASE_PUBLIC_URL, because the issuer and the token audience cannot be taken from aHostheader the caller controls. NoREBASE_ROLEturns it on, and a managed project cannot switch it on from its own environment. The Helm chart gainsmcp.enabled,mcp.publicUrlandmcp.openRegistration. Dynamic client registration is on by default;REBASE_MCP_OPEN_REGISTRATION=falseturns it off. A person can list and withdraw the applications they connected (GETandDELETE /api/oauth/grants): a withdrawn grant’s refresh tokens stop working at once, and an access token already issued expires within the hour. See the MCP surface. -
rebase cloud db connect— a local port that is your cloud database. A managed database has no public endpoint, and the console and CLI used to answer that with akubectl port-forwardcommand nobody outside the platform could run: no route has ever issued a customer credentials for the cluster.db connectopens a listener on127.0.0.1and carries each connection over a WebSocket through the control plane, which holds the only credentials for the cluster. Postgres still asks for its password, so the tunnel is a network path and not a credential. It runs until Ctrl-C, and--revealputs the password in the printed URL.db infoanddebug dbprint the tunnel instead ofkubectl, and say why the host they list does not resolve on your machine; indb info’s outputportForwardis replaced bydirectAccess, which carries akubectlrecipe only when the cluster is your own. The tunnel anddb info --revealneed the organization’s owner or admin role — the same one Studio’s SQL editor asks for, because all three end at a session on production data. -
Select every row that matches, not just the rows on screen. A collection view holds a few hundred rows of what may be hundreds of thousands, and there was no select-all anywhere, so acting on a thousand rows meant ticking a thousand checkboxes. The toolbar’s checkbox now opens a menu — All on this page, All 12,480 products, None — and a full selection is a query rather than a list of rows: unticking one adds an exclusion, and moving the filter drops the selection, because it was a promise about one specific query. The count sits beside the checkbox, and nothing moves when the first row is ticked. The Breaking entry above covers what this changes for a custom action.
The CSV export follows the selection, or the current view — it used to read the whole collection with no filter at all, so a filtered table of 40 rows exported everything — and says which it is about to download. A bulk delete runs eight requests at a time, can be cancelled, and runs every one before reporting a failure, so it cannot half-apply silently.
-
Write the columns one many-to-many link carries.
PATCH <collection>/<id>/<relation>/<targetId>with a_pivotbody sets the junction row’s own columns — theroleon one membership — without re-sending the whole membership array, which is the lost update the membership diff exists to avoid. The payload is validated against the relation’sthrough.propertiesthe way a row’s values are, sorequired,enum,min/maxand per-fieldaccess.writemean the same there. A body carrying both_pivotand the target’s own columns is refused rather than ordered. -
A bundle deploy records the commit it was built from. The CLI builds and uploads a tarball, so nothing near the control plane had a repository to ask, and almost every deployment in the console’s list showed no commit. The CLI is the one party standing in the repository: it reads
git log -1there, subject included, and sends nothing when there is no repository or no commit yet — never an empty string, and never an identifier invented for a dirty tree.
Changed
Section titled “Changed”-
The usage-sharing question is six lines, and its default is now yes.
rebase init’s prompt used to print the entire JSON payload inline — twenty-five lines in the middle of a flow meant to be quick — and default to no. It is still a question: nothing is sent until it is answered,nis one keystroke, and both the prompt and the answer name the command that turns it off.rebase telemetry shownow prints a specimen payload while sharing is off, instead of refusing until it is on, so the person deciding can read exactly what would be sent.DO_NOT_TRACK,REBASE_TELEMETRY_DISABLED,CIand a project’s"telemetry": falsestill suppress the prompt and every event outright.A reported failure now says what kind it was.
cli.errorcarried the error’s class name, which isErrorfor a refused connection, a missing file and a permission denial alike; it carries the error’s code —ECONNREFUSED,ENOENT,EACCES— now, and still never its message. -
Request metrics count the tenant’s traffic, not the platform reading it. The metrics middleware was installed before
/metricswas mounted, so every scrape counted itself as a request — and a control plane scraping every few seconds made those most of the traffic, the 0.0% error rate and the fast mean latency./metrics,/health,/livezand/readyzare no longer counted, by exact path, so an app’s own/healthy-snacksstill is. Requests outside the API base path are labelledappinstead ofother, andotherkeeps only paths under the API base that this backend does not serve — a number that means something when it is not zero. A dashboard grouped by that label will see the split. -
The panel downloads 85 kB less before the login screen paints. The record inspector imported the JSON preview statically, which pulled a syntax highlighter into the eager bundle for a tab nobody can open until they have signed in and opened a record. It loads on demand now, like the history view beside it.
Removed
Section titled “Removed”-
The shared database tier is retired.
--db-modeis gone fromrebase cloud compute setandrebase cloud projects create, andcompute showno longer prints a “Database” row or carriesdatabaseModein its--jsonoutput. There is nothing left to choose: every managed database is a CloudNativePG cluster of the project’s own, in the project’s own Kubernetes namespace, with its own scheduled backups and its own WAL archive.The pooled tier existed as a cheap rung, and at the density it actually ran at it was not paying for itself while being the sole source of three defects — a 4:1 oversubscribed volume, an enforcement that killed connections rather than degrading to read-only, and, worst, a
dedicateddial that silently abandoned a project’s data on the pool and pointed its application at a new empty database. The control plane now refuses that move outright.Nothing is removed from the wire: the control plane still accepts
dedicated, and a project row that still sayssharedstill reads back. A client old enough to send--db-mode sharedgets a 400 that says the tier was retired, rather than writing a value nothing provisions. The database is still dialable —--db-cpu,--db-memory,--db-instancesand--storage— andrebase cloud db create --type byodbstill points a project at a PostgreSQL the platform does not run. -
The
rebase eject infratombstone. The command andrebase.infra.jsonwent in 0.17.0; what survived was a branch that printed a sentence saying so, still there two minor versions later. Asking for it now reports thatrebase.jsondeclares no app namedinfra— which is true, and is the same answer every other unknown name gets.
-
A job, queue message, topic event or history entry written from a callback now commits with the write, and never without it. The jobs guide, the queue and topic handles and the hooks guide all promised that “a job enqueued in a transaction that rolls back was never enqueued”, and the history service that an entry commits with its row. Neither held. Both were written through the default driver’s own pool connection, in autocommit: a job enqueued from an
afterSavethat then threw stayed queued for a row that was never written, and a worker could claim it before the row committed; a batch that rolled back after its first row kept that row’s history entry.Inside a write they now go on the write’s own transaction. That transaction runs as the restricted request role, which is revoked from
rebase.jobsandrebase.entity_historyon purpose, so each goes through one narrowSECURITY DEFINERfunction —rebase.enqueue_jobandrebase.record_history, created at boot and executable only by that role. An idempotency-key clash inside a write answersnullinstead of aborting it. Webhook deliveries queued from a callback wait for the commit and are dropped with a rollback. Outside a write — a custom function, a cron — an enqueue commits at once, as before.A database that refuses to create the functions is reported at boot, and both fall back to the old behaviour rather than failing writes.
-
A global callback is never handed a collection that is not there. Every callback props type declares
collection: CollectionConfig— non-optional, and the documented global hooks dereference it (if (collection.slug === "audit_log") return;). The Postgres driver resolves that value from the registry, which answers nothing for a path it does not know. The collection and property tiers are derived from the resolved collection, so they cannot run without one; the global tier is registered independently of it, and received the absence through a cast that quietly dropped the| undefined. A globalbeforeSavereadingcollection.slugtherefore threw aTypeErrorwhich the callback error path then answered as400 CALLBACK_REJECTED— blaming the author’s own rule for a value the framework had failed to supply.Skipping the tier instead was not available:
afterReadis documented as the place for security-critical redaction, “no read path bypasses it”, and a tier that silently does not run on the paths the registry cannot resolve is precisely such a bypass. So a path with no collection is now refused before any tier runs, with404 NOT_FOUND— which is what the read and write paths already did with such a path a few lines further on, and is the honest answer to a request naming a collection that does not exist. A relation embedded in a REST read is masked through the target config the relation itself declares, rather than a second lookup that could miss. -
cms— a collection’sformViewcan edit the record it shows.FormViewConfigdocuments a Builder that “receives the same props as entity view tabs” with Save beside it (includeActions, default true). The edit view rendered the Builder in place of the record form, and only the form can hand out a working context — so every Builder got the read-only stand-in kept for tabs that are still loading:readOnly: true, withformContext.setFieldValue,save,submitandformex.setFieldValueall throwing. Each keystroke threw inside the input’s own handler and was dropped; Save and Discard, which wait on the same context, never appeared; and a new record, which has no id to build even a stand-in from, rendered nothing at all. The collection’s entity view tabs were caught too: they hold the stand-in only until the form publishes its context, and with no form that was for as long as the record stayed open.The form now renders the Builder in place of its generated fields — the way an entity view declared with
includeActionsalready was. It gets the live context, so what it writes is validated, stored by the bar’s Save and covered by the unsaved-changes prompt. It also getsparentCollectionSlugsandparentEntityIds, a lazy Builder gets its own loading boundary, and it sits in the form’s column, as it already did on the read-only and detail screens.includeActions: false, computed and never read, now takes Save, Save and close and Discard out of the bar while the formView is on screen, for a Builder that stores the record itself throughformContext.submit().Where the record cannot be edited, the context now says so. The stand-in reported
disabled: falsewhile throwing on every write, so a Builder that honoureddisabledoffered controls that could not work; it carriesdisabled: truebesidereadOnly: truenow, as the detail view’s always did. It also no longer vanishes once the hidden form beside it mounts: for a user without permission to edit, that swapped aformViewfor the default rendering, and droppedadditionalFieldsfrom the default one, a frame after they appeared. -
A collection created from the panel’s editor no longer stops the project from booting. The schema editor moved a collection’s presentation keys into its
adminblock when it wrote a file, and left a property’s —markdown,multiline,previewAsTag— exactly where the panel put them, at the top level of the property. The boot validator treats a key that moved in 0.11 as fatal, and the loader imports every collection in the directory, so one file written this way stoppedrebase devfrom starting at all: “Could not regenerate the database schema”, then a backend that never came up.savePropertyhad been fixed for this;saveCollectionhad not, and creating a collection is asaveCollection.Two things made it certain rather than likely. The walk that nests a property’s keys did not know about
oneOf— the container the block-based templates are built out of — so it skipped every block inside them. And all four of the editor’s starter templates were written in the pre-0.11 flat shape behind anas unknown as AdminCollectioncast, which is the only check they had. The cast is gone: they aresatisfies AdminCollectionnow, so a key in the wrong place is a compile error. The templates also hadurl: "image"on a boolean flag — the renderer isadmin.urlPreview.What the schema editor writes is now asserted against the boot validator itself, for both entry points, rather than against a copy of its rules.
-
The Markdown field’s “Paste behavior” panel is gone. Its two switches wrote
markdown.htmlandmarkdown.transformPastedTextat the top level of the property — the fatal shape above — clobbering theadmin.markdownflag that makes the field a markdown field at all. Nothing ever read either one: the editor’smarkdownConfigprop was destructured and never used, and no caller passed it. Removed rather than wired, so the panel stops promising behaviour that was never implemented. -
The agent skills are inside the example gate now.
check:doc-examplesread the docs and nottooling/rebase-agent-skills, which is the surface an agent copies from verbatim. Two of its examples wrote a property key at the top level — one of them directly beside a correctadmin: { readOnly: true }in the same literal. The gate now validates 123 collections instead of 87. -
A tenancy rule over a
belongsTorelation compares the foreign-key column.tenant: { field: "org" }over a relation compiled to a comparison against a column calledorg. The column isorg_id— the relation’s local key — soCREATE POLICYfailed, and a table with RLS enabled and no policy denies every row. The rule read exactly right, which is what made it quiet. OnlybelongsTohas a column on the table itself, and that column is the one compared now. -
Callbacks on a collection in a second
database(...)source get their client. The server client was attached to the default source’s driver only, so a callback on any other source readcontext.clientasundefined. Every source’s driver gets it now. -
A property that declares
url: truerenders as a link.url: trueis the data statement — the string is a URI, and the OpenAPI contract is generated from it — but the panel’s preview and form field keyed offadmin.urlPreviewalone, so a property that had declared itself a URL rendered as plain text in the table, the list and the form. The declaration is enough now, and the form field getsinputType="url", so a phone offers the right keyboard.urlPreviewkeeps its own job — rendering what the link points at, an image, a video or a file card — and acceptstrueagain: it was always honoured at runtime and missing from the type, so the one value meaning “just a link” did not compile. -
A resized column stays resized. Column widths, column order and the view mode a collection was left in are per-user state kept in localStorage — but
<Rebase>only provided the store it was handed, and the apps the CLI scaffolds hand it none. A drag widened the column until the next re-render rebuilt it from the unchanged collection, and favourites and recently-visited never appeared on the home page. The local store is the default now, and a write to it re-renders the view that reads it. -
An
additionalFieldsBuilder gets the documented context in every view.AdditionalFieldDelegateProps.contextis aRebaseContext, and the table view passed one. The entity form and the entity view passed their own form context through a cast, so a Builder reading the documented context worked in the table and readundefinedin the form — and in the entity view it was also the context from the first render, never updated. Both pass the liveRebaseContextnow. -
An explicit disconnect stops the subscription watchdogs. The realtime client’s
disconnect()cleared the socket’s handlers, including theonclosethat suspends them, so it was the one close that left them armed for up to 30 seconds. After a sign-out — which disconnects but keeps the subscriptions to resume later — a watchdog firing in between failed one, and signing back in left it dead. Afterclose(), the timer held a Node process open on its own. -
The runtime retries a bundle fetch it was asked to wait on. A
429 Too Many Requestson the bundle download was treated like a 404 — permanent — so the six-attempt retry loop was skipped for the one status that needed it, and a managed deployment could fail to start and roll back over a limit that would have cleared in seconds. 429 and 408 are transient now,Retry-Afteris honoured in both forms RFC 9110 allows, and the wait is capped at 30 seconds. -
A healthy process no longer announces itself as partial. Every boot logged “Partial runtime surface — some routes are not served by this process”, because the MCP surface is off by default and off was read as trimmed. That line is how a deliberately trimmed process is told apart from a broken one, so it now fires only when somebody actually turned a surface off.
-
rebase buildprints a docker command that builds. For an app whose Dockerfile copies the workspace’s root lockfile — the normal shape in a pnpm, turbo or nx monorepo — the printeddocker build -f backend/Dockerfile .died on its firstCOPY, because the build context has to be the repository root. An app’scontextinrebase.jsonmay now point above the project, and onlycontext: every other path names something Rebase reads, which a bundle has to carry.rebase buildprints the command from where it must run —cd .. && docker build -f app/backend/Dockerfile .— and prints nothing when the Dockerfile sits outside its own context, rather than a command that fails on a line the reader did not write. -
rebase cloud billingfinds the organization’s billing account. Both readers in the CLI looked forbilling_account_idandbillingAccount, and the API sendsbillingAccountId, so every organization reportedaccount: nulland the deploy pre-check never saw an internal plan. -
Studio’s Policies screen files your tables as yours. Rebase creates a project’s collection tables in the
rebaseschema, beside its own plumbing, and the screen treated that whole schema as platform-internal: a project’s own tables — RLS on, policies written — sat dimmed and collapsed under “Rebase Internal” besiderefresh_tokens, and “Schema Collections” rendered empty. A table the project maps to a collection is the customer’s wherever it lives, and insiderebasethe table’s name decides, against the list the privilege revoke already uses. -
Studio’s API explorer explains a failed load. An expired console session rendered the string “401” in a red box, with no title and no way out. It says what happened now, tells a refused session from a spec it cannot parse, and offers a retry, with the raw status kept underneath for a bug report.
-
Smaller panel fixes. A missing image fills the box it would have occupied, with a centred icon, instead of an error block in the corner of it; the drawer’s icons no longer shift 2px sideways when the collapsed rail opens under the pointer; and fifteen colour utilities that named theme tokens the theme never defined — among them the list row’s hover colour and the selected row’s highlight — now render.
-
The callbacks guide described the wrong transaction. It said an
afterSave’scontext.datawrites open separate transactions and that a failingafterSavedoes not roll the save back. On Postgres the opposite is true, and always was:beforeSave, the write andafterSaverun in one transaction, and a throw rolls back the write with everything the callbacks wrote. The guide, theafterSavereference and the agent skills say so now, and that MongoDB has no transaction.
Security
Section titled “Security”-
A realtime
afterReadread through the owner connection, past RLS. On Postgres, theafterReadhooks that run on a subscription frame were handed a context assembled by hand, and itscontext.datawas the realtime service’s own driver: the base driver, on the owner connection, outside the user-scoped transaction the frame’s rows had been read in. The rows in the frame were filtered correctly. What a hook then read throughcontext.datato enrich them was not — a hook that joined in related rows saw every tenant’s on each.listen()frame, and whatever it attached reached the subscriber. The REST read of the same rows runs the same hook on the caller’s transaction and saw only what RLS allowed.A subscription’s hooks now get the context the REST path builds, bound to the frame’s own transaction, and it refuses rather than falling back to the unscoped one. The hand-built context also lacked
clientandstorageSource, so a hook that signed a URL or invoked a function worked over REST and failed on every subscription; both are there now.If a collection has an
afterReadthat reads other rows throughcontext.data, and anything subscribes to it, upgrade.
[0.20.0] - 2026-09-10
Section titled “[0.20.0] - 2026-09-10”-
Soft delete. One condition, applied from every read path rather than bolted onto each one, so a soft-deleted row is invisible to
find,findById, REST, the SDK and realtime alike instead of leaking through whichever reader was written last. -
Field-level access control —
access: { read, write }per property. The server now enforces the write rules the config already published; previously a property could declare its access and nothing checked it on the way in. -
tenant— one declaration for a multi-tenant collection. The tenancy claim, the RLS scope and the config validation come from that single declaration, so a collection cannot be tenant-scoped in one layer and open in another. -
Junction payload columns —
manyToMany.through.properties. A join table can carry its own columns and they are planned, generated and typed like any other. -
A read query language the driver actually implements: keyset pagination (
after(),?after=), nestedincludetrees,not,distinct(),fields(),aggregate()and explicit nulls placement — one keyset codec and one include normaliser shared by SDK, REST and the Postgres driver, rather than three readings that disagreed at the edges. Nested includes batch, andaggregate()removes the per-push/countround trip. -
A write path to match: batch writes, ETags, field operations, upsert targets,
return=minimal,defaultValueapplied on create, and userautoValuesstamped instead of dropped. -
Schema declarations that reach the generated artifacts: declared indexes now land in the Drizzle file, plus
defaultValue,updated_attriggers and numeric precision. -
includeis typed from the collection’s relations in generated code, so a wrong include name is a compile error rather than a runtime empty. -
Storage enforces a property’s
maxSizeandacceptedFileson the server, not only in the browser. -
The MCP server is published to the MCP Registry.
@rebasepro/mcphas carried itsmcpNamesince it was written, but nothing ever published aserver.jsonagainst it — the name was reserved on npm and claimed nowhere, so the server was absent from the registry every MCP client searches to offer an install. -
Usage sharing reports deploys and failures.
cli.deployandcli.errorwere declared and never recorded, so a consenting user reported scaffolding and dev servers but never whether they shipped or whether anything broke.cli.errorcarries the command and the error’s type, never its message — a message is usually a path or a connection string.rebase initalso asks again on a machine that previously declined; it never re-asks one that accepted, andDO_NOT_TRACK,CIand a project’s"telemetry": falsestill suppress it outright.
Changed
Section titled “Changed”-
One interpreter, three renderers.
Property→ SQL had three independent emitters and no shared representation, and they disagreed in twelve places — three of them fatal at boot. There is now a singleSchemaPlanthe DDL, Drizzle and policy renderers all read, and the live catalogue is the runtime’s schema rather than a fourth opinion about it. -
Studio and the panel join the surface ladder: surface roles, chrome discipline, and a density pass across the editors.
-
A scaffolded project is built with TypeScript 6. Every workspace package was already there; the templates pinned
^5.9.2, two majors behind, so a new project’s first install warned about an unmettypescriptpeer. Its vite toolchain moves with it, to the versions the reference app is built and tested against every CI run. -
Scaffolding asks for the project name when one is not given, defaulting to
my-app.
-
A generated column no longer wedges every push behind it. PostgreSQL refuses
ALTER COLUMN … TYPEon a column aGENERATED ALWAYS AS … STOREDexpression reads, and a search block’stsvectoris deliberately hidden from Atlas — so Atlas planned an alter against a column whose dependant it could not see, PostgreSQL refused, and becauseschema applyruns in one transaction every unrelated statement rolled back with it. Onevarchar(255)→textwidening on a searched column was enough to makerebase db pusha permanent no-op, with an error naming neither the generated column nor the reason. Bothdb pushanddb migratenow drop the generated column, let the apply through, and rebuild it fromsearch.sql. -
A database refusal now names the field, not the column — on both the server and the driver, so a validation error points at the thing the author wrote rather than its snake_cased physical name.
-
A primary key no generator can honour is refused at config load, instead of producing a schema that fails on first insert.
-
The driverless realtime refetch fallback establishes the viewer too, so a fallback refetch no longer returns rows RLS should have hidden.
-
One zod:
loadEnvstops merging across copies. -
A literal NUL byte in
conflict-target.tsmade the file read as binary to git and vanish from every search. -
A selected reference cell in the panel previews and edits in one line.
-
Keyset pagination could not be started.
?after=was accepted andmeta.nextCursorwas never sent, so a client had nothing to page with: neitherrestFetchServicewrapper forwardedcursorFor, and the route can only see what those wrappers expose. The same omission once made/api/data/:slug/aggregateanswer 501 everywhere, so a guard now reads the members off the interface and fails if either wrapper drops one. -
distinctcollapsed nothing. The projection force-selects the primary key so a row can be addressed, and a surrogate key makes every row unique by construction — the same reasoning that already refusesdistinctbeside a search query. A distinct read no longer selects the key, and reports nototalrather than a row count that describes a different set. -
A write could be refused for a field its own
beforeSavefills in. The create-timerequiredcheck runs at the request boundary, before the hook, and exempteddefaultValueandautoValuebut not “a field a hook supplies” — which is the slug-from-title pattern. It now steps aside for a collection that declaresbeforeSave, leavingNOT NULLas the backstop. -
A list column could name one property and render another. The leading column took its label from
listProperties[0]and its content from the title slot, so a users collection listing["roles", "createdAt"]printed “Roles” over the name-and-email cell and showed roles nowhere. -
The collection editor silently dropped three fields.
autoValueon a string property andprecision/scaleon a number one never reached the serializable mirror the panel round-trips through, so opening such a collection and saving turned aNUMERIC(10,2)column back into an unbounded one. -
A new project booted into a warning its own fix could not clear. Seven schema-drift differences on
users, on a database nobody had touched, andrebase schema generate— the remedy the warning printed — changed none of them: they are all by design, and the boot differ was the one reader that had not been told. -
A first install was neither quiet nor clean.
@ariga/atlasreached it only transitively and is now an optional peer of the driver — nothing in adlxwants it, and the runtime image already turns it off. The admin’s spreadsheet reader moved off an unmaintained package, taking six deprecated transitive dependencies with it, and a blank header column no longer shifts every column after it into its neighbour’s field. -
Date cells in the collection list carry an accessible name; the focus ring is drawn inside the box, so an ancestor can no longer clip it.
-
Soft delete did not work at all. Every
DELETEon a collection declaringsoftDeleteanswered 500 and left the row live: a soft delete is a save — it stampsdeleted_at— and the read-back afterwards went through the ordinary walk, which hides stamped rows, so it could not see what it had just written and threw inside the transaction, rolling the stamp back with it. -
A row already in the trash could never be purged.
?hard=trueis the “empty trash” operation, and both delete routes looked the row up with the default read — which hides exactly the rows they were asked to remove — so they answered 404 for a row?deleted=onlywas listing a moment earlier. -
minandmaxover a number column came back as strings.?select=avg(price),max(price)answered{ avg_price: 5, max_price: "8.5" }: one column, two functions, two JSON types, and arithmetic on the second one silently concatenating.min/maxare uncast by Postgres and were not parsed back; they are now, decided by the column’s declared type so amin(sku)of"00123"still comes back as text. -
A new project’s first
pnpm run devno longer opens with a vite warning about__dirnamein a config file its author did not write.
Security
Section titled “Security”- The password hash was on the wire.
enforceAuthSecretExclusionran after every driver had already built its own registry, and a registry copies each property it is handed — so the flag reached the config object and none of the copies that serve requests. A redeclared users collection refused writes namingpassword_hashexactly as documented, while returning the scrypt hash and any pending verification token in every row a select policy admitted. It now runs before the bootstrappers, and again afterwards for whatever a driver introspected.
[0.19.1] - 2026-09-07
Section titled “[0.19.1] - 2026-09-07”-
A project that installed its own zod booted, reported healthy, and loaded none of its functions. The whole error was
[functions] Failed to load ops.js:{"code":"invalid_type","expected":"nonoptional","path":["GEMINI_MODEL"]}naming fields the project sets a
.default()for, and never mentioning zod.loadEnv({ extend })combined the two schemas withrebaseEnvSchema.merge(), which reaches into the other schema’s internals and therefore only holds when both were built by the same copy of zod. This package inlined the zod it built against while re-exportingz, so an app’simport { z } from "zod"was a different set of classes — and, under our own^4.4.3range, a different version, since we build against 4.4.3 and apps install 4.5.x. Merging across the two dropped everyZodDefaultwrapper, so each defaulted field came back required.Fixed at both levels. zod is externalised, so an app gets one instance whether it imports
zfromzodor from@rebasepro/server; andloadEnvnow parses the base schema and the extension each with its own.parse()and merges the results, so it no longer depends on shared class identity at all. The production-refinement pass still runs over the merged object, so a.default()in an extension is still checked for a loopback host.The guard added for this in 0.18 never fired once. It probed
extend instanceof z.ZodType, on the stated theory thatinstanceofis false across two copies — true of zod 3, and false of zod 4, which installs a structuralSymbol.hasInstance. Its test passed because the fixture built its “foreign” schema fromzod/v3, which is a different implementation rather than a second copy of the one that ships. That test now pins zod 4.5.4 as a separate devDependency and asserts the pairing works; it fails against the old code. A build gate refuses any package that inlines a library it re-exports.
Five defects an end-to-end pass over a freshly installed project turned up.
-
rebase db pushcould not finish against a database that had never booted. It died at “Step 3/3: Applying RLS policies” withrelation "rebase.users" does not exist— which is the documented bring-your-own-Postgres first run: setDATABASE_URL, then push. The step before it,ensureAuthTables, exists precisely to create that table and had failed one line earlier with a warning nobody would connect to the error: it loads the project’s collections to find the one flaggedauth, and could not resolve./authorsontoauthors.ts.Third copy of the same rule. Two spawns of the driver CLI were fixed to take tsx; this one was missed, because a guard that asks “does a tsx lookup appear earlier in this file” is satisfied by an unrelated one. There is now a single
spawnDriverCli, and the gate counts spawn sites rather than inspecting them — one copy is the number that makes the question unnecessary. -
.env.examplenamed a port the project does not publish, with itsDATABASE_URLuncommented.rebase initderives a per-project database port and rewritesdocker-compose.ymland.envwith it. It left.env.exampleat the template’slocalhost:5432— and left itsDATABASE_URLset, which contradicts the rule the rest of the scaffold states in three places: unset means the managed database.Copying
.env.exampleto.envis a near-universal habit, and on a machine already running Postgres on 5432 — most developer machines — the result is not a connection error. It is a successful connection to a different database, which boot then provisions. Found exactly that way: a test harness read the example file and spent three runs talking to the wrong server.initnow comments the line out and writes this project’s port into it, and into thesslmodeillustration further down, so the file names one port. -
A dev database that fails to start now says why. The daemon is detached, so its output goes to
pglite.lograther than to the terminal, and both failure messages named that path instead of carrying its contents. That is a fair trade on a laptop and none at all in CI, where the runner discards the workspace: a canary run failed with “The development database failed to start (exit 1) — see pglite.log”, and by the time anyone read it the file was gone. Both messages carry the tail of the log now, bounded, and say so distinctly when the daemon wrote nothing or the log cannot be read. -
GET /api/data/:slug/aggregateanswered 501 on every deployment.FetchService.aggregatehas existed for as long as the route has, and the route reads it offrestFetchService— an adapter object that listedfetchCollectionForRestandfetchOneForRestand nothing else. Both getters, so both the service-key path and the authenticated one. The 501’s own comment says it describes “every non-Postgres driver”, which is what its author believed; the endpoint index documentscount(),sum(),avg(),min(),max()andgroupBy, none of which had ever run over HTTP.Forwarded from both. The authenticated one goes through the same read-only
withTransactionas the other two, which is what sets the RLS GUCs and drops to the restricted role — not an implementation detail here, because an aggregate is an efficient way to learn about rows you cannot select. Verified: an admin counts 2 users and a second account counts 1, matching what each can list, and an unauthenticated aggregate is 401. -
rebase ejectproduced a project that compiled and could not boot. The template’senv.tstookzfrom"zod"rather than from the runtime, under a comment explaining that the version an ejected project pinned did not export it and ending “Switch both lines back when the version bumps.” 0.18.0 bumped and publishedz; the workaround stayed, and became the thing it was written to avoid.loadEnv({ extend })recognises a.default()by class identity, so a schema built with a second copy of zod is rejected field by field: every ejected project on 0.18.0, 0.18.1 and 0.19.0 died at boot on a rawZodErrornamingSMTP_PORT,SMTP_SECUREandAPP_NAME— three variables with defaults, none of which the operator had set.check:templatesnow refuses a template that importszoddirectly while the runtime exportsz, which is the narrow condition that turns this workaround into a defect rather than a necessity. -
A path naming a relation the collection does not declare was a 500.
GET /api/data/authors/1/postsansweredINTERNAL_ERROR— telling the caller to retry or report an outage over a request that will never succeed, and burying a message that already said exactly what was wrong. It is a404 UNKNOWN_RELATIONnow, like an unknown collection: the URL names nothing. Three throw sites, one innested-path.tsand two inRelationService. -
rebase schema stalechecked nothing on the stock scaffold. It loads the project’s collections in process, and the two places that spawn the driver CLI each chose the interpreter from the driver’s own file extension — tsx for a.tsentry,nodeotherwise. Correct while the driver shippedsrc/; wrong the moment it shippeddist/cli.js, because the files being loaded are the project’s TypeScript. Node cannot resolve./authorsontoauthors.ts, so the command reported “⏭ Not checked” and exited 0 — on a scaffold whose own collections import each other. Both spawns take tsx whenever it is installed, and a gate refuses an interpreter chosen from the artifact’s extension. -
defineCronrequired anamethe runtime does not. The loader has always readdefinition.name ?? loaded.id, where the id is the file’s own name — so a cron without one registered happily underrebase devand then failedrebase buildwith “Property ‘name’ is missing”.nameis optional, and documented as defaulting to the filename.
[0.19.0] - 2026-09-07
Section titled “[0.19.0] - 2026-09-07”Changed
Section titled “Changed”-
The scaffold’s imports lose the
.jsextension. A new project’s collections imported each other as./authors.js— an output extension in a.tsfile, on every relative import — and nothing in the scaffold asked for it. Both its tsconfigs aremoduleResolution: "bundler", where extensionless is correct;normalizeEsmSpecifiers’ own docblock calls extensionless “the extremely common style”. The template was the thing out of step.What the extension was really for is one step further on. TypeScript emits module specifiers verbatim, by long-stated policy, and Node’s ESM loader resolves no extensions — so
./authorscompiles and then throwsERR_MODULE_NOT_FOUNDundernode dist/….rebase buildhas always closed that gap for the bundle it writes. Nothing closed it for the plaintscoutput thatrebase ejectruns, or that a self-hoster runs afterpnpm build, so the templates paid the tax in source instead.rebase normalize-imports <dir>is that step, made available to the builds that need it: it rewrites each relative specifier in emitted JavaScript to the file it actually resolves to. Only relative specifiers, only when the target exists, idempotent, and skipping comments — the stockconfig/resources.tsdocuments an import inside a docblock. Both scaffold build scripts run it aftertsc, so an ejected project and a hand-built one get whatrebase buildalready produced.The scaffold’s
backend/tsconfig.jsonalso gains therootDir: ".."its layout has always implied — the program includes../config/**, so the emit already lands atdist/backend/src/…, which is whatscripts.startruns. TypeScript 6 refuses to infer that (TS5011).
check:ejectnever ran what it checked. It typechecked the ejected project withnoEmit, which is blind to the only failure that path has: Node resolving./authorsto nothing at start-up. It now compiles each package with the project’s own tsconfig, runs the same normalization the build runs, and asserts every relative specifier in the emitted output names a file that exists. Mutation-tested by skipping the normalization: 25 dangling imports across both flavours, where the old gate stayed green.
[0.18.1] - 2026-09-07
Section titled “[0.18.1] - 2026-09-07”-
The published driver shipped no CLI, so a new project’s first data read returned 500.
rebase init→pnpm install→pnpm run dev→ register →GET /api/data/postsanswered500 Table not found for collection 'posts'on 0.18.0, and the boot log saidApplied 30 additive schema change(s)— the table was there. The driver looks its columns up inbackend/src/schema.generated.ts, which the scaffold ships as a stub, andrebase devregenerates that file at startup for exactly this reason. It could not: the regeneration shells into the driver’s own CLI, and the published tarball did not contain one.@rebasepro/server-postgresruns four things as child processes — the Drizzle generator, the DDL planner, the introspector, the schema doctor — located by path relative to its CLI. Those paths named.tsfiles, and resolved only becausefileslistedsrc.dff34e8688removedsrcfrom sixteen tarballs to stop shipping the same bytes twice; it was right about the bytes, and it deleted this package’s entire command surface, because nodist/cli.jshad ever been built. Everyrebase db …,rebase schema …andrebase introspectwas broken for every npm consumer of 0.18.0. The parent CLI reports a driver CLI it cannot find as “Dependencies are not installed”, so the message blamed the user’s install — at a project that had just installed cleanly.The build emits
dist/cli.jsand the four child scripts. They still run undertsx, which is not incidental: they load the project’s collection files, and those import each other as./authors.js, the extension TypeScript tells you to write. Node resolves it literally and fails on the user’s code rather than on ours. The two generators are built fromsrc/schema/bin/*, because their ownimport.meta.url.endsWith(process.argv[1])guard is false once the module is a shared chunk rather than the process entry — the built script otherwise exits 0 having generated nothing, and the caller reports success over an unwritten schema.tooling/scripts/test/driver-cli-surface.test.mjsreads the spawn sites out ofcli.tsand requires each one indist, so a fifth spawned script added without a build entry fails on the commit that adds it. Verified end to end against a project scaffolded by the published 0.18.0 CLI:GET /api/data/postsreturns 200.The release pipeline’s new smoke step is what caught this, on its first ever run. 0.17.3 failed the same way and shipped, because nothing installed what had been published.
-
The driver’s build banner made the runtime image’s copy unparseable. Adding entries reshuffled the chunks, and the banner — prepended to every output chunk — declared
import process from "process"in one that also contained chalk’simport process from "node:process". Two declarations in one module:SyntaxError: Identifier 'process' has already been declared, and the driver would not load. It appeared only in the image, from source identical to the local build, because which modules share a chunk is a decision the bundler remakes each time — the local dist parsed fine.processis a Node global and the import bought nothing; it is gone.check:runtime-image:bootscaught it, and a new assertion indriver-cli-surface.test.mjsnow refuses a banner that binds any plain identifier, so the next one is caught on the host without building an image.
[0.18.0] - 2026-09-07
Section titled “[0.18.0] - 2026-09-07”Breaking
Section titled “Breaking”-
defineCollectionis one signature, and its errors land on the field. It was three overloads — Postgres, Firestore, MongoDB — and when a call failed TypeScript emitted exactly one diagnostic, ondefineCollection(, listing each overload’s first failure. So a Postgres collection’s typo reportedFirebaseCollectionConfigandMongoDBCollectionConfigat an author who had named neither engine, and a collection with two mistakes reported one of them, revealing the second only on the next compile.There is now one signature discriminated on
engine(Postgres when absent). Errors are reported where they are, all of them at once: a misspelled key on the key, a badadmin.display.titleon the path, a link field that belongs to another relation kind on that field.Two type exports move with it:
UnknownPropertyKeyis nowNoSuchKey, which carries adidYouMeanmember naming the shape whose keys were valid, andPropertyTypeNotOnThisEngineis new — it is what arelationon a Firestore collection resolves to. Both are internal machinery of the builder; nothing should be importing them, and neither has a runtime representation, so no contract bump. -
defineCollectionno longer stops checking theadminblock when a property is wrong. The builder declaredconst P extends PostgresProperties, and a constraint TypeScript cannot satisfy is one it silently falls back from: a single property with a baddefaultValuemadePcollapse toPostgresProperties, the inferred entity shape collapse toRecord<string, unknown>, anddisplay.title,listProperties,propertiesOrderandpreviewPropertiesall widen tostring. Every check the builder exists to provide switched itself off, quietly, at the first mistake.Pis unconstrained now; exactness and the engine gate are expressed insideStrictProperties, where a violation is one error on one property. -
defineCollectionnow checks a relation against the member itskindselects.Relationhas been a closed union since 0.11 —belongsToownslocalKey,hasOne/hasManyownforeignKeyOnTarget,manyToManyownsthrough,viaownsjoinPath— but TypeScript’s excess-property check runs against a union as a whole, so{ kind: "belongsTo", foreignKeyOnTarget: "author_id" }compiled. It also meant something else: the generator readslocalKey, defaults it to<relationName>_id, and never looks at the column the author named, so the relation resolved to a different link than the one written. The boot validator already refused these; the type now refuses them too, which moves the report from a failed deploy to a red squiggle. Code that carried a link field belonging to another kind stops compiling — it was already failing at boot. -
A relation no longer carries its own
validation.requiredlives on the property. There were two places to write it and they were read by different generators: the Postgres DDL asked the property (so the foreign-key column came outNOT NULL) while the SDK type generator asked the relation (so the generatedInserttype made the field optional). Acreate()that omitted the relation therefore typechecked and then failed at the database with a not-null violation — and getting both right meant writingrequiredtwice, identically.RelationBase.validationandResolvedRelationBase.validationare deleted. Move it up one level, beside every other field’s:author: {type: "relation",validation: { required: true }, // ← hererelation: { kind: "belongsTo", target: () => authors }}The boot validator refuses the old key by name rather than ignoring it: silence would have left the surviving
requiredunset, quietly turning a required relation optional in the generated types and nullable in the column. How every reader now asks isimport { isRelationRequired, relationDeclaringProperty } from "@rebasepro/common":isRelationRequired(collection, relation)for the answer,relationDeclaringProperty(collection, relation)for the property that carries it.The runtime contract major is not bumped. What was removed is an optional member of a TypeScript interface — it has no runtime representation, so no already-built bundle can fail to boot on it.
-
A required
belongsTono longer defaults toON DELETE CASCADE. It isRESTRICT.validation: { required: true }on a relation says the child cannot exist without a parent. It does not say that deleting the parent should delete the child — but that is what the generator inferred, soonDelete, a field nobody has to write, quietly turned everyDELETE FROM authorsinto a cascade through posts, their comments, and anything hanging off those. The default is nowRESTRICT: the delete fails and names the constraint, andonDelete: "cascade"is something an author asks for on purpose. Optional relations are unchanged (SET NULL), and amanyToManyjunction is unchanged (CASCADE— the row it deletes is the link, not the target).This is a DDL change for existing projects. The next
db pushwill plan a constraint rewrite (DROP CONSTRAINT/ADD CONSTRAINT) for every required relation that never named anonDelete, and after it those parent deletes start failing where they used to cascade. To keep the old behaviour, write it down:onDelete: "cascade"on the relation. Review the plan before applying it —db pushprints the statements.All three generators moved together (the
CREATE TABLEDDL, the desired state boot-ensure diffs the live database against, and the generated Drizzle schema), so the default cannot differ between them and make every boot plan the same rewrite forever. -
The Node floor is
>=22.22.0, on every published package. It was>=20(and@rebasepro/cli@0.17.3declared noenginesat all), so a project on Node 20 installed and ran. The single source is.nvmrc, andcheck:floorsholds every manifest to it — one floor, in one file, rather than a number repeated in twenty-two. Move to 22.22.0 before upgrading:pnpm installanswers an engines mismatch with[WARN] Unsupported engineand carries on, so the failure arrives later, somewhere with no mention of Node in it.check:release-bumpnow refuses a release that moves anenginesfield without saying so here. -
rebase.datais gone at runtime, not only from the type.RebaseServerClientdroppeddataso the admin-scoped plane would have exactly one name and the privilege would be visible at the call site — and the property was then left on the object as a runtime alias, which defeats the point: untyped code could still reach the privileged plane by the name that means user-scoped everywhere else. It is deleted at boot;rebase.dataisundefined. Server-side code saysrebase.dataAsAdminwhen it means the admin plane andcontext.datawhen it means the caller’s. Eleven places in the prose across six locales taught the old name, and the scaffold’s always-on agent rule wasalways use the SDK (rebase.data.<slug>)— the one accessor the server omits, in the file an assistant reads before doing anything else. -
@rebasepro/clipublishes three exports, not ninety-five.src/index.tsre-exported sixteen modules —initCommand,dbCommand, the whole ofcommands/cloud,detectPackageManager,findProjectRoot— and became an API because it was published, not because anyone decided it should be one. Nothing imported it: not this repository, not the control plane, which does not depend on@rebasepro/cliat all. Three now, each with a reason:entry, becausebin/rebase.jscannot run without it, and themanifestandbundlecontracts, which are deliberately shared so a control plane can validate a bundle with the code that produced it. -
Peer ranges are carets, and an open range is not a promise.
appandcmsmoved to React 19.2.7 when react-router 8 made it mandatory; the five packages they pull in —ui,forms,firebase,plugin-insights,cms-types— stayed at>=19.0.0, a range whose lower half cannot satisfy the app depending on them, so an installer picking 19.0.0 produced a tree that resolved cleanly and broke at render. They are^19.2.7now.>=19.0.0also claims React 20 compatibility on behalf of a component library, which is precisely the claim that turns out to be false.@rebasepro/app’s owntypescriptpeer moves from>=5.0.0to^6.0.0:src/vitePlugin.tsimports the compiler API to find the callbacks block, which is a runtime import in a published package and not a build-time convenience, and the plugin is written against TypeScript 6’s compiler API —>=5understates it in one direction and promises TypeScript 7 in the other, where that API no longer exists. -
rebase cloud webhooks createtakes--endpoint.--urlnames the control plane for every command in this family —resolveCloudUrlreads it straight off the raw line, ahead of the environment variable and the link file — andwebhooks createdeclared a second--urlfor the customer’s endpoint. The two parses are independent, so the documented example senthttps://example.com/hooktorequireClientas the host to authenticate against: the one command whose whole argument is somebody else’s URL could not run. A test now reads everyspec:handed toparseCloudArgsand refuses any key the global spec already covers, because that spec is spread over the globals and silently replaces one. -
serializeFilteremits strictly. The shared leaf encoder parses liberally, because a short code arrives off the wire, and emits strictly, because a caller handing one to the serializer built the condition by hand and should use the spelling the types name.serializeFilter({ a: ["gt", 5] })throws rather than round-trippinggte. This is the ruleserializeTuplehas always followed; what changed is thatserializeLogicalConditionno longer carries a second, drifted copy of it.
-
rebase db migrate --baseline <version>records a version as applied on a database that already carries the schema — every database Rebase has booted against does, because boot provisions the tables — so the firstdb migrateno longer dies ontype "posts_status" already exists. That error now names the version on disk and the command that records it. -
Every kind in one graph: crons, functions and queues join databases, buckets and topics. A cron file is now also a declaration — the loader records it under the id the scheduler runs it as, with its schedule and zone — and a function is recorded from the bundler’s analysis by filename.
rebase resourceslists them; a host reads a project’s schedules before running it.import { queue } from "@rebasepro/types"joinsdatabase,bucketandtopic:queue<T>("thumbnails")is the work-list shape of background work, one handler per queue, on the same durable job queue topics ride on — and unlikejobs.tasks, which needed an entrypoint a managed project does not have, a declared queue is picked up by every boot path. -
Handles are the API.
defineCollection({ dataSource: analytics })andstorage: { storageSource: media }take the handle a constructor returned — the same name, spelled once — and record its key.rebase.bucket(media)reaches a declared bucket’s storage source;rebase.sql(q, { database: analytics })takes the handle too. The derive step records who uses what (usedByon each graph entry:collection:events,property:posts.cover,function:report), which is the map a console needs for “what breaks if I remove this” and a future split needs to be derived from. -
rebase devserves every declared database.database("analytics")inconfig/resources.tsis the request: the development daemon starts a second PGlite instance for it on demand — without a restart, sorebase studioin another terminal keeps its connection — and exportsDATABASE_URL__ANALYTICS. A variable you set by hand is never overridden.--resetremoves them all. -
A declared object store nothing binds is a local directory in development.
bucket("media", { engine: "s3" })with noS3_BUCKET__MEDIAused to answer 501 on the first upload, which made MinIO a prerequisite for uploading one file. In a development process it now stands in asuploads__media, boot says which engine it stands in for, andrebase statusshows it in yellow beside the tick. Production never does this: an unbound bucket stays unbound. -
Kinds own their binding.
@rebasepro/serverregisters a resolver per kind (registerResourceResolver), and boot,rebase statusand the gate that holds a kind’senvBasesto what the runtime reads all consult the registry rather than a switch. A kind this runtime has no resolver for is refused at boot by name instead of dropped. Adding acacheis a spec, a resolver and a driver. -
Cron
timezone.defineCron({ schedule: "0 3 * * *", timezone: "Europe/Madrid" })reads the schedule in that zone; without it the schedule is the host’s own zone, which is UTC in nearly every container and yours on a laptop. An unknown zone is refused when the job loads. -
rebase cloud resources— what the code declares against what the platform holds, per database and bucket — andrebase cloud resources prune database <key>, the one removal a deploy is not allowed to make. What used to berebase cloud resources(CPU, memory, replicas, cost) isrebase cloud compute: two commands named “resources” that showed different things was a support ticket. -
The bundle-manifest contract.
tooling/contracts/bundle-manifest.jsonis written from the CLI’s manifest composer with fixed inputs, and the control plane’s tests read that same file to prove intake accepts it and the deploy finds every resource in it. Both suites were green for two weeks while every bucket a current CLI declared reached the platform as nothing; this is the test across that seam. -
rebase db branch prune— branching shipped with no cleanup story at all. No TTL, no prune, nodelete --all, and every branch is a full-size copy:CREATE DATABASE ... TEMPLATEduplicates the files on disk, so five branches of a 100 GB database cost 500 GB. The only way to reclaim any of it was to remember every name you had ever typed.rebase db branch prune # orphans only — always saferebase db branch prune --older-than 2w # and anything past two weeksIt also finds the two ways branches drift from their metadata, which drift in opposite directions: an entry whose database was dropped outside Rebase, which
listwould keep reporting forever whileswitchandinfofail against a database nothing can find; and a branch database whose entry was never written, becausecreatemakes the database first and records it second. Nothing expires unless asked, ages are floored so a cutoff never catches something younger than it says, and--older-than 7his refused rather than read as seven days. Atlas’s<db>_dev_diffscratch databases are reported alongside but removed only with--include-dev-diff. -
rebase db branchreported branches the managed database had not made. PGlite serves exactly one database, soCREATE DATABASE ... TEMPLATEwrote apg_databasecatalog entry and copied nothing. Every step then agreed:createanswered✓ Branch "feature_x" created successfully., andlistshowed it at 7.1 MB because the catalog entry makes itsJOIN pg_databasesucceed andpg_database_sizeanswer for the one real database. Connecting torb_feature_xreportedcurrent_database()=postgres, and a table created “in the branch” appeared in the parent — so every write made in the belief that it was sandboxed landed in the developer’s own database. Measured on a freshrebase initscaffold, which is the default path. The wholebranchdomain is now refused there, before the database is started, namingrebase dev --dockerandDATABASE_URLas the two things that work. -
rebase db pullhanded back a database the application could not read.pg_dump --no-privilegesstrips every GRANT, so the copy arrived with the source’s RLS policies and itsFORCE ROW LEVEL SECURITYintact and nothing behind them. Measured on a 30-table project: 68 policies and 60 grants in, 68 policies and 0 grants out, and the first read as the role Rebase serves every request through failing withpermission denied for table leads— after a green✓ Local database now holds a copy of …. Anyone who pulled and then openedpsql, ranrls-check, or pointed a test suite at the copy hit a wall with no hint of the cause. The pull now re-provisions the app role through the sameensureAppRoleboot anddb pushcall, so internal tables stay revoked as well. The backup path already guarded this hazard; the newer command people are told to use did not. -
rebase db pull --database-urlwas accepted and ignored. The flag never reached the resolver, so the pull went ahead against the.envdatabase anyway:rebase db pull --from prod --database-url scratch --yesdestroyed the working database while naming a different one. It is now refused rather than honoured — the target is the local development database by construction, since a command that can copy in both directions eventually copies the wrong way, and the wrong way here is a laptop over production. The refusal points at--from. -
rebase db branch switch— branching stopped one step short of being a feature.createcopied a 12 MB database in 1.2s and then printedDatabase: rb_feature_authand nothing else: there was noswitch, no--branchonrebase dev, noREBASE_BRANCH, and not even a connection string to paste. The only way to work on a branch was to hand-editDATABASE_URL, while the documentation said the CLI updated your local development configuration — it did not, and the.envwas byte-identical afterwards.rebase db branch switch feature_auth # every later command followsrebase db branch switch # which branch am I on?rebase db branch switch --off # back to the main databaseThe branch is recorded in
.rebase/branch.jsonas a name, never a connection string, so credentials stay in.envalone. It outranksDATABASE_URLin.env— any lower and switching would do nothing on a project that sets one — and loses to--database-urland aDATABASE_URLin the shell, so a flag on the command line still beats a switch made yesterday. Deleting the branch you are on returns the checkout to the main database instead of leaving it aimed at a database that no longer exists. -
policy.registered().POST /auth/anonymousmints a real user row with a real uid, so a guest satisfiespolicy.authenticated()— which is the point of the feature, and also means “signed in” was true for anyone who pressed Continue as guest.registered()isauthenticated()plus “not a guest”; reach for it wherever a rule is about somebody who could be held responsible for something. The flag travels in the access token and reaches the database asrebase.is_anonymous(), so a policy can ask without a lookup. -
rls-checkgainedpolicy-authenticated-tautology. Correcting an anonymous tautology by excluding the sentinel is where people stop, and what remains — “every account may read every row” — is the shape that made a customer’suserstable readable by anyone who could sign up. It is a separate id from the anonymous finding on purpose: different severity, different fix, and--skipshould be able to silence one without the other. -
get(id)reads a row that is expected to exist.findByIdreturnsM | undefined, and it was the only way to read one row, so every caller had to prove the row was there before touching a field — or reach for the!that everyone reaches for instead. A row fetched by an id that came from a link, a route parameter or another row is expected to exist: its absence is the error case, not a value to thread through the rest of the function.get(id)returns the row and throwsRebaseApiErrorNOT_FOUND(404) when it is not there;findByIdstays as the explicit maybe-form. Added in all three implementations of the collection surface — the browser SDK, the offline wrapper, and the server-side accessor behindrebase.dataAsAdminandcontext.data— so the shape of “it is not there” does not depend on who is asking. -
Anonymous sessions and MFA in the SDK.
signInAnonymously()mints a real account — an id, roles, a session, and row-level security scoping its rows — andlinkAnonymous(email, password)promotes it in place, keeping everything written as a guest. The MFA routes have been on the server since 0.16 with no client method to reach them; enrolment, verification and the challenge step are onauthnow. Both pair withpolicy.registered()below: a guest satisfiespolicy.authenticated(), which is the point of the feature and also the reason a rule about somebody who can be held responsible needs its own predicate. -
channel.onError, so a refused broadcast is not a delivered one. A channel frame is fire-and-forget, so a refusal about one matched no pending request, no subscription and no channel:CHANNEL_FORBIDDEN,RATE_LIMITED,CHANNEL_HISTORY_WRITE_FAILEDandCHANNEL_BUS_PAYLOAD_TOO_LARGEall fell through into a console warning whileawait channel.broadcast(…)resolved as though the message had been sent. The missing piece was an address: the server names the channel on a refusal about one, and the client routes it to the channel’s own handler. -
rebase doctorchecks the things that break a first run. It compared three descriptions of a schema, which is the right check for a project that works and the wrong one for a project that has never worked — everything that stops a first run happens before a table can be compared. Seven checks. Five need no database and run first: the running Node against the range the CLI declares, two lockfiles in one project, two collections claiming one slug, aJWT_SECRETproduction will refuse to boot on, and the same@rebasepro/*package pinned to different versions across a project’s manifests. Two join the database phase: pgvector missing where a{ type: "vector" }property needs it, and a schema stamp that says this database was provisioned from different collections. An environment error fails the command — a doctor that exits 0 over one is a doctor nobody can gate on — and.envvalues are never echoed, because doctor’s output goes wherever a terminal goes. -
Studio’s Logs Explorer can show you an error. Its ring buffer was filled by one request middleware and nothing else, so the panel rendered a wall of
GET /api/data/posts 200 4ms— every entry atinfo, whatever the request answered — while every error, warning and boot diagnosis the server wrote went to a terminal the person looking at the panel does not have. The request entry now carries the status as its level, the collection, and the code and message the error handler answered with; andloggergained a sink, teed into the ring at warn and above withsourceread off the message’s own prefix. Sinks receive the message after redaction, never before. -
Studio speaks the seven locales the panel ships. Every tool name, group heading and description was an English literal in a panel that translates 900 other keys, so a German reader got a German drawer with an English half. 43
studio_*keys cover the eleven tools, the five groups and the refused and empty states.devViewsadds a tool rather than being discarded:RebaseStudioConfigdeclared it andRebaseStudiodestructured onlytools, then built its own list and registered that — so<RebaseStudio devViews={[…]}>typechecked, registered nothing, and left no trace explaining why. -
A cron that will never fire is visible, and a timeout stops the work. A schedule the scheduler refused — six fields, copied out of a tool that supports seconds, is the common one — was logged once at boot and then gone, and the job is absent from
listJobs(), so “my cron is missing” and “my cron will never fire” were the same picture.GET /api/admin/cronnow carries arejectedarray with the id, the schedule and the reason, counted intoskipped. A job may also declare a timeout, after which the run is abandoned and recorded as such rather than holding its slot forever. -
Boot says where each backend option goes, and warns when it goes nowhere. Every backend page shows
initializeRebaseBackend({ … }), and a managed deployment has no such call — the runtime makes it, reading exactly four names out ofconfig/index.ts. Anything else was dropped in silence:export const storagePoliciescompiled, deployed, and did nothing. Boot now warns when the config index exports an option the managed runtime cannot take, and names where that option does belong. -
A successful
rebase cloud deployprints the URL it deployed to. The one thing the command was for was left to a second command. Resolved exactly asstatusresolves it, so the two cannot disagree about the host, and best-effort throughout: a control plane that does not report a base domain prints no URL rather than a fabricated one. -
Fourteen
rebase cloudgroups gained the--helppage the docs promised them.login,logout,whoami,link,unlink,use,open,rollback,cancel,start,stop,restart,metricsandresourcesall fell through to the index — a list of groups and not one flag — sologin --password,link’s positional URL, the-ythatstoprequires and every resource dial had no discoverable spelling anywhere in the CLI. The pages carry the units and ranges the dials take (--db-mode shared|dedicated, autoscale 1–16, CPU target 10–95) and one sentence that keeps them honest: the prices are the control plane’s quote, not a number written here. Two tests hold it, andresourcesjoins the index it was missing from. -
A headless first run ends in a box.
printSummarywaited for a frontend URL before drawing anything, so a headless project — the shape whose whole first run isrebase dev— got no summary at all, and the managed database’s port is derived from the project path, so nothing on disk said how to reach it. The box is sized to its contents, names Swagger only when the backend says it mounted it, and says why when it did not.rebase db urlis the same answer outside the banner: the resolved connection string on stdout and nothing else, sopsql "$(rebase db url)"works. -
The version handshake the compatibility matrix already described. Three documented signals had a receiver and no sender.
x-rebase-schemahas been listed as “sent by the SDK” since the matrix was written and no client ever put it on a request; the transport takesschemaVersionand sends it now.runtime.versionwas published only on the admin-gated/contract, so the two callers that need it — a CLI deciding whether it is too old, an SDK reporting what it built against — could not ask;/api/meta/schema-version, which is unauthenticated by design, carries it. Andrebase cloudrequests now sendUser-Agent: rebase-cli/<version>, from a singlecliVersion()replacing two divergent copies that read the manifest by counting directories. -
rebase skills installhas a target for every pointer file the scaffold writes.rebase initwrites five instruction pointers andskills.tsknew four agents, neither of which was Codex or Copilot — so a Codex user opened a file their own scaffold had written, followed it torebase skills install, and got a prompt that did not list them.codex,kiroandcopilotjoin the list. The MCP server can show the SQL before running it: an agent asked to change a schema had no way to say what the change would do, sincerebase db pushprinted the planned SQL only while refusing.rebase_schema_plananswers that question on its own, and hands a destructive plan to a human rather than approving it. -
rebase cloud loginwarns where the password just went. A password written as an argument is in the shell’s history file and in the process table for as long as the command runs, and neither is something this CLI can redact afterwards. The flag stays — there is no machine token, so a non-interactive login genuinely needs the password from somewhere — but it warns before the request, not after, and namesREBASE_CLOUD_EMAIL/REBASE_CLOUD_PASSWORDas the route that does not touch the command line.
Changed
Section titled “Changed”-
A bucket can be marked as the default, and the promotion that stands in for that says so.
bucket("media", { default: true })names the bucket that serves uploads which name nostorageSource. A project of named buckets with none marked still gets the first one declared promoted at boot — refusing would have stopped every project written before the option existed at the next runtime rollout — but the warning now names the one line that ends the guessing, and why it matters: the local bucket development stands in with is dropped in production and the promotion is not, so the answer differs either side of a deploy until one is marked. -
rebase cloud deploy --forceis now--eject, with no alias.--forcemeans four different things across this CLI — overwrite a file (schema introspect,apps,eject), disconnect other Postgres sessions (db branch), set a build-time key anyway (cloud env set) — and three of them are recoverable in a minute. The fourth moved a live project off the managed runtime onto a container image it now owns, which is the least reversible thing the CLI can be asked to do, under the same word as “overwrite this file”. It has its own name now, and--forceoncloud deployis an unknown option rather than an alias: a script carrying the old spelling stops instead of ejecting a project on a word it no longer means. What the flag does is unchanged — it is still the only way to build a container image for a managed project, for the bare form and for--sourcealike. -
A collection’s
callbacksruns on the server, and only there. The panel’s own callbacks areadmin.browserCallbacks. The Vite plugin has always stripped that block’s bodies out of the admin bundle, so abeforeSavecalling a vendor with a key fromprocess.envdoes not ship to every visitor — but two keys were exempt, on the grounds that the panel ran them. Both exemptions were wrong, in different directions.afterSavehad no client-side call site at all: the body shipped and never ran.afterReaddid run, unconditionally, on top of the server having already applied it — so every server-backed collection transformed its rows twice, and anything not idempotent compounded. Meanwhile a collection on adirecttransport, where the panel talks to the store itself and no server sees the operation, got read callbacks while its write callbacks were stripped out from under it, silently. The strip is total now, with no allowlist to fall out of date, and callbacks the panel runs live underadmin.browserCallbacks: a separate key, so which runtime a callback belongs to is a fact about the collection file rather than about adataSourcesdeclaration in another one — which is the thing a build-time transform cannot see. Move a browser-sideafterReadinto the new block; a server-side one already worked and needs no change. -
saveEntityWithCallbacksanddeleteEntityWithCallbacksrun callbacks. Both have been named for callbacks they never ran, since they were written, anddeleteEntityWithCallbackswent as far as accepting acallbacksprop and dropping it on the floor. They run the collection’sadmin.browserCallbacksaround the write:beforeSavecan block a save the way the server’s does,beforeDeletecan block a delete, andafterSavereceives the row as saved rather than the values submitted. That prop is gone, as is thecallbacksprop onDeleteEntityDialogthat fed it — both were being passed the server’s block, in the browser, where it does nothing. -
The ERD has one layout, and it reads top to bottom. The LR/TB toggle offered a choice the canvas cannot honour: the visualizer’s pane is tall and narrow, so the left-to-right default pushed the graph off both sides on open. The machinery went with the buttons rather than being left behind.
-
Saving a collection file is the first edit, and now that is what works. Adding a property while
rebase devwas running produced the worst shape a failure can have: everything looked right. tsx restarted the backend, boot’s additive ensure added the column, the panel showed the field — and the first save of a row carrying it answered400 VALIDATION_UNKNOWN_FIELDS: 'posts' has no column 'subtitle', because the driver looks its columns up inbackend/src/schema.generated.tsand nothing had regenerated it. Four voices told the reader four different things to do about it, and the one on screen namedrebase db push— which cannot run at all on the database a scaffolded project uses, since Atlas diffs against a second empty database and PGlite serves exactly one. The watcher does it now: the same idempotentensureGeneratedSchemacalldevalready makes at startup runs on a change underconfig/collections/, and the box says what happened rather than what to type. -
--no-dbstarts no database, including the managed one. The flag gated the branch that starts a docker-compose container and nothing else; the managed PGlite starts on the other branch, inprepareDatabaseEnv, which was called unconditionally. So on a scaffolded project — the one project shape where the managed database is what you get —rebase dev --no-dbwrote.rebase/pglite/, started a daemon and served against it. Two reads of one flag are two things that have to agree; there is one now. With no database prepared the backend is left to fail onDATABASE_URL: is required, which is the failure the flag exists to produce. The README and the Quickstart said--no-dbmeant “bring your own”, which described neither the old behaviour nor the new one: bringing your own is settingDATABASE_URL. -
rebase dev --dockerstarts the container and reaches it. The flag changed a banner line and nothing else: the backend was spawned with noDATABASE_URLat all, and the preflight that starts the container decided “local, and not running” from a DSN it could not find in.env, so it returned before it ever looked atdocker-compose.yml. The URL is derived now —composeDatabaseUrlreads the composedbservice and interpolates${DATABASE_PASSWORD:-changeme}the same way compose does, producing byte for byte the stringrebase initalready writes into.envas the commented-outDATABASE_URL. A compose file that cannot yield one is a--dockerthat cannot be honoured, and it says so rather than falling back tolocalhost:5432and pointing the project at whatever Postgres happens to be on the default port. -
Sixteen packages stop shipping their sources twice.
files: ["dist", "src"]was justified by making stack traces and go-to-definition work for anyone who installs the package. They already did: every.mapunderdistcarries fullsourcesContent. Sosrcwas a second copy of the same bytes —server-postgres7.30 → 5.39 MB unpacked,types1.35 → 0.84,client1.28 → 0.87,common1.09 → 0.76. The"source": "src/index.ts"field goes with it, because it named a file the tarball no longer contains — from all twenty that carried it, including the four (cli,server,codegen,rls-check) whosefilesnever listedsrcin the first place, so the pointer had been dangling for as long as it had existed.check:package-contentsrefusessrc/in a tarball, so this cannot come back one manifest at a time. -
Every package is ESM-only, and now says so. All 21 are
"type": "module"with no CommonJS build, which the README never stated —require("@rebasepro/client")fails withERR_REQUIRE_ESM, a message naming the loader rather than the decision, and no configuration recovers it. Stated in the README, and@rebasepro/mcpand@rebasepro/rls-checkgain theexportsmap their eighteen siblings have, so every file in them stopped being API by default. -
One always-on rule for flat-layout agents, not 84,000 characters. Cursor, Windsurf, Kiro and Copilot load their whole rules directory into every request, so installing 21 skills there put about 84,000 characters of Rebase reference in front of every question a person asked those tools — whether or not the question was about Rebase. An instruction an assistant skims is an instruction it does not follow, so the effect of installing more was to make each skill count for less. Those targets get one rule that points at the rest.
rebase-basics, the skill loaded for every Rebase task, was 929 lines, two thirds of them reference material read once a month; it leads with the recipes now — the sequence for adding a collection, a function, a rule — and keeps the tables behind them. -
rebase initasks npm once for a version it already knew. Every@rebasepro/*package in this repository ships at one version, andcheck:publishable-setfails the build on the first PR after a bump if any of them drifts — so the version to pin was decided beforeinittouched the network: it is the CLI’s own. Asking the registry once per package bought nothing but eleven chances to hang, and offline it was eleven timeouts ending in a fallback to"latest", the one answer that scaffolds a project mixing framework eras. One call remains, to@rebasepro/server, because lockstep is enforced in the repository and not on the registry — a release can still leave a package behind, which is what happened to@rebasepro/agent-skillsacross three of them. A registry it cannot reach is not a release gap: it prints what it pinned and why and carries on. -
The scaffold’s toolchain catches up with the runtime it targets.
@types/nodewas^20in all four scaffold manifests whileengines.nodesays 22.22, so a new project typechecked against the standard library of a runtime it refuses to run on.backend/tsconfig.jsonshippedmoduleResolution: "node"— TypeScript’s node10 algorithm, which does not readexportsmaps — while itsconfig/sibling, whose sources the same program includes, has always usedbundler. And dotenv was pinned a major behind every package in this repository, so a scaffolded project loaded.envthrough a different major than the CLI and the runtime do.check:templatesnow asserts the tsconfig it compiles with is the one the template ships, instead of proving the templates work under a setting they do not carry.
Removed
Section titled “Removed”-
loadDeclaredStorageSources(@rebasepro/server),normalizeStorageSourcesandDeclaredStorageSources(@rebasepro/types). All three served thestorageblock ofrebase.json, which the manifest validator now refuses: buckets are declared withbucket()and the graph is generated intorebase.resources.json. The loader parsed a block that can no longer exist and the merge resolved a conflict between two homes there is now one of. Pre-release, a breaking change is just a change — the runtime contract stays at 1, as it did for the declaration change itself. -
basePathandbaseCollectionPathon<Rebase>. Declared onRebaseProps, documented as “URL prefixes when the admin is not at the site root”, and never destructured inRebase.tsx. Setting either did nothing at all, and the symptom of getting the prefix wrong is a collection view that hangs on a spinner — so the reader who reached for the documented prop got exactly the failure the prop claimed to prevent, with no way to tell it had been ignored. The sub-path recipe isapps.admin.pathinrebase.json, whichrebase buildalready supplies. -
UIReferenceView,UIStyleGuideandCrmDashboardDemoleave@rebasepro/app’s barrel. A file that renders every component in the kit, a token sheet, and a fake CRM with its sample data, in the bundle of every consumer who never opens them — andRebaseRouteDefshardcoded/debug/ui, so the whole reference was a static dependency of the admin’s route table. -
Four agent-launcher manifests that nothing could install.
tooling/rebase-agent-skills/carried a Claude plugin, a Cursor plugin, a Gemini extension and a Kiro power; every one of those installers reads its manifest from a repository root, and these sat five directories down in a monorepo.files: ["skills/"]kept them out of the npm tarball as well, and they were pinned atversion: 1.0.0against a package at 0.17.3. -
pnpm.onlyBuiltDependenciesfrompackages/cli. It is honoured at the workspace root only, so it did nothing there — and it was published to npm as part of the manifest. Worse than inert: it listedesbuild, which the workspace root deliberately sets tofalseunder a docblock explaining that esbuild’s postinstall swaps its JS shim for a native binary and leaves everypnpm exec esbuilddying onSyntaxError: … ELF. The one setting that would have mattered if pnpm ever started reading it was the one that would have reintroduced a build failure the repository had already diagnosed.
-
The
databaseresource kind could not load beside a driver published before 0.17.2.optionKeysgained"extensions"in the literal itself, and a kind literal that has shipped is a wire contract: every published driver inlines this package and compares the shared registry’s entry against its own copy at load. So a runtime from 0.17.2 onward met a 0.17.0/0.17.1 driver, found two differing specs at the same revision, threwResource kind "database" is already registered with a different definition, and refused to boot. The freeze that was later added to prevent exactly this froze the newer literal, which does not make the older one go away — there were already two in the field, and no single literal can equal both.databasenow carriesrevision: 1, so a copy holding either published literal loses to the current definition with a warning instead of throwing.shipped-kinds.test.tsholds every spec this package has published and asserts each one can still meet the current one; removing the revision fails it with the 0.17.0 literal, which is the crash. -
A tenant whose pods could not boot reported healthy for six and a half days. Tenant Deployments run
maxUnavailable: 0, so a failed move surges a new pod and leaves the previous ReplicaSet serving.AvailablestaysTrueandreadyReplicasstays at the desired count — both sum over every ReplicaSet, and both were describing the pod that was not being rolled out. The rollout controller does read the Deployment when a health scrape comes back empty, but it only ever looks at projects named in an activerolloutsrow, so a tenant moved by hand or by a fleet script got no supervision at all. Two of them crash-looped 222 times while every check called them fine.A new
fleet-health-guardcron reads every tenant Deployment on every cluster each ten minutes, regardless of how it got into the state it is in, and judges it by exactly the reading a rollout uses —Progressing: Falseplus the per-template counters, never the sums. A tenant Kubernetes has given up on is named with the image it could not move onto and returned to the ReplicaSet it is actually serving, which ends the crash loop without changing what is served. It stands down while a supervised rollout is in flight, so it cannot race the controller for a tenant that is legitimately moving. -
The alert for tenant application errors could not describe one. Its filter matched every container in a tenant namespace and its label extractors read
jsonPayload.msg,.errorand.logger— CloudNativePG’s field names. The application logger emits{severity, message, timestamp}and has never had amsg, so every notification about an application error renderedError: (null) Detail: (null) Component: (null). It is now scoped to the app container and readsjsonPayload.message; the database half it was silently covering became its own policy, keeping CloudNativePG’s names.The policies lived only in the Cloud console, where no test could see them and no review could read them. They are checked in under
saas/infra/monitoring/with an idempotentapply.sh, and a gate asserts that every extractor names a field the containers it matches actually emit, and that every${log.extracted_label.…}in a notification is a label some condition extracts. -
A collection routed to a second database got its table in the first. Provisioning filtered collections by engine, so
dataSource: "analytics"on a Postgres collection handed it to the default Postgres database: the table was created where nothing read it, the analytics database never got one, and every query there failed on a missing relation behind a boot that reported the schema up to date. Tables and RLS policies are now provisioned per source, and the helper functions the policies call — which arrive with the auth tables on the default only — are created on every other source first. -
Two local buckets shared one directory. Every local source without its own
STORAGE_PATH__<KEY>resolved to the same base path, so a file uploaded tomediawas readable through the default source. A named source now getsuploads__<key>; the default keeps the plain path, so nothing an existing deployment wrote moves. -
REBASE_TOPIC_URLwas a variable nothing read. The topic kind advertised it as a binding andrebase statuslisted it. The gate now covers every registered kind, so a phantom name fails a build. -
Blank variables are unset. A value of three spaces was a bucket name.
-
The scaffold and the docs named variables nothing reads —
STORAGE_BUCKETin bothresources.tstemplates,STORAGE_REGION__MEDIAandREBASE_DB_POOL_MAX__ANALYTICSin the multiple-sources page. Fixed to the names the resolver reads (S3_REGION,DB_POOL_MAX). -
rebase status— what this project declares, and whether it is configured. The model a developer has to hold is three files:rebase.jsonsays where the code is and who runs the server,config/resources.tssays what the project needs, and the environment says how to reach each thing. Everything else —rebase.resources.json, the bundle manifest — is generated from the middle one for readers that cannot run your code.None of that was visible in one place.
rebase resourceslisted declarations, the variables lived in a.env, and the rule joining them was a suffix convention you had to know. So the question people actually arrive with — why does uploading tomediaanswer 501 — could only be answered by deriving the variable name by hand. It is now printed, per resource, with the consequence spelled out:buckets✓ media s3 · account:minio✓ S3_BUCKET__MEDIA✓ S3_ACCESS_KEY_ID__MINIO (shared, for S3_ACCESS_KEY_ID__MEDIA)○ exports s3· S3_BUCKET__EXPORTS not set└ declared, not configured — uploads here answer 501It shows three things nothing showed before: which shared-account variable a bucket is actually reading, a source that is declared but not configured (before a 501 in production rather than after), and a
localbucket, which resolves happily and is dropped in production because a container’s filesystem is erased on restart.The verdicts come from
resolveDataSourcesandresolveStorageBackend— the functions that run at boot — rather than from a second implementation of what “configured” means, which would eventually reassure someone about a deployment that is about to refuse to start. -
A second collection claiming the same
slugor table was dropped without a word.CollectionRegistryregisters by slug and by table name, and returns early when the table is already taken — so the second file’s routes never existed, its relations resolved to the other collection’s rows, and the file sat inconfig/collectionslooking loaded. Boot now fails, naming both files:• posts2 collections declare `slug: "posts"`: posts.ts, blog_posts.ts. …The file names come from the loader, which is the only thing that has them; passed through
ValidateCollectionConfigOptions.sources, and falling back to the collection’s index for callers that validate an array they built themselves. The table is resolved withgetTableName— the same function the registry and both generators use — so two slugs that derive the same table are caught as well as two that declare it, and a duplicate slug says it once rather than twice. -
A typo inside an
adminblock was the one config mistake with no signal at all. The boot validator checked the collection’s keys, each property’s keys and each relation’s keys — and then stopped at the edge ofadmin, on the reasoning that the block belongs to@rebasepro/cms-typesand the panel adds to it. But the lists to check against,ADMIN_COLLECTION_KEYSandADMIN_PROPERTY_KEYS, live in core so that core can read them, and@rebasepro/cms-typestype-checks both against the option types. Soadmin: { multilne: true }booted clean, rendered a single-line input, and left nothing to find.Both blocks are checked now, as warnings under the existing
REBASE_STRICT_COLLECTION_CONFIGpolicy, and every unknown-key message — at any level — carries the near-miss when there is one: “multilneis not a known propertyadminkey and is being ignored. Did you meanmultiline?” The suggester is the CLI’s, moved into@rebasepro/utilsasisNearMissandsuggestNearMiss.Both key lists now assert completeness against the option types in both directions, at compile time. The reverse direction was believed to have no type-level expression and had never been checked; on its first run it found two real options that had never been listed —
formaton a number property andrenderInFormon a relation. Left unlisted, they would have made this new warning fire on correct config, which is how a check earns its way into being switched off. -
Two enum entries with the same
idsilently removed the enum. The ids are the labels of a Postgres enum type, so a duplicate makesCREATE TYPE "posts_status" AS ENUM ('draft', 'draft')— which Postgres refuses with23505onpg_enum_typid_label_index. Boot treated every unique violation on apg_catalogindex as a lost race with a peer pod, skipped the statement, and carried on: the type was never created and the column became plainTEXT. The config said “one of these three” and the database accepted any string, with nothing in the log.The config validator now rejects a duplicate or blank enum
id, and a blanklabel, naming the property and the index of the entry; a repeated label is a warning, since two options that read the same is a panel problem rather than a database one. Andpg_enum_typid_label_indexis off the duplicate-object-race allowlist, so a config that reaches the database with one fails loudly. The concurrent-boot case that allowlist exists for is untouched: two pods adding the same label race onALTER TYPE … ADD VALUE, which raises42710, and the generator writesIF NOT EXISTSanyway. -
rebase resources --checkfailed every project that declares nothing. A backend has a default database and a default bucket whether or not anyone says so, and a project with no declarations has nothing to record — but the check demanded arebase.resources.jsonsaying exactly that, and reported its absence as stale. This surfaced the moment the check was put in a gate: it failed this repository’s own reference app, which declares nothing, and would have failed every scaffolded project until someone declared a second bucket. -
Nothing ran
rebase resources --check. Not CI, not a package script, not another gate — while the comment introducing it said it “is what keeps it honest”.rebase buildrewrites the file, so a project that builds is honest by construction; aruntime: "custom"project never runsrebase build, because it builds its own image. So for exactly the projects where the committed graph is the only record of what they need, it could drift in silence.pnpm check:resource-graphsnow runs it over every installed project in the repository, in CI. -
A bucket’s shared credentials worked on the managed runtime and silently did nothing in an ejected one. Turning a declaration into a source definition was two field-by-field maps —
graphToStorageSourcesin@rebasepro/server, which the managed boot path uses, anddeclaredStorageSources()in@rebasepro/types, which an ejected project’s entrypoint and the frontend use. The server’s copy carried a bucket’saccount; the types copy dropped it. Sobucket("media", { account: "minio" })foundS3_ACCESS_KEY_ID__MINIOon the managed runtime and found nothing afterrebase eject— the source was skipped as unconfigured and every upload to it answered501 STORAGE_SOURCE_NOT_CONFIGURED, having never asked for the credentials the project had set. There is now one mapper, and a test that the two readers return identical definitions. -
An ejected backend ignored every database but the first. The emitted entrypoint was one hardcoded
createPostgresDatabaseConnection(env.DATABASE_URL), so a project declaringdatabase("analytics")ejected into a server that never opened a second connection: collections routed there fell back to the default driver and their rows landed in the wrong database, behind a boot that logged a warning and a health check that stayed green. It now usesresolveDataSources+initializeDataSources— the same resolvers the managed runtime uses — binds one bootstrapper per declared database, and closes every pool on shutdown rather than only the first. -
bucket({ engine: "s3" })threw “a bucket needs a non-empty key”. The options-only formdatabase()has had since it shipped was missing here, so configuring the default bucket meant writing the internal sentinelbucket("(default)", { … }), and passing options where a key belongs failed with a message naming neither the mistake nor the fix. -
The bucket and database kinds advertised environment variables nothing reads.
ResourceKindSpec.envBasesis what a generator or control plane binds from, and the bucket kind namedSTORAGE_BUCKET,STORAGE_ENDPOINT,STORAGE_REGIONandSTORAGE_PUBLIC_URL— none of which the runtime has ever read — while omittingS3_ACCESS_KEY_IDandS3_SECRET_ACCESS_KEY, without which a bucket cannot be reached at all. The database kind namedREBASE_DB_POOL_MAXfor a resolver that readsDB_POOL_MAX. Both lists now matchboot/sources.ts, and a new test holds them to it by reading the resolver’s own source. -
A test’s registry reset was a silent no-op.
storage-account-scope.test.tscalledresetResourceRegistry?.(), which is not an export — so the optional call did nothing and the test ran against whatever an earlier test had declared. -
Every
rebase dbfailure exited 1 without saying anything. The driver’s entry point ended in.catch(() => process.exit(1)), which discarded the error. So a branch that could not be created, a name Postgres would silently truncate, a duplicate, a branch that was not there — each printed its header and then nothing, with an empty stderr:$ rebase db branch create feature_auth🌿 Creating database branch...Name: feature_auth← nothing. exit 1.This was the whole
dbnamespace, not onlybranch. The messages existed and were carefully worded; none of them had ever reached a terminal. A child process that already wrote its own diagnosis through inherited stdio still stays quiet, and a bare DrizzleFailed query:wrapper now carries the PostgreSQL error it hides incause. -
rebase db branch infoexited 0 for a branch that does not exist. It printed✗ Branch "x" not found.in red and reported success, whiledelete— answering the same question — exited 1. Sorebase db branch info "$b" && deploy_against "$b"ran the deploy against a branch that is not there, with the reason already on the terminal. -
rebase db branch create alpha betacreated a branch calledalpha. The extra word was discarded silently, so an unquoted name (create my feature), a flag written without its dashes (create feat from main), or a shell splitting a token you thought was one all succeeded and made a branch you did not ask for — under a name you then type to switch, delete, or point a deploy at. Words the command cannot account for are now refused before anything connects, with a suggestion of the hyphenated name you probably meant. -
A branch could not be created while the dev server was running, and would not say why.
CREATE DATABASE ... TEMPLATEneeds the source quiescent, andDatabasePoolManageronly disconnects pools inside the process doing the work —rebase db branchis its own process, so arebase devin another terminal was never touched by it. Wanting a branch and running the app are the same moment, so this was the common path, and the advice on failure was “close other clients and try again” for the one case where you do not know what is connected. The failure now lists the sessions byapplication_name, and--forcedisconnects them for you, on create and delete alike. -
Creating a user in the panel never showed the temporary password. The server mints one, returns it beside the columns on the create response, and will not repeat it; the dialog that shows it to the administrator was installed as an
afterSaveon the collection’scallbacks— the server’s block, which nothing in the browser runs and whose bodies the bundler strips on the way in. So the callback was never called, by anything, and the credential was returned to a panel that dropped it. It is injected ontoadmin.browserCallbacksnow, written to both the block and its flattened form so re-resolving a collection cannot undo it. Resetting a password was never affected: that dialog fetches and renders the result itself. -
Four of the seven showcase cards on the homepage rendered blank. A key sweep on 2026-09-02 deleted the strings for the presupuestos, Prospector, Unfeigned and Edith cards because the carousel builds its keys dynamically and the sweep only saw literal
t("…")calls. The four locales get their copy back, and the built page no longer carriesalt="undefined …". -
rls-checkreported clean on a table the whole internet could read. Thepolicy-anonymous-tautologycheck bailed out whenever it saw the literal<> 'anonymous', so a policy guarding against'anon'instead fell past the bail — and then no longer matched the bare null-test shape either, so the check returned nothing at all.rebase.uid() IS NOT NULL AND rebase.uid() <> 'anon'reads as “signed in”, is true for every anonymous caller, and was scanned and passed. The guard is now parsed rather than string-matched: the expression is split onANDcounting parens,<> ALL (ARRAY[…])andNOT IN (…)are read as the exclusions they are, and a policy only clears when it excludes an id a signed-out caller can actually arrive with. Excluding some other literal is now the loudest finding of the three, because it is the one that survives review. Severity also rises a step forALL,UPDATEandDELETEpolicies, where the same predicate decides who may write. -
A bundle that vendors its own
node_modulesbooted two copies of the framework. New pods crash-looped withCould not load the database driver "@rebasepro/server-postgres": Resource kind "database" is already registered with a different definition— while the driver was installed the whole time. The process held the image’s@rebasepro/typesand the bundle’s vendored copy;registerResourceKindkeys offglobalThisdeliberately, so both registered into one registry, and the two specs for kinddatabasehad diverged by a singleoptionKeysentry. It threw during the driver’s import, so the driver got the blame. The dedupe that exists to prevent this was unreachable on three of the four paths into a running pod. (#38)The shipped kind literals are frozen at their 0.17.3 bytes now, because the copy that compares them is inlined in every published driver and is in the field. What a kind binds from is a per-copy overlay instead —
import { amendResourceKind } from "@rebasepro/types"— which an older inlined copy never sees and therefore never compares. -
zodwas missing from the runtime-provided list, so a managed app ran no crons and reported success. A bundle shipped its own zod beside the image’s;loadEnv({ extend })then parsed a schema built by a different module instance, and every field carrying a.default()was rejected. Nothing in that failure mentions zod. Added to all three lists, and the agreement test that should have caught it no longer filters to@rebasepro/*— which is why a non-scoped entry could diverge for four releases unseen. The runtime also re-exports its own copy —import { z } from "@rebasepro/server"— so anextendschema cannot be built from the wrong one;loadEnvrefuses a foreign schema by name rather than validating half of it. -
Every failure on a first cloud deploy said what it was, or said nothing. The boot ensure built its message from
err.message, which for drizzle isFailed query: <sql>and not one word of Postgres’s answer — the SQLSTATE, message, detail and hint all sat in.causeand were discarded. Four unrelated boot failures read identically. Fixed here and in the RLS-policy sibling, where the swallowed reason explains a table that is now denying every request. Also:storage create404’d becauseinvoke()percent-encoded a function name the CLI had folded a project id into, sostorage-provision%2F<id>matched a route that had been live for six weeks. -
A restored, empty scroll entry asked the API for
limit=0.initialItemCountfell back with??, which catchesnullandundefinedbut not0— and0is exactly the value that means nothing was restored. Any filter combination matching no rows saveddata: [], so returning to that view renderedInvalid limit: 0instead of a table, and a client bug wore an API failure’s clothes. (#37) -
The default scaffold’s first data read returned 500.
rebase initwrites noDATABASE_URL, sorebase devtakes the managed PGlite path — and nothing on that path replaced the template’s stubschema.generated.ts(export const tables = {}). The database was fine: boot created every table,/healthanswered 200 and auth worked, while everyGET /api/data/*returnedTable not found.rebase devnow generates the Drizzle schema before starting anything, whichever database is behind it. -
rebase db pushagainst the managed database failed twice over. Firstpq: SSL is not enabled on the server, because PGlite’s socket server speaks no TLS — its remedy box then told the reader to appendsslmode=disabletoDATABASE_URL, the variable that is unset precisely because the managed database is in use. Past that, Atlas needs a second empty database to diff against and PGlite serves exactly one. The managed DSN now disables SSL, andpush,generateandmigratestop up front on that database with the two things that do work. -
A
--headlessproject read as misconfigured on a clean boot. The source bundle’s manifest declaredentry.collectionsandentry.schemafrom the conventional layout whether or not those paths existed, so a correct project warned that a file “does not exist” and that no tables would be created. The manifest now states what the project has. -
/api/data/*answered a plain-text 404 when a project served no collections. The surface was not mounted at all, so Hono’s default replied — which reads as a wrong URL when the truth is that there is nothing to serve yet. It now returns a JSONNO_COLLECTIONS404 saying tables must exist first. -
The welcome email was in Spanish, and linked the wrong port. Subject and body, on an English project, while every other template is English. And
rebase devleft the frontend port to Vite while the backend was started with the scaffold’s fixedFRONTEND_URL=http://localhost:5173, so the link named a port the app was not on. The frontend port is now derived per project, like the backend’s, and handed to the server that builds the link. -
Twenty lines of ERESOLVE before the CLI printed anything.
@electric-sql/pglite-socketpins its peers to exact versions per release, so^0.2.9floated to 0.2.11, which demandspglite-pgvector0.0.9 while the CLI asked for 0.0.7. The family is pinned exactly. -
rebase --debug db branch switch featurereported success and left the checkout on the main database.switchwrites a per-checkout pointer the CLI owns, and the dispatch found the word atrawArgs.slice(2)[2]— which assumes nothing precedes the command words.--debugis whatbin/rebase.jsprints after every failure as the thing to re-run with, so it is the likeliest token to precede them. The command missed the CLI’s branch, went to the driver, and came back reporting success; every laterdev,pushand backup then ran against the wrong database believing it was the branch — the exact failure branching exists to prevent, announced as a success. Same class, swept:refuseAtlasOnManagedDatabaseandrefuseBranchOnManagedDatabaseread the domain at a fixed index and stopped refusing, and the driver reads its domain out ofargs[0], sorebase --debug db pushanswered “Unknown domain command: –debug”. A leading flag is moved after the command words rather than dropped — dropping is the same bug facing the other way, and it would lose--database-urlin silence. -
rebase db backup listcreated a backup.listwas a positional the local command parsed permissively and never read, so the wrong guess wrote a dump instead of reading one and left a file retention then has to reason about — whilerebase cloud db backup listdid what it says. Both spellings list now. In the same shape:--outputis canonical and--outis the aliasbuildandcloud env pullalready carry, butdb backuptook only--outandgenerate-sdkonly--output, so the wrong one was silently discarded and the default path used. -
A misspelled flag is an error, not a silently discarded value.
arg(…, { permissive: true })does not relax parsing — it moves an undeclared flag into the positionals, and nothing on these command lines reads a positional:rebase db push --alow-destructive pushed with the destructive gate shutrebase schema generate --ouput x wrote the default path, in silencerebase generate-sdk --ouput ./sdk same, and the next build imported itOne spec per command in
cli-flags.ts. The driver keeps its check at its own entry point rather than in those parsers, which is load-bearing:db pushanddb generatere-enter the schema and DDL generators with the db line, so a strict parser inside either would reject--allow-destructiveon a line where it is correct. -
rebase schema generate --helpregenerated the schema.cli.tsrewrote the subcommand to--helponly when the user named none, so the flag travelled into the driver, whoseschemaCommandhas no--helpcase, and it ran the generator — overwritingsrc/schema.generated.ts.introspect --helprewrote the collection files. It also hitrequireProjectRootfirst, sorebase schema introspect --helpin an empty directory exited 1 with “Could not find a Rebase project root”: help you cannot read until you already have a project. Answered before the root lookup and before the spawn now, with a page per subcommand, and the driver keeps its own second line of defence because it is spawnable on its own. -
A mistyped command answers in one line, with the correction. Six families each answered a mistyped subcommand differently, and five of them dumped the whole help afterwards — which pushes the line that says what happened off the top of a CI log.
telemetryprinted only the help, so a typo was indistinguishable fromrebase telemetry --help. None offered the correction, which for a typo is the entire answer. One helper now: the near miss when there is one, where to read the rest, on stderr, exit 1 — Damerau-Levenshtein because a transposition is the commonest typing error and plain edit distance scores it 2, a tight budget becausepushandpullare two edits apart and one is the destructive neighbour of the other, and an abbreviation completes only when it names exactly one command, soresnever picks betweenresetandrestore. -
The top-level help lists the commands that were only in the source.
db pull,db stop,db reset,db branchandschema staleall worked and appeared in no help page anywhere — and those are the recovery commands, the ones you go looking for at the moment you are least able to go source-diving for them. The guard reads the commands out of the dispatch — thecaselabels, the subcommand comparisons, and the driver’s ownVALID_ACTIONS— so a command added without a help line is a test failure. -
bin/rebase.jsstopped colouring pipes, and a typo stopped offering a stack trace. Its three stderr writes happen before the bundle is imported, so they never went through chalk and hard-coded\x1b[31m: the escapes landed in every redirect, every CI log and every agent reading a failed command. NowNO_COLOR/FORCE_COLOR/isTTY, the same three chalk honours. And a usage error no longer ends with “Re-run with--debugfor the stack trace” — the stack points at the argument parser, and the suggestion is to add another flag to the command line we have just established is the problem. -
A child process that never started said nothing at all.
schema.tsanddoctor.tsended their spawn incatch { process.exit(1); }— the error not even bound — sorebase schema generateagainst a broken tsx exited 1 with no output, and so didrebase doctor, whose entire job is to say what is wrong.db.tswas supposed to be the model and had the same hole for a subtler reason: it filtered execa’s message with/Command failed|exited with code/i, and execa prefixes every failure that way — including the spawn failures the filter was written to let through. A child that ran has a numericexitCodeor a signal; a child that never started has neither. One helper, that rule. -
Six commands had three wordings for “dependencies are not installed”, and none of them said what to run. “Could not find CLI entry point for @rebasepro/server-postgres”, “Could not find tsx binary”, “Could not find tsx binary for backend” — all three mean
node_modulesis missing, and all three read as Rebase being broken. Now: “Dependencies are not installed — runpnpm installin”, with the package manager taken from the project’s lock file and the directory named, because the working directory is usually backend/orfrontend/where the install would create a second, wrongnode_modules. -
Six things a first run showed you, and none of them were true.
--yessays no to git init and to installing, which is the right behaviour for CI and the opposite of the interactive defaults, and--helpnow says so rather than leaving the reader to discover it from an emptynode_modules. Offline, every version lookup failed and the release-gap refusal fired — “Rebase X is not fully published to npm … not a problem with your machine, your network, or your package manager” — when every package being unreachable is exactly a connectivity failure.index.htmllinked a/favicon.icothat the template does not ship and declared itimage/svg+xml, so every first page load logged a 404.generated/is written byrebase generate-sdkand is now ignored, since a committed copy is stale the moment a collection changes. The summary box named the admin URL and not the API, which is what every SDK client, curl and Swagger link needs, and the two ports are derived separately so one is not derivable from the other. Andrebase.jsonspellednpm run build --workspace frontendin a pnpm project;initrewrites it from the detected manager. -
A warning that was always there. Every first
rebase devon a fresh project warned that.env’sPORTandVITE_API_URLwere being ignored — about a file the developer had not opened yet, and both halves were wrong.PORT=3001is copied verbatim out of the scaffold’s own.env.example, so a value equal to the scaffold’s default was written by the scaffold, not chosen by anyone.VITE_API_URL=ships empty and should never have been named at all: the reader was^\s*KEY\s*=\s*(.+?)\s*$with/m, and\smatches a newline while$matches at every line end, so the leading\s*ate the line break and the capture returned the next line. An empty variable read as whatever was written under it.readEnvValuematches horizontal whitespace only. -
--no-installwas in both the README and the Quickstart, and was not a flag.arghas no negation, so the second half of--install/--no-installwas not “the default, redundantly stated” — it was an unknown option, andrebase init app --yes --no-installexited 1 on a line copied straight out of the docs. It is an explicit “no” now: it suppresses the interactive install question too, and wins over--installwhen both are passed. And the example script the scaffold ships is a script that runs —scripts/example.tsis in the base template while its runner and every one of its imports were in the baas overlay only, so in the default scaffoldpnpm examplewas a script that did not exist. -
A boot failure names the database, the port and the reason.
loadBootEnvrestates aZodErroras a list of variables to fix, and its “this one is simply missing” branch testedissue.message === "Invalid input"— zod 3’s wording. Under zod 4 the message carries the types and thereceivedfield is gone, so nothing had matched since the upgrade and the friendliest line in the boot output was the validator’s own, verbatim. Alongside it: a failed connection now says which database, on which port, and why, andrebase devsays which port was already in use and which one the server moved to — and says when the backend died, instead of leaving “Press Ctrl+C” on screen against a process that is gone. -
A 404 on a row says which row, and names row-level security. “Entity not found” was the entire message on five routes. It does not say which collection, which id, or — the part that costs the most time — that a row can be present and invisible: authenticated requests run as a restricted role, so a
SELECTpolicy that excludes this caller produces exactly this 404. Somebody checks whether the row exists, finds it in psql, concludes the API is broken, and spends the afternoon in the wrong file. The collection and the id are both in the URL the caller just sent, so naming them back reveals nothing. -
Five more messages that named nothing. “Parent table not found” appeared four times in
RelationServicenaming neither the collection nor the table, and the absence has two causes worth telling apart — the collection is not exported fromconfig/collections/index.ts, or the generated schema predates it. “Junction table not found:” had the name and not the diagnosis. “Default driver not initialized by bootstrappers” left the reader’s next question — which one — unanswered, and in a multi-source configuration that is not obvious. serve-spa.tskeeps its bareNot foundbodies: two of the three are a forbidden path answered as a missing one on purpose. -
One log line per failed request, carrying the user and the collection. A failure produced two lines, each holding half of it: the error handler had the code and the diagnosis and nothing about who asked, and
requestLoggerhad the user, the status and the latency and nothing about what went wrong. Correlating them meant matching on the request id, for twice the volume and less than one line’s worth of meaning. The handler leaves its half on the context and stays quiet wherever a request line is coming; where nothing claimed one — a project mounting routes onto its own Hono app — it still speaks. -
LOG_LEVEL=warnsilenced everyconsole.login the process.utils/logging.tsreassignedconsole.debug,console.logandconsole.warnto no-ops, irreversibly, because the originals were discarded rather than saved — so a line that ships in the scaffold’s own.env.examplemuted a dependency’s output, the project’s own debugging, and any CLI report running in the same process. Deleted. The structured logger is the only level authority now: it filters its own lines and touches nothing else, and the level is read per line rather than captured at construction, since the singleton exists from the first import, long before any project configuration has been read. -
The root app answers the JSON envelope when anything throws, so a failure before the API router — a body too large, a malformed URL, a middleware fault — stops arriving as Hono’s own HTML. And a failed write keeps the SQLSTATE that says why it failed: drizzle’s
err.messageisFailed query: <sql>and not one word of Postgres’s answer, which sat in.causeand was discarded, so four unrelated write failures read identically. -
rebase doctorno longer ends when it cannot reach the database.checkCollectionsVsDatabasethrew, the error escapedrunDoctor, and the CLI’s last-resort catch printed “Doctor failed” over a drizzle stack — so the two phases that need no database at all, the generated schema and the SDK types, never ran. The database phase is a skipped phase with the boxed diagnosis above it now, and the run continues to the verdict.blockedseparates the two ways a phase can be skipped, because “you did not setDATABASE_URL” is a situation somebody may be content with and “theDATABASE_URLyou set refuses connections” is not — the report renders them identically and the exit code does not, orrebase doctorgoes green in CI against a database it never reached. -
The dev secrets were called ephemeral when they were not. A generated
JWT_SECRETis written to.envand survives every restart; boot announced it as regenerated each time, which is the one property that decides whether yesterday’s tokens still work. -
CORS_ORIGINSis read in development too.resolveCorsOriginreturned early outside production and never looked at the allow-list, so the variable did nothing in the environment where it is most often needed: a phone on the LAN, a colleague’s machine, an ngrok tunnel, a forwarded Codespaces port — every one is a non-localhost origin, every one was refused, and the variable that names the fix had no effect on it. Development allows localhost plus the list now. -
Nine places the SDK envelope and its unwrapping disagreed with themselves.
RebaseApiErrornow carriesrequestIdandretryAfterSeconds— both were on the wire and dropped, so a bug report could not quote the one string that finds the server-side line and the offline queue’s backoff ignored a server that had said exactly how long to wait. The auth middlewares answer througherrorHandlerinstead of seven near-copies of the envelope written by hand, and the handler is called rather than thrown to, becausedefineFunctionhands users a Hono app and a throw with noonErrorat the mount point is a 500 where a 401 was meant.client.call()returns the body verbatim likefunctions.invoke(), instead of unwrapping a top-leveldatakey and making the documented shorthand return a different value. A second.where(or(…))now ANDs with the first instead of replacing it — it was the one call on the builder that made a query match more rows.update()takesWriteOptions, on the verb where a lost response is most likely to be retried into a second applied edit. And the deadcollection_patchhandler is gone, with its own drifting copy of the cache-merge rules. -
pageandoffsetare validated, likelimit. A negative or non-numeric value reached the query builder, where it became either a Postgres error about a bound parameter or a silently wrong window. -
count()de-duplication is per client, not per process. The in-flight map was module-level and keyed on a path plus a query string, which says nothing about who is asking — so two clients in one process counting the same collection shared a single request and the second was answered with the first one’s total. That is not a stale number, it is somebody else’s: row-level security makes an admin panel’s count and a signed-in user’s count of the same collection genuinely different answers. -
The server being down is a
RebaseApiErrortoo. The class’s own docblock says acatchblock only ever needs to check for this one class, and it did not cover the failure every app meets first — a refused connection, a DNS failure, CORS, an abort — which came out as whatever the runtime’sfetchfelt like rejecting with.e.statusande.codewere undefined on the error most likely to have a branch written for it. -
The fluent builder takes what
find(params)takes. Two spellings of one API, and the chainable one was a subset, so a caller who started with.where()discovered it could not express the query the object form could. Every gap was documented, worked over HTTP, and was a compile error on a generated SDK — which is to say on every project that took the trouble to be typed.listen,listenByIdandcountare part of the contract, not optional members: optionality was answering two questions at once, the SDK landing page’s own Quick Example did not compile understrictNullChecks, and the realtime page had to write.listenById!(. -
One leaf encoder for filters, so a group means what it says.
serializeLogicalConditioncarried its own copy and the copy had drifted fromserializeTupleon every rule the top-level codec had already been fixed for. Each divergence is a filter that runs, returns rows, and answers a different question than the one asked, with no error anywhere:nullwent out as the four-character string, sodeleted_at.eq.nullwas a 500 on a timestamp column and silently the wrong rows on a text one. -
An
afterReadthat returns nothing no longer erases the row. The collection tier fell back with?? fetchedand the global and property tiers did not — so the same callback, one that mutatesrowand returns nothing, worked when registered on a collection and replaced every row withundefinedwhen registered globally. Three call sites, one per read path. -
A
beforeDeletethat returnsfalseanswers 403, not 204. It is typedboolean | voidand documented as “return false or throw to block deletion”. Returningfalsedid stop the delete — and the route then answered204 No Content, which means the row is gone: the panel removed it from the list, clients dropped it from their caches, and the next reload brought it back. A veto that reports success is worse than no veto. -
A write from an
afterReadis a 409 that names the callback. Request-scoped reads open their transactionREAD ONLY, so a write from anafterRead— or from anything it calls — is refused by Postgres with SQLSTATE 25006, and that arrived as500 Internal Server Error, which reads as the database being down rather than as the reader’s own code being refused. 25006 maps to409 READ_ONLY_TRANSACTIONnow. -
afterSaveErrorgets the error.AfterSaveErrorPropsnever declared it and neither driver passed it, so the handler the guide has always shown compiled and loggedundefined. The caller’s realidcomes with it (it was the string"unknown") and so do thepreviousValuesthe pre-write read already had (they were hardcodedundefined, hiding an update’s before-state). -
rebase.emailis there even when nothing configured mail.RebaseServerClientdeclaresemail: EmailService, not optional, and the property was simply absent whenever the backend booted withoutauth.email— so a cron or a custom function written against the type compiled, deployed, and died on “Cannot read properties of undefined (reading ‘send’)”, a stack trace about a language feature rather than about the thing nobody set up. A stand-in is always attached; itssend()throws a sentence namingSMTP_HOST,auth.emailand the dev sink, andisConfigured()is false. -
rebase devwatches the directories the runtime scans, not only what it imports. tsx restarts on a change to something the entrypoint imported; functions and crons are not imported, because the runtime scans their directories at boot. So a newbackend/functions/new.tswritten whilerebase devran stayed 404 until somebody restarted by hand, and a new cron never registered at all. -
loadEnv({ extend })refuses a schema built by another copy of zod. A managed bundle that installed its own zod ran with two copies loaded,.merge()accepts the schema because the shapes are identical, and.parse()then rejects every field carrying a.default(), because a default is recognised by class identity. The deploy came up, reported success, and ran zero crons — and nothing in the failure mentioned zod. It is refused by name now, with the remedy. -
The RLS editor saves through plan/apply, like everything else. Two doors onto one file, behaving differently: editing a policy in the collection editor planned the change, showed the SQL, asked, and applied it, while editing the same policy in the RLS editor POSTed the rules to
/schema-editor/collection/saveand stopped — the source changed, nothing said what the change implied, and the database was left to a laterdb push. A policy declared and not enforced is the worst thing this editor can produce, and it was what it produced by default. -
A refused listing is not an empty one. Backups, Cron Jobs, API Keys and Branches each caught a failed list, opened a snackbar, and left their list empty. Four seconds later the toast was gone and the screen read “No backups found yet”, “No Cron Jobs Registered”, “No API keys yet”, “No branches yet” — statements about the project, made on the strength of a request the caller was refused. Anyone arriving after the toast was told a database with backups has none. In the same pass: the schema visualizer stops waiting for collections it has —
undefinedis the Studio bridge before the admin’s registry has registered itself and waiting is right,[]is that registry saying there is nothing to draw, and a project that declares no collections sat on “Loading schema…” for as long as anyone left it open — and the empty states name things the reader can actually find, rather than adocs/backups.mdpath that exists in this repository and in nobody’s project. -
Core hooks say which provider is missing. Five contexts defaulted to
{} as Whatever, a lie the type system agreed with:useData()called outside<Rebase>returned an empty object and the failure surfaced one call later as “data.collection is not a function” inside some component’s render — no hook named, no provider named, and a stack pointing at the wrong line. They default tonullnow and each hook throws with both names. -
The demo’s
useAuthflashed signed-out on every reload.getSession()is synchronous, and in cookie mode a restore is always a round trip — the refresh token is in anHttpOnlycookie the page cannot read — so reading it on the first render answersnullwhether there is a session or not. The hook awaitsclient.auth.isInitialized()now, which is the pattern the docs show. -
rls-checkstopped hiding what a scan did not cover. A--rolethat is not inpg_roleswas dropped, so a typo did not widen the scan and get noticed — it narrowed it, and every check gates on a grant to an exposed role, so the run printed a clean report of a database nobody looked at. It is exit 2 now, naming the role. And a keyword connection string reaches the host it names:pgcannot read the libpq keyword form, sohost=127.0.0.1 port=1 …connected to the default host while the error named127.0.0.1:1— a failure reported against a host the scan never went near. The keywords are translated into explicitClientoptions, and the ones that cannot be expressed are refused rather than dropped. -
Six MCP errors an agent could not act on.
fetch failedis what Node says when nothing is listening, and on its own it names no host, no port and no next step — nine times out of ten the situation is thatrebase devis not running, and the agent holds the tool that starts it. Connection errors now carry the URL that was tried and the command that fixes it. The environment block outranks the persisted registry:REBASE_PROJECT_DIR/REBASE_BASE_URL/REBASE_API_TOKENseeded thedefaultproject exactly once, on the first start that found no~/.rebase/projects.json, so the block a person had just written into.mcp.jsonwas dead — pointing it at a second project kept talking to the first, and nothing anywhere said so. And the package’s npm landing page told its reader to runnpx rebase-mcp— the unscoped name on npm belongs to somebody else, so the documented first command fetched and executed a stranger’s code.check-doc-commands.mjshas caught this class since it was written;packages/*/README.mdwas simply never in its globs. -
Eleven claims in the agent skills that the code contradicts. A doc a person reads wrong costs them a minute; a skill an agent reads wrong becomes code.
rebase-apigave the list default as 20 and the ceiling as 100 — they are 50 and 1000, and asking for more is a 400 rather than a clamp, so an agent paging by 100 got 50 rows and reported a short table as complete. The user tools takeuid, notuserId, which is what the input schema declares and what the handler reads.rebase-basicsopened with two prerequisites that do not exist — sign in to Rebase Cloud, then pick a project withlist_projects— and there is nolist_projectstool, the MCP server has never readtokens.json, and it does not talk to Rebase Cloud at all, so an agent following the first section was stopped at step two by a login that cannot succeed. A gate now checks the class. -
rebase cloud rollbackrefused every managed project.isRollbackablerequired animageUrl, and a managed deploy publishes none — the platform image is the platform’s half of the runtime and the customer’s half is the bundle. So the refusal fired locally, before the server was ever asked, and told the owner of a managed project that no image was recorded when one never could have been. The rule is the backend’srollbackTargetOfnow: successful AND (image OR bundle). -
A mistyped
rebase cloudaction ran the group’s default. Four groups are written as a chain ofif (action === "x")with the listing at the bottom, so a word matching nothing fell through to it:storage creatlisted the buckets and exited 0,clusters verifyylisted the clusters,resources et --cpu 500mprinted the dials it was asked to change. Reporting a typo as a successful run of a different command is worse than an error. Each group declares the words it dispatches now, and the rest are refused before the client is built. Relatedly, four groups answered a mistyped action with{"error":{"code":"error"}}— an envelope whose only machine-readable field is the word “error” — and all of them answerunknown_commandnow; andbilling --helpnames the actions billing dispatches, having documentedportalandusageagainst a dispatch that answerssetupandcheckout, so both documented words fell through to the default and printed the billing account instead. -
A piped
rebase cloud <group> --helpsaid something different from the terminal page. Eight group pages existed twice — a hand-formatted template literal for a terminal, and a bare list of action words for everything else — so piped,env --helpanswered{"command":"env","actions":["list","set","unset","reveal","pull"]}: no descriptions, no flags, and not the paragraph about build-time variables that is the reason the page exists. This family latches JSON mode off a TTY, so that was every scripted and every agent-driven read of it. One description, rendered twice.REBASE_JSON=0is honoured, having been tested against the literal “1” so that “0” fell through to the TTY test and set the mode anyway. And the index lists each group once, from one list —clustersappeared twice under two different descriptions, because the page was a hand-formatted literal sitting beside the array that fed the JSON form with nothing relating the two. -
rebase cloud deployasks about the card before the build, not after the upload. The first deploy of a project needs a payment method on the organization, and nothing said so until the control plane answered 402 — which on the managed path arrives last, after a type-check, a build, a pack and an upload whose only product was a discarded tarball. The check refuses in one direction only, on a positive “no card on file”, because two of the server’s skips are invisible from here; every answer this client cannot get proceeds and lets the server decide. -
The “you are not linked” hints named a command that does not exist. Three messages ended with
rebase link <url>, andcli.tsdispatchescloud— the link lives inside that family. Sorebase apps config backendon an unlinked checkout, and both ofgenerate-sdk’s unlinked paths, answered “what do I run now?” with something that exits 1, at exactly the moment somebody is stuck. A test now sweeps everyrebase <word>the CLI quotes at a reader and holds the first word to the dispatch list —check-doc-commands.mjsdoes this for the markdown, and nothing did it for the strings the CLI prints, which are read more often because they arrive when something has gone wrong.
Security
Section titled “Security”An external audit of the framework and Rebase Cloud on 2 September 2026. Every item below was reproduced before it was fixed. Read the first one if you read nothing else: it is the only one that can have been silently true on a deployment you already run.
-
A server’s first boot on an empty database served every request with RLS effectively off. The driver decides whether to drop to the restricted
rebase_userrole by asking whether its connection role is a superuser, hasBYPASSRLS, or owns any tables. On a fresh database with an ordinary owner the answer to all three is no, so no role switch was configured — and the same process then created and owned every table, which makes it exempt from every non-FORCEpolicy for the rest of its life. It logged “subject to RLS natively; no role switch needed” while it did so. The next restart answered differently and quietly fixed it, which is why it survived: the window is one process lifetime, and it is the first one, when a deployment has its first users and nobody is looking yet. Reproduced end to end — a second user listed, counted and rewrote the first user’s rows under anownerFieldrule. The posture is now decided after the schema is provisioned, from ownership Postgres would actually recognise (pg_has_role), and a connection that just created tables and still reports “no switch needed” fails the boot instead of logging past it. -
excludeFromApiwas a read-side rule that stopped at the top-level row. It was stripped from a row and from an inline relation target, but the “ref” rendering used by WebSocket fetches and by every realtime subscription frame copied the target’s columns unfiltered — so aposts.authorrelation tousers, which is what the scaffold ships, delivered password hashes to anyone subscribed. REST was clean the whole time, which is why it was not noticed. It was also read-side only in the other direction: any caller who could write a row could write the columns it hid, and the OpenAPI generator documented that as intended. Both directions now hold, in every rendering. -
storagePublicReadsatisfied the production access-control gate and left write, delete and list open. The boot check treated it as “access control is configured”, after which no authorize hook was required — but public read only relaxes reads. Writes fell back to the globalrequireAuth, which is off in exactly the public-site configuration the docs recommend alongside it. Proven anonymous: upload over another user’s key, list, read, delete. Public read without a hook now installs one that denies write, delete and list to anyone who is not an admin. -
Resumable uploads were not bound to their owner.
HEAD,PATCHandDELETElooked an upload up by id and never compared the recorded owner, so a leaked upload id let somebody else finish or cancel it under the owner’s authorized key. The per-upload ceiling was 5 GB regardless of the controller’smaxFileSize, which was checked only after finalize had read the whole temp file into memory. Ownership is now checked, the declared length is refused up front, and the file is streamed. -
Revoked and demoted admin tokens kept working on the most valuable routes. The user-management routers re-read roles and the revocation watermark on every request; the gate in front of backups, cron, logs, the schema editors, the RLS audit and dev mail did not, and neither did the API-key router or the auth router’s self-service routes. So a revoked token still downloaded a full database dump and read captured reset emails for up to an hour; a demoted admin could still mint an
admin: trueAPI key that never expires; and a stolen access token could still link an attacker’s GitHub identity to the account after the victim had reset their password and signed out everywhere. All of them re-read now. -
The DDL builders interpolated schema and table names straight into
CREATE TABLEandALTER TABLE. Identifier validation ran on the introspection reads and not on the statements built from them, and the live-schema router is mounted regardless ofNODE_ENV, so a crafted table name executed on the owner connection — which bypasses RLS entirely. The AST schema editor had the matching hole on the TypeScript side: top-level keys were emitted unquoted into a collection file and a relation target containing an arrow was emitted verbatim, andrebase devre-imports on change, so the payload ran as soon as it was written. Names that become SQL identifiers or source are now validated where they are used, not only where they are read. Database introspection had the same shape — a table name frompg_classwent into both a query and a file path — so a hostile table in a database you onboard could run SQL as your role and write outside the collections directory. -
A fresh deployment gave admin to whoever registered first. No shipped artifact set
DISABLE_SELF_REGISTRATION— not the compose file, the Helm chart, the platform blueprints, or the Hetzner module whose README brings DNS and TLS up before the operator has had a chance to register. The first administrator is now seeded fromREBASE_ADMIN_EMAIL/REBASE_ADMIN_PASSWORDand self-registration ships off. The address is checked against the same rule the login route parses its body with, and a boot refuses one that rule would reject — an admin nobody can sign in to is worse than the race it replaced, because the account existing is also what removes the first-run path. -
The same window, closed in the framework and not only in the artifacts. An empty user table admitted the first registration and promoted it to admin — the right rule for a laptop, and an open window on every host with a public hostname, since the shipped artifacts bring DNS and TLS up before the operator has typed anything.
GET /auth/configadvertisedneedsSetup: true, so the unclaimed hosts were also easy to find.POST /admin/bootstrapoffered the same prize one request later, to the earliest-registered user.The window now exists only outside
NODE_ENV=production. In production an empty table refuses the bootstrap registration withSETUP_REQUIREDand says what to do instead, a first account created through open registration is an ordinary account,needsSetupis never advertised,/admin/bootstraprefuses, and boot warns when the table is empty andREBASE_ADMIN_EMAILis unset. The two ways in — the named admin seed (REBASE_ADMIN_EMAIL/REBASE_ADMIN_PASSWORD, which every shipped blueprint already sets) and the service key — are the ones nobody can race for. Development,rebase devand the test suites keep first-registration-is-admin. -
GET /api/functionshanded anyone the inventory of custom endpoints. Functions themselves stay anonymous-callable by default (a webhook receiver has to be), but the listing now requires a resolved identity — a signed-in user, an API key or the service key.rebase cloud debugalready read a 401 there as “mounted”. -
Two audit reports named internal infrastructure. A private database address and a cluster-internal service hostname in
docs/audits/are replaced with placeholders, and the webhook audit carries a dated status line, since the SSRF guard it says does not exist has existed since 2026-08-08. -
Smaller, and all reproduced: preview URLs were sanitized by a blocklist that tab and newline variants walked past, and the rich-text editor assigned AI completions to
innerHTML; a download token could mint itself a fresh one for the same path, which on a trailing-slash key is a perpetual folder-wide grant; static SPA serving returned dotfiles and followed symlinks out of the build directory, so/.envand/.git/configanswered 200; 4xx responses echoed PostgresDETAILin production, an existence oracle for rows RLS hides; image transforms decoded at sharp’s defaults with no pixel ceiling and trusted the uploader’s declared content type; the email-OTP send route reintroduced timing enumeration and had no per-recipient limit; andcustomizeAccessTokencould overwriteuidandroles, because custom claims were spread over them. -
rebase auth reset-passwordwith no password set a constant. Both reset paths used the literalNewPassword123!and--helpprinted it as the default — a string that is in a public repository and in a published package. Reset is the documented way back into an account nobody can sign in to, which in practice means an admin. A password is now generated and printed, and one you supply is masked rather than echoed back. -
Published tarballs carried their own tests. Sixteen packages ship
files: ["dist", "src"]on purpose; seven of them keep tests beside the code insrc/, sosrcswept them in —@rebasepro/clientpublished 27 test files.check:package-contentsnow asks npm what it would pack and fails on anything test-shaped. -
Dependency advisories.
fast-uri(four highs, all of them the parser’s idea of a host disagreeing with the fetcher’s — malformed IPv6, a percent-encoded scheme, a repeated hostname, skipped IDN canonicalization) andqsboth reach@rebasepro/mcpas runtime dependencies, not only build tooling. Both raised, withbrowserslistalongside them.
Documentation
Section titled “Documentation”-
The branching page promised three things the feature does not do. It said the CLI updates your local development configuration when you create or switch to a branch — there is no
switch, andcreateleaves.envbyte-identical. It presentedDatabasePoolManager’s pool eviction as the guard againstis being accessed by other users, when that only reaches pools inside the process doing the work andrebase db branchis its own process — so a runningrebase devblocks branching and always did. And nothing said branching needs a real PostgreSQL server: on the managed PGlite databaseCREATE DATABASE ... TEMPLATEwrites a catalog entry and copies nothing, so the “branch” resolves to the database it was cloned from.rebase db branch infoand--fromwere missing from the CLI reference as well. -
docs/compatibility.mdpublishes the readiness table it promised — one row per subsystem, dated, rated stable / beta / experimental, each with what the rating rests on. Realtime is beta because subscriptions are matched by collection path, the data table’s missing grid semantics are listed as the defect they are, and@rebasepro/server-mongois marked experimental with no row-level security. -
One first run. The README, the docs index and the quickstart page described three different sequences, none of which matched what happens. Converged, in six locales, with the derived ports and the
initflags documented and “use your own Postgres” as a named variant. -
The self-hosting page stops publishing a compose file that cannot work. Its inline YAML mounted
/var/lib/postgresql/data(which the pg18 image refuses) and setPOSTGRES_USER: rebase_appagainst apostgres://rebase:connection string. It now points at the compose file in the repository, the one the acceptance gate boots on every push. -
Rebase Cloud has a documentation page, and
rebase cloud’s twenty-eight command groups are in the CLI reference. So areresources,apps init/config, sixdbsubcommands, and everyinitflag. -
@rebasepro/server-mongoand@rebasepro/firebasehave pages, each leading with what it does not do. -
Six deployment guides built a Dockerfile that does not exist. AWS, Azure, GCP, Scaleway, Railway and Fly.io all said
docker build -f backend/Dockerfile ., with a careful note about the build context because that had been wrong once. There is nobackend/Dockerfile: a scaffolded project’s compose stack mounts a bundle into the published runtime image, and the only Dockerfile the CLI writes comes fromrebase eject, at the project root. Every one of the six stopped at step 2, for everyone, on every platform. -
The production checklist described a window that is shut. “On the first visit, Rebase shows a bootstrap screen … claim it right after deploying” is development. In production an empty user table refuses the bootstrap registration with
SETUP_REQUIRED,needsSetupis never advertised, and an account created through open registration is an ordinary one — so a reader following the page deployed, waited for a screen that does not appear, and had no admin. The Kubernetes guide’s own headlinehelm installwas refused by the chart for the same reason (config.adminEmailhas been required since self-registration was turned off), the self-host recipe is six values rather than four, and the VPS recipe ran the process in development mode — localhost origins reflected, the OpenAPI spec served, the first-admin window left open on a box with a public name. The unit file now carriesNODE_ENV=productionand the twoREBASE_ADMIN_*lines, and says why each is there. -
Two pages recommended two different compose files. Deployment said the generated
docker-compose.yml“is the source of truth; use it as-is”; Self-Hosting said to useinfra/docker/docker-compose.selfhost.yml“rather than copying a snippet out of this page”. The two do not take the same environment, so a reader with a scaffolded project who met both produced a stack that would not interpolate. The two storage refusals that stop a production boot — a configured bucket with no access-control model, andSTORAGE_TYPE=localon a host with no persistent volume — are named on the checklist now. -
An error-code reference and a troubleshooting page, both gated, and the error envelope the server actually sends: both samples in
backend/index.mdshowed{ message, code, status }with kebab-case codes, where the server sends{ message, code, details?, requestId? }with SCREAMING_SNAKE codes and nostatusin the body at all — so a reader who followed either wrote a branch that never fires, in six locales. -
After-hooks run inside the write’s transaction, and the docs now say so.
afterSaveandafterDeletehave always run there, awaited, so a throw in one rolls the row back. Three docs and a JSDoc promised the opposite — “run after the transaction commits”, “do not block the HTTP response” — which invited exactly the code that breaks under the real behaviour: an HTTP call inafterSavethat turns a slow remote into a rolled-back save. -
“Two-Phase Meta” is deleted, because it never happened. The realtime page taught an emission model the SDK does not have: a first callback with heuristic metadata carrying
estimated: true, then an optional second one with the real numbers. There is one emission per push, it waits forcount(), and there is noestimatedflag anywhere in the codebase — so an app written to the page checked a field that is always undefined and rendered its first paint as authoritative. -
Four documented routes answered a
text/plain404.GET /api/collectionshad its own section inapi.mdand no router serves it, so the request fell through to Hono’s own 404 — not the error envelope, not even JSON. Its neighbours in the route table were the same. The OpenAPI paths are/api/docsand/api/swagger, named wrongly in every locale, and/api/meta/contractis what actually serves schema metadata. -
The five
RebaseBackendConfigkeys nobody could find, and the seven nobody should set.compression,maxBodySize,csrf,cronPersistenceandschemaEditorall change behaviour you can otherwise only observe, and none appeared in the configuration reference — so the way to learn that responses are gzipped by default, or that CSRF is deliberately off, was to readinit.ts. Seven others are internal and now say so. The configuration page also lists the variables the code actually reads, andDATABASE_URLstops being called required: unset means the managed database, set means yours, said once. -
The AI plugin’s Autofill posts field values to
app.rebase.pro. That is a reasonable default — the service is free, the requests are anonymous, no JWT travels with them — but the person deciding whether the content of those fields may leave the machine had no way to learn it from the docs. The plugins page names the address, says what is and is not sent, and shows theendpointoption for running it yourself. The same page never said that plugins are an admin-panel concept. -
The information architecture, checked rather than asserted. Every link resolves and every page leads somewhere; the two longest pages are split; a glossary covers the five words the first fifteen minutes assume; Studio has a home;
rebase cloudhas a subsection per group; there is one index of every route the server mounts; a feature that is not released yet says so; a BaaS reader has a path that never mentions React; and a translation that has gone stale is now something the verifier says out loud rather than something a reader discovers. “a entity” was a global search-and-replace, thirty times over. -
Docs that quote a file now are that file. Page three of Project Structure showed an
App.tsxwith noAppfunction, no export, acreateRebaseClientat module scope and two hooks no package exports — a newcomer met that on page three and concluded the scaffold was broken. The block is the template’s file byte for byte, andcheck:templatesfails when the two differ. The plugins, custom-views and frontend-overview samples compile and lead with the compositionrebase initactually writes; thedefineFunctionhover example imports from the portable@rebasepro/server/functionsand stops recommending ause()that covers only the routes below it; the functions guide’s first example isapp.post("/"), becausefunctions.invokesends POST and following the page start to finish produced a 404; andrls-check’s README samples are output the tool actually produced, with every flag it accepts in the table and a gate that reads the flags out ofparseArgsto keep them there. -
The
rebase cloudrecipes are the ones the CLI accepts. The deploy recipe is one command —deployhas readrebase.jsonsince it learned to, sorebase build && rebase cloud deploy --bundlewas three things to remember for a path that needs none of them — and the link sequence starts withbilling setupand creates the project with flags, becauserebase cloud projects create my-appis parsed withmaxPositionals: 0and exits before it reaches the control plane. Rollback says what is restored for each way of deploying, and--provider/--regionare explained as the registered deploy target they record rather than the region choice the beta does not offer. All mirrored into the five locale copies.
[0.17.3] - 2026-08-31
Section titled “[0.17.3] - 2026-08-31”-
A driver published before 0.18 could not load beside the 0.18 runtime, and
revisioncould not have fixed it.346df48e2gave thedatabasekind arevisionso that an older copy of@rebasepro/typeswould lose a disagreement instead of throwing. The bundle corpus refused the 0.18.0 release anyway, on the 0.17.3 row, with v0.17.3’s message verbatim — “Two packages cannot define the same kind.”, no revision clause — which is the tell: the throw came from the driver’s inlined copy, not from the runtime.The registry is process-global on purpose, but each copy calls its own
registerResourceKind, and the one that runs the comparison is whichever registers second. The runtime registers at import and a driver is imported after it, so the judge is always the bundle’s copy, frozen at its release. 0.17.0–0.17.3 deep-equal the spec and throw; they have never heard ofrevision, so no value this package writes can change what they do. The previous fix was only ever reachable in the load order that does not happen.Kinds now live under
Symbol.for("@rebasepro/types.resourceKinds.v2"), which no released copy looks at. An older copy registers into the legacy map alone, finds nothing to contest, and cannot throw — in either order, which is what the last fix only claimed. Declarations stay on the shared symbol, because that sharing is real and load-bearing.revisionkeeps its job among copies that understand it, and two current specs at one revision still throw.The duplication itself is the disease:
@rebasepro/server-postgresinlined@rebasepro/typesinto itsdist, so every pod held two registries. It is externalized now, along with@rebasepro/commonand@rebasepro/utils— all three areRUNTIME_PROVIDED, so an npm consumer installs them, a linked consumer resolves them through the workspace, and a managed bundle gets the image’s. One copy per process; nothing to reconcile.Two gates hold it.
packages/types/test/kind-registration-protocol.test.tsreproduces v0.17.3’s function verbatim rather than importing this build’s — the hole that letshipped-kinds.test.tspass green on the commit the corpus failed — and asserts both load orders.tooling/scripts/test/driver-single-copy.test.mjsreads the builtdistand refuses a driver that defines the registry instead of importing it. -
Three releases published without
@rebasepro/agent-skills, and nothing failed. On 2026-08-24rebase-agent-skills/moved undertooling/. Both release paths named their publishable packages as literal paths; the shell loops were updated and fourpnpm --filter './rebase-agent-skills'were not. pnpm treats a filter that matches nothing as a warning and exits 0 — it printsNo projects matched the filters "…", then does the work for the filters that did match. So the bump ran forpackages/*, silently skipped the skills package, and every job stayed green.The package was last published on 2026-08-23. 0.17.0, 0.17.1 and 0.17.2 each shipped without it. Worse, and far less visible:
packages/clidepends on it asworkspace:*, which pnpm resolves at publish time against that package’s own manifest — so all three published CLIs carry a hard"@rebasepro/agent-skills": "0.16.0", a pin nobody wrote, four versions behind.rebase skills installhas been writing the 0.16.0 set ever since, which means every agent skill authored or edited in that window — including the wholerebase-cloudskill — reached no user at all.Nothing in the pipeline could have caught it: every check asked whether the packages it found were correct, and none asked whether it had found them all.
A release no longer enumerates its own contents.
publishable-packages.mjsderives the set frompnpm-workspace.yaml— every member that is notprivate— and it is the single derivation used by the workflow, byrelease.sh, and by the workspace-protocol validator, all three of which held their own copy of the list. Publishing takes no--filterat all, sincepnpm -r publishalready publishes exactly the non-private members wherever they live, so there is nothing left for a directory move to invalidate.pnpm check:publishable-setis the guard, and it runs on every PR rather than at release time, because a release-time check is discovered during a release. It fails when publishable packages fall out of version lockstep (the symptom), when any release file enumerates packages by hand (the cause), when a publishable@rebasepro/*package sits outside the workspace globs where nothing would see it, when a package declares nofiles, or when itsrepository.directoryno longer matches where it lives — which the same 2026-08-24 move had also left stale.This does not repair the published 0.17.2:
workspace:*was resolved at publish time and cannot be rewritten after the fact. The next release is what puts the skills package back on npm and points the CLI at it.A fourth copy of the list surfaced when CI ran against this fix, and it is the one that could not be repaired by correcting a path: the registry-install e2e built its set with
readdirSync("packages"), so it structurally could not see the package undertooling/.@rebasepro/agent-skillswas therefore never packed, the CLI’s dependency on it was never rewritten to a local tarball, and the install fetched it from the public registry — which worked only because 0.16.0 is the version this very bug had stranded there. It packs the derived set now, and a first-party package that is not in it is a thrown error rather than a warning: an e2e that reaches the real registry for our own package is not testing the tree it was given. -
The API key dialog described the widest grant in the product as a narrower one. A permission row’s collection field addresses three namespaces, not one:
*matches every collection and every custom function and storage, whilestorageandfunctions/functions/<name>reach the other two. The dialog labelled that field “Collection slug or ” and the detail panel rendered the wildcard as “ (all collections)” — so a key granting everything read as a key granting only the collections, and two namespaces were undiscoverable from the UI that creates them.permissions.tsis now the single place that knows the mapping; the picker labels, row descriptions, grant summary and detail panel all read from it, so they cannot drift from each other or from the server guard. The free-text box became a grouped picker (Everything / registered collections / Functions / Storage / a free-text escape for anything unregistered), and a live read-back under the rows spells the grant out in English as it is built. A row granting nothing used to be dropped silently at submit and now says so, and operation toggles are neutral when off —deleterendered red whether or not it was checked, so a read-only key looked destructive. -
Selectannounced every option list as “Select an option”. Its trigger hardcoded that string wheneverlabelwas not a string, so every such select in the API key panel was identical to a screen reader. It takes anaria-labelpassthrough now. -
The demo has been un-deployable, not merely stale.
scripts/moved undertooling/, andapp/backend/Dockerfile’sCOPY scripts ./scriptskept naming the old path while theRUNtwo lines below it already used the new one — the two halves of one rename disagreeing, so everypnpm deploy:demosince died at that layer with “file not found in build context”. It copiestooling/scriptsrather than all oftooling/, since only the scripts are needed in the image. Same move, same shape as the entry above.
- API keys can be created as admin keys, and are labelled as such. The wire
has carried
adminall along and this view could neither set nor show it: the localApiKeyMasked/ApiKeyPermissioncopies had drifted from@rebasepro/typesand never gained the field, so an admin key was indistinguishable from a scoped read-only one. The types come from the package now, and admin keys are badged in the list, the detail panel and the created-key confirmation.
[0.17.2] - 2026-08-31
Section titled “[0.17.2] - 2026-08-31”-
rebase db pushwas impossible for any collection declaring a{ type: "vector" }property. The column compiled toVECTOR(n)indrizzle/schema.sql, and Atlas computes its desired state by materialising that file in a scratch database it creates empty and empties again at the start of every run — so the type was resolved against a database that structurally cannot have pgvector, and every push died withpq: type "vector" does not exist. Not intermittently: permanently, for the framework’s own embedding property.Nothing in userland got past it. Seeding the extension does not survive Atlas’s clean (measured: present before the run, gone after), a
CREATE EXTENSIONin the desired state is refused as a paid feature, an extension in a non-publicschema makes the scratch database “not clean”, and--excludefilters the diff only after the file has been parsed and applied.Vector now takes the carve-out full-text search already had. The column, its ANN indexes and the extension are generated into
drizzle/vector.sql, Atlas is told to exclude them, and Rebase applies the file itself afterschema applyand appends it to migrations. Excluding the column turns out to be enough on its own — aNOT NULLorUNIQUEon it is a property of the column and goes with it. -
rebase db generateandrebase db migratewere both down for any project with asearchblock.--excludeis accepted byatlas schema applyand by nothing else, and the guard that added it read as a subcommand test without being one:migrate applymatchesargs.includes("apply")exactly asschema applydoes. Atlas rejects an unknown flag before doing any work, so both commands exited withunknown flag: --exclude. Present since the same commit that started appending search DDL to migrations, which means that append had never once run. -
A migration could carry a
DROP COLUMNfor a search or vector column nobody asked to lose.migrate diffcomputes the current state by replaying the migration directory — which builds those columns, because that DDL is appended to migrations — and diffs it against aschema.sqlthat deliberately omits them, so Atlas plans a drop. Nothing caught it: the destructive gate reads the push plan, and this is a file applied days later. Those statements are now removed from the file Atlas writes, clause by clause because Atlas folds the phantom drop into whatever real change shares the table. A drop the CLI cannot rewrite stopsdb generaterather than being guessed at. -
A no-op
rebase db generategrew the last migration every time it ran. TheCREATE SCHEMArewrite and the RLS policy append were not gated on Atlas having written a new file, so a run that found nothing to diff appended another copy of the policies to a migration that had already been applied in production — changing a hash Atlas had recorded, while the appended SQL ran nowhere. -
Live schema editing left behind a
schema.sqlthatdb pushchokes on. The commit generated it whole, so it carried the RLS policies, the search helpers Atlas will not parse, and the vector column. It now writes the same splitrebase db generatedoes. -
rebase devcould not host a vector column at all. The managed development database derived every extension bundle’s module path as@electric-sql/pglite/contrib/<name>, which does not exist for pgvector — it is a package of its own, and it was not declared.CREATE EXTENSION vectorfailed there withextension "vector" is not available, which reads like a broken database rather than a missing import. -
rebase db push --helpapplied the schema. The flag printed usage and then ran the command it was documenting, against whatever database the project was pointed at. Asking what a destructive command does is the one moment you are most certain not to want it to happen. -
A first deploy deadlocked on a step nothing named. A project created with
rebase cloud projects createhad no database, was writtenstatus: "provisioning", and stayed there: nothing was in progress, the platform was waiting forrebase cloud db create, and no output, help page or skill named that command. “Provisioning” reads as work underway, and the correct response to work underway is to wait — so the correct response to this state was the one thing guaranteed never to resolve it. Measured at 43 minutes of polling on a real first deploy; an unattended agent would still be polling. -
cloud deploymeant two different things depending on the runtime. The managed-bundle path stopped at “deploy started” and told you to runcloud logs, while the source path waited and made its exit code the verdict — so the same command returned 0 for builds that went on to fail. It now follows on both paths unless--no-followsays otherwise, and takes--waitand--timeout. -
A cluster’s refusal was printed as if it were yours. Failures arrived as a whole Kubernetes
Statusobject with request headers, an audit id and a flowschema uid. Worse than the noise: a403naming asystem:serviceaccount:is the control plane’s OWN credentials being refused, which nothing in a user’s project can grant — and printed raw in a failed deploy it reads exactly like a project fault. Someone acting on that reading deletes working code. Failures are now classified before they are summarised, carryplatform: truein the JSON, and say in words when retrying and changing the project will not help. The untouched body stays behind--debug. -
clusters verifynever saw the id it was given. It selected one fromrawArgs, which is the whole ofprocess.argv, so the first match was the node binary path: every invocation asked about a cluster called/usr/bin/node, got a 404, and read as “this diagnostic is not deployed yet”. It is the one command that reportspermissions.allowed/permissions.deniedfor a cluster, so the diagnostic for a missing RBAC grant was itself unavailable.
-
rebase cloud projects create --db managed|byodb|none, defaulting tomanaged, so the sequence every project needs is one command rather than two.--db noneis the deliberate opt-out and still prints the command that finishes the job. -
blockedOnandnextActiononcloud status.blockedOn: nullis the load-bearing value — it is the CLI saying that waiting is correct, and the only condition under which pollingstatusmakes sense. Every other value names a command. -
db create --wait, which polls a bring-your-own database until it answers. For a managed one it reports that there is nothing to wait for and returns, since a loop there would be the same non-terminating wait in a new place. -
A
rebase-cloudagent skill, andrebase-deploymentnow points at it. -
database({ extensions: ["vector"] }). Declared inconfig/resources.ts, it letsrebase db pushand the boot schema-ensure runCREATE EXTENSION IF NOT EXISTS vectorfor you.A permission rather than a request: the statement is issued only where something in the schema needs it, so naming an extension nothing uses installs nothing. It is opt-in because everything that decides whether the install can succeed — the image shipping the library, the role’s grant, a managed provider’s allow-list — is invisible from inside the connection. Saying nothing withholds the install, never the column, so a database where pgvector was installed by hand keeps working with no configuration.
database()also accepts options in place of a key, since the default database has no name to pass.
Changed
Section titled “Changed”- A changed
dimensionson a vector property is no longer silent. Atlas used to own the column and plan the type change; now that it cannot see it,ADD COLUMN IF NOT EXISTSwould have done nothing and left the old width behind a config that says otherwise. The generated DDL widens the column when it holds no values and refuses — naming the statement to run — when it does.
Removed
Section titled “Removed”seedDevDatabaseSearchHelpers, which never did anything. Its docstring claimed an excluded column is still materialised in Atlas’s scratch database; it is not. Measured with a real generatedtsvectorcolumn: a seeded scratch database and an empty one produce byte-identical output in every configuration, and Atlas wipes that database before it plans. The--excludepatterns were always the whole protection.
[0.17.1] - 2026-08-30
Section titled “[0.17.1] - 2026-08-30”-
A managed pod could not unpack its own bundle. 0.17.0 shipped a fix for a
tarfailure and the fix did not work; every managed pod rolled onto that image crashlooped on the bug it was meant to close. An archive rooted at.carries the mode of the directory it was packed from, and GNU tar applies that to the extraction root as its last act — refused where the process does not own the directory, and refused after every file has been written, so a complete bundle is reported as a corrupt one. A Kubernetes emptyDir is exactly that case:root:node0775 setgid against a runtime running as uid 1000.No flag avoids it.
--no-overwrite-dir,--no-same-permissions,--delay-directory-restoreand--exclude=./were each measured against GNU tar 1.34 extracting into a directory owned by another uid, and all four still fail on the root: it is not an entry the archive can be told to skip, and it fails even when the mode being set is the mode the directory already has, because the refusal is about ownership rather than change.The runtime now unpacks into a directory it creates and moves the entries up, so the root
tarchmods is one it owns. Staging sits inside the destination, which keeps the moves renames within a single filesystem rather than a second copy of a tree that is already the largest thing in a pod’s ephemeral-storage grant. -
A failed fetch no longer discards a bundle that works. The runtime threw away the bundle already on disk when a download or unpack failed and then exited, so a pod holding something serviceable died anyway. It now falls back to it. Had this been in 0.17.0 the unpack bug above would have degraded the fleet rather than crashlooping it.
-
A collection whose name collides with an internal table keeps its grants. Such a collection had them revoked.
Changed
Section titled “Changed”check:runtime-image:bootsboots the image a fourth way:mode=urlinto a directory the runtime does not own, which is the shape every managed pod actually has. The gate previously only ever unpacked into/bundleas the image ships it — the one arrangement in which the failure above cannot occur, which is why it stayed green through 0.17.0.
[0.17.0] - 2026-08-29
Section titled “[0.17.0] - 2026-08-29”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.
-
pnpm check:portable-core— what the request path depends on Node for. A request this server can answer without touching the database pool is a request an isolate could answer, and that set is larger than it looks: token verification, rate limiting, idempotency, storage URL signing, and every custom function. Eight modules on that path needed a Node process, for nine separate reasons. Five of those modules needed it for no reason anyone had chosen:randomUUIDfromnode:cryptowhere thecryptoglobal would do,node:pathto fold.out of a storage key that never touches a filesystem, SHA-256 and a constant-time compare that WebCrypto does just as well.Those five are gone, and the gate records what is left in
contracts/portable-core.txt. It is a ratchet rather than a wall: the file may shrink and may never grow, so a branch that puts a fresh dependency on Node in front of every request has to say so in review instead of a year later. Nothing has to reach zero for that to be worth having —drizzle-ormandpgneed a TCP socket, and that is a driver decision. What it buys is that a later port is a scoping exercise against a list, not an excavation.Three lines remain, each with its reasoning in the file: the JWT library, PEM key parsing, and the client’s socket address — a per-adapter capability rather than something a portable module can reach, since Hono has no runtime-agnostic
getConnInfo.The SSRF guard joined the same list and needed two changes to clear it.
net.isIPbecameutils/ip-address.ts, a transcription of Node’s own grammar held to it by a property test comparing the two directly — a validator that is stricter thannet.isIPsends a literal down the resolution path, and one that is looser judges bytes nobody else agrees with. And its default resolver is loaded on use rather than imported, so a runtime with nonode:dnscan be handed one instead of being unable to load the module at all. A host with neither fails closed and says which of the two it is missing: the alternative to resolving a name is not “allow it”, it is “do not send”. -
Custom functions have their own entry point:
@rebasepro/server/functions.import { defineFunction } from "@rebasepro/server"reaches the whole framework — the boot sequence, the collection loader, the backup routes, the SPA server,@hono/node-server,ws,jsonwebtoken, Drizzle. On Node that costs a little start-up time and nothing else, which is why it stood. It also meant a function file could only ever resolve inside a Node process, however portable the function’s own code was — and since that import line is in every function file, every template and every documentation page, it is not a thing that can be changed later without breaking everyone who wrote one.The new entry point carries the authoring surface and nothing else:
defineFunction, therebasesingleton, route guards, typed context accessors, configuration readers,waitUntil,ApiError,HonoEnv. Its published bundle imports exactly two things,honoandhono/adapter, and the build refuses to ship it otherwise — a test walks the import graph from source and names the chain that broke the rule, and a second check evaluates the emitted file in a context holding web globals and noprocess,Bufferorrequireat all. Importing from the package root still works and still behaves identically; it is now the second-best way to write a function rather than the only one. -
Typed accessors for the request context.
getUser(c)returns{ uid, roles, …claims }orundefined, withrolesalways an array. Every documented example used to open withconst user = c.get("user") as { uid: string; roles?: string[] } | undefined— an assertion in a security-relevant position, copied once and never re-examined, and wrong for at least one auth path that reaches it.getUserId,getRoles,hasRole,isAdmin,isAuthenticated,getDriver,requireDriver,getApiKeyandgetRequestIdcome with it.requireDriver(c)replacesc.get("driver")!and, when there genuinely is no driver, says that the app was mounted outside the functions router instead of failing twenty lines later onundefined.requireRole("editor", "admin")joinsrequireAuthandrequireAdmin. All three read the identity the platform already resolved rather than parsing a token, which is what makes them portable — and is a distinction with no behavioural difference inside a function, where both auth middlewares have already run. Outside one, where nothing has, they answer 500 naming the wiring rather than 401 blaming the caller’s token. -
waitUntil(c, promise)for work that outlives the response. An un-awaited promise looked equivalent and was not, in both directions. AtSIGTERMa floating promise is dropped mid-flight, so a rolling deploy has always been able to lose the webhook a request had already answered 200 for; shutdown now waits for tracked work, bounded, and says how much it had to drop. And on any host where the process does not outlive the request, an un-awaited promise is not slow but cancelled — silently, behind a clean 200.waitUntilis the one construct both cases honour. -
Configuration is read from the request:
getEnv,env,requireEnv,lazyResource.const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)at the top of a function file is a live defect today, not merely an unportable one: it is evaluated while the file is being imported, so an unset variable throws before any request exists and the loader reports the whole file as a skipped function. The route 404s, and the reason is one line in a boot log.lazyResource(env => new Stripe(env.STRIPE_SECRET_KEY!))builds the same client once, on first use, from that request’s configuration.rebase doctorandrebase buildnow report module-scopeprocess.envreads in the functions directory. -
rebase buildrecords what each function needs from its host. The bundle manifest gains afunctionsarray — name, file, and whether the function’s own source reaches a Node built-in or a package that needs one. Purely descriptive: nothing fails, and a function that opens a file or runs raw SQL is a fine function. It is recorded because the name is already the function’s identity everywhere (/api/functions/<name>, thefunctions/<name>API-key permission,REBASE_FUNCTIONS_ONLY), and a host that wants to know what is in a bundle should not have to boot it to find out. -
Live schema editing, from the collection editor to the database. A running backend can now plan a schema change, show what it would do, and apply it only once somebody agrees.
planSchemaChangereads the live catalogue before it plans, because whether aNOT NULLcan be added is a question about rows and whether an enum value will land is a question about the type — neither is answerable from the collections alone. The editor’s save path shows the verdict in a sentence, then each change with its remedy, and does nothing until confirmed.Applying is a second privilege, not the same one that opens the editor: it alters the database and it writes a commit into the project’s repository under somebody’s name, and an admin credential is not an author. For deployments with no working tree — a Cloud tenant runs a built bundle and its repository lives elsewhere — the commit goes through GitHub’s Git Data API instead of
git. -
A managed development database, so
rebase devneeds no Postgres. Getting a project running wasdocker compose up -d db, thendb push, thendev— three steps, each a place to bounce, plus a compose file the developer then maintains.rebase devnow starts the database and pushes the schema itself. The managed database is PGlite behind a multiplexing socket server, anddb pull, the schema flows and the rest of the CLI were wired through to meet it.Realtime was the one thing it could not do, and it failed in the worst way available: every query succeeded,
LISTENreturned cleanly, and change events simply never arrived. It now works through a notification proxy. -
REBASE_DB_POOL_MAX, a ceiling every pool honours. The managed database is a single session, where two pooled clients holding overlapping transactions deadlock rather than error. -
The RLS audit runs on a schedule, and the backend serves what it found. Also
rls-check --html: the text report is written for a terminal, and the person who has to act on it is usually not the person who ran the scan. A--fail-onexit code stops a pipeline; it does not survive being forwarded to whoever owns the database. -
rls-check --role, because a check can only gate on a role it knows about. Every check reports a table as exposed only when a role an untrusted caller can arrive as holds privileges on it, and that set was hardcoded toPUBLIC,anon,authenticated,web_anonandrebase_user. A stack whose app role is calledapp_usergave every check nothing to gate on, so the scan printed a clean report for a database it had not cleared. The report now also listsunrecognizedGrantees— write-holding roles it can neither recognise as exposed nor explain as trusted — so it says “clean as far as I could tell” rather than “clean”. -
Storage: byte-range requests and per-object access control. Media can be seeked, and who may read an object is declared rather than coded.
-
Bot protection on the auth endpoints that cost something to hit, development secrets that survive a restart, and auth email captured in development instead of refused.
-
An ANN index for every vector column, with pgvector shipped in the scaffolded database image.
-
Collections declare their indexes, and a hand-written index stops disappearing. The collection model had 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”-
JWT verification and signing are asynchronous.
verifyAccessToken,generateAccessToken,verifyDownloadToken,generateDownloadToken,hashRefreshTokenandextractUserFromTokenreturn promises. Nothing about their behaviour moved; the signatures did, and on purpose, before anything forced it.Every portable JWT implementation is asynchronous, because
crypto.subtleis. So a later swap ofjsonwebtoken— forjose, or for WebCrypto directly — is not the expensive part: the expensive part is going from synchronous to asynchronous verification, which touches every caller of every function that reads a token. That was 22 call sites insrcand about 190 in the suite. Paying it now, with the tests green and nothing else moving, costs a day; paying it as a line item inside a runtime port, on top of everything else changing at once, is how a port stalls.jsonwebtokenis now confined to one module,auth/jwt-crypto.ts, which is what makes the eventual swap a one-file change with no caller affected. The one trap in that swap is written down where the swap will happen:jsonwebtokenstampsiaton every token it signs andjosedoes not, andiatis what the revocation watermark is compared against — tokens minted without it would verify perfectly and simply stop being revocable.RateLimiterOptions.keyGeneratorandresolveLimitaccept a promise as well as a value, since a limiter that buckets by user has to verify a token to find one. Passing a synchronous function is unchanged. -
EmailService.send()reports what the provider said, and carries headers. It returnedPromise<void>, which meant an application that sent a message could not learn the id the server assigned it — so threading a reply back to the message that prompted it was impossible through this interface, and any app that needed it had to bypass the service and hold its own transport. It now resolves with{ messageId, accepted, rejected }, every field optional because not every backend reports them: an absentmessageIdmeans “not reported”, never “not sent”, which is still signalled by a throw.messageIdcomes back without angle brackets, since it is a value to store and compare against a reply’sIn-Reply-To, and one that sometimes carries brackets is a bug waiting in every comparison.EmailSendOptionsgainsheaders. Several things a real sender must do are only expressible as headers and had no route through this interface at all:List-UnsubscribeandList-Unsubscribe-Post, which give a mail client its own one-click opt-out and which the large providers weigh when deciding whether bulk mail reaches an inbox;In-Reply-ToandReferences, without which a reply starts a new thread. Values are validated, not escaped — a value containing CR or LF is rejected, because a newline ends the header and begins another one, so any field built from data the sender did not write is a way to add aBcc:. Stripping the newline instead would deliver a message the caller did not write and tell nobody. Header names are checked against RFC 5322 too, and both checks run before a customsendEmailprovider is reached, so the custom path is not a way around them.Breaking only for code that implements
EmailService: asendreturningPromise<void>no longer satisfies it. Callers are unaffected — they may ignore the result — and theauth.email.sendEmailhook stays permissive (Promise<EmailSendResult | void>), so an existingasync () => {}provider still works and simply reports nothing. The development mail sink now reports a synthetic id, so a flow that stores one and later matches a reply against it takes the same path in development as in production. -
A vendored tree too large to upload is not vendored. The control plane refuses a bundle over 100 MB, and vendoring is the one thing that can push a bundle near it — so a build that crossed the line shipped a bundle whose deploy would be rejected, with the remedy (
--no-vendor) only discoverable by knowing that had happened. Past 200 MB on disk the tree is now thrown away and the bundle ships unvendored: 40–60s of cold start, and a deploy that works.--vendorkeeps it regardless, for a deploy that builds from source and never uploads the tree at all.The ceiling assumes a pessimistic 2× floor on compression, because the limit is on the compressed upload while this measures the tree on disk. The warning below it now says which quantity is which — “201 MB, close to the 100 MB upload limit” was two different numbers described as one, and read as nonsense.
-
GET /api/auth/configanswers one question once, from one handler. Two handlers claimed that path:init.tsregisters it directly and only afterwards mounts the auth router, so the router’s copy never ran — and the two returned different payloads, one reportingemailServiceEnabledandmagicLinkEnabledwhere the live one reportedpasswordResetandmagicLink. A fix aimed at the wrong copy therefore changed nothing, which had already happened once. The router’s copy is gone, the payload is assembled in one function, and the surviving route is rate-limited like the rest of the unauthenticated auth surface — it counts users on every call.In the payload itself,
registrationandregistrationEnabledwere the same boolean under two names, both advertised.registrationEnabledis the only one now, and it is required rather than optional: it says whether self-registration is open right now, first-user bootstrap window included.anonymousLoginis required for the same reason.AuthConfigin@rebasepro/clientandAuthConfigResponsein@rebasepro/appare aliases ofAuthAdapterCapabilitiesinstead of near-copies of it — the SDK’s copy listed anemailServiceEnabledflag no backend has ever sent, and marked as optional fields every backend always sends. A test pins the exact key set, because the drift that started this was a field name, and no per-field assertion can see one. -
PATCHis the update verb for the data API.PUTwas mounted on the same handler and the generated OpenAPI spec described the operation twice — once aspatch, once asputmarkeddeprecated— so a client generated from the spec had to choose, and the verb it chose meant “replace” for a handler that merges. The SDK’supdate()now sendsPATCH, which is what the spec has advertised since 0.14;updateManywas already there.PUTstill answers, on the same handler, carryingDeprecation: true(RFC 8594). It was removed during this cycle and put back: every published SDK up to and including 0.16.0 sendsPUT, so removing it broke clients that had no fixed version to upgrade to — seePUTon a collection answers again under Fixed. There is noSunsetdate, because the removal is gated on which SDKs are in the field rather than on a calendar.
Removed
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.Corrected since release: this paragraph listed
context.client.datain entity callbacks andclient.datain a cron handler as unaffected and user-scoped. Neither was. Both are this same server singleton, so both were this same admin-scoped alias, and both went with it. In a callback, query throughcontext.data, which runs with the privilege of whatever triggered the callback; in a cron handler, userebase.dataAsAdminfrom its context. 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 |