Salta ai contenuti

Changelog

  • ?count= on a REST list is now the count switch, so a filter on a column named “count” goes inside ?where=. ?count=none skips the count behind meta.total, ?count=exact is the default (see Added), and any other value is a 400 INVALID_COUNT. Before, ?count=5 filtered on a count column. 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/tus answers 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 push with “already exists”. Two properties that resolve to the same column (myTitle and my_title) and a relation target written 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 /bulk upsert 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=, /bulk with upsert: true, a _batch upsert operation, the SDK’s upsert() and createMany(…, { upsert: true }), and the realtime socket. Create users with POST (or create) and change them with PATCH (or update).

  • A read naming both page and offset is refused with PAGE_WITH_OFFSET. The server answers 400, and the client SDK, the in-process rebase.data and the offline client refuse it before any request. Before, page won silently and the offset was 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) answers 400 VALIDATION_CONSTRAINT with the required violation, on creates and on updates, even where the field has a defaultValue: a default fills an absent key, not an empty one. A collection with a beforeSave keeps its exemption, since the hook may fill the value. The admin panel already writes null for a cleared field. See the upgrade guide.

  • 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 or rebase db push replaces (see Fixed). Only a policy name that collided changes.
  • The remote MCP’s list_collections reply carries its listing once, in the text block, with no structuredContent. A client that read the listing from structuredContent reads the text instead: the same content, as compact JSON. The other tools keep their structuredContent.
  • @rebasepro/app no longer exports the Spanish translation table. The entry re-exported es from ./locales/es, the only language besides English it exported, and that kept the Spanish strings in every bundle built from the package. en is 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. Call get(key) where a missing source is expected.

  • useBackendStorageSource is removed from @rebasepro/app. Use client.storage, or client.createStorageSource(key) for a named source; both carry storageId, public and context, which the hook dropped.

  • client.cron calls /api/admin/cron by default, not the deprecated /api/cron alias. A server that serves only the old path (or a proxy that forwards only it) now answers 404 to the SDK’s cron calls. Pass cron.cronPath to 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(…) (with or, and, not, cond, still exported) and client.backups. client.ws is unchanged.

  • 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 while initializeRebaseBackend and the scaffold’s .env.example used 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. Set JWT_REFRESH_EXPIRES_IN=400d (or auth.refreshExpiresIn) to keep the longer window.

  • An administrator’s chosen password is held to the password policy (400 WEAK_PASSWORD). On POST /api/admin/users and on every create on the users collection through the data API (REST, /bulk, batch, MCP, the socket), as registration, resets and PUT /api/admin/users/:uid already were. On a /bulk the 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 send skipPasswordPolicy: true on 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 a password. AuthAdapter.prepareUserCreation takes an optional third argument, UserCreationOptions { mayWaivePasswordPolicy }.

  • 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 unsuffixed S3_* / GCS_*. If your unqualified uploads relied on the promotion, mark that bucket bucket("media", { default: true }); in production these are the files the default wrote into the named bucket. Without the mark, those uploads answer 501 STORAGE_NOT_CONFIGURED in production, and boot says so once.

  • Storage controllers can stream, through two new optional methods, statObject and getObjectStream. The file route uses them to serve a file, a Range request, a revalidation and a HEAD without 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 orphans lists the stored files no row names, and --delete deletes them. Every row of every collection is read, soft-deleted rows included; files newer than --older-than hours (24) are left out; nothing is deleted while a collection cannot be read (409 STORAGE_SCAN_INCOMPLETE). Behind GET|DELETE /api/admin/storage/orphans (database:read / database:write).

  • rebase storage move copies a folder of files to another storage source under the same keys, resumable, with --from, --to, --prefix, and --delete-source to delete each source copy once the target holds it. Behind POST /api/admin/storage/move (database:write).

  • storageTargetsInText(text) lists the objects a text names (references and pre-reference download URLs), from @rebasepro/types and @rebasepro/client.

  • Local storage warns at boot when its folder is on a case-insensitive file system (macOS, Windows), where Avatar.png and avatar.png are one file.

  • isDatabaseUnreachable(), ApiError.databaseUnavailable() and DATABASE_UNAVAILABLE_MESSAGE in @rebasepro/server, for a custom route or function that wants to answer an unreachable database the way the built-in routes do: 503 DATABASE_UNAVAILABLE with Retry-After: 5.

  • A list can skip its count, with ?count=none over REST or count: "none" on find(); a value the server does not know is a 400 INVALID_COUNT. meta.total is a COUNT(*) over every matching row, most of what a list request costs on a large table. With none nothing is counted and meta.hasMore stays exact, because the read asks for one row past the page; over HTTP meta.total is then absent. exact is the default. The SDK’s iterate() and findAll() 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.precondition and DeleteProps.precondition carry the 404 and If-Match checks into the write’s own transaction, on a driver that declares checksRowPreconditions. All three are optional: a driver without them keeps the separate reads it had. Both Postgres drivers implement them.

  • jobs.timeoutMs bounds one job attempt, and every job, queue and topic handler gets a signal aborted at that deadline. A handler that hangs on a fetch can pass the signal on and stop with the attempt.

  • getSignal(c) in @rebasepro/server/functions is aborted when a custom function’s request times out (504). Pass it to fetch so the work stops with the request instead of running on.

  • CORS_EXPOSED_HEADERS is exported from @rebasepro/server. A backend that wires its own cors() 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 } }, or rebase generate-sdk --register, see CLI) and context.data in collection callbacks, rebase.dataAsAdmin, and the rebase a function or cron receives are typed from it: rows, where/orderBy, include, and create/update inputs. defineFunction<Database>() and defineCron<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>.trash in collection callbacks, and rebase.data.<collection>.trash in functions and crons: find, count, restore, restoreMany, purge and purgeMany, the same trash the browser client has. The reads return only soft-deleted rows; a restore sets the soft-delete field back to null; a purge deletes the row for good.

  • databaseDefault marks 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; a null sent on create is left to the database; the generated Insert type makes it optional. Given the expression, in the template form isId takes (databaseDefault: "sql`gen_random_uuid()`", new type SqlExpression), the schema planner writes it as the column’s DEFAULT, so rebase db push keeps it; databaseDefault: true declares a DEFAULT written elsewhere and writes none. “Import from table” and rebase schema introspect set the expression they read on such columns.

  • A built bundle can edit its collections from the admin panel. Set REBASE_LIVE_SCHEMA_REPOSITORY=owner/name and REBASE_LIVE_SCHEMA_GITHUB_TOKEN (or REBASE_LIVE_SCHEMA_GITHUB_APP_ID, _INSTALLATION_ID and _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_BRANCH and REBASE_LIVE_SCHEMA_COLLECTIONS_PATH default to main and config/collections.

  • createRebaseBackend builds a backend and mounts, starts and listens to nothing: each of those is a call you make. routes.auth(), data(), storage(), functions(), admin(), cron() and meta() 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/data included, and getUser(c) and getDriver(c) work inside it. mount(app, { basePath }) is the whole layout initializeRebaseBackend serves; attachRealtime(server), start() (cron, job workers, sweeps) and shutdown() are the rest. backend.rebase is the in-process client, and backend.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: createRebaseBackend requires a provision option, which is true, false or { collections, realtime }. true creates what the backend serves: the collection tables and their RLS policies, change capture, the request role and the framework’s own tables. false opens 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. initializeRebaseBackend passes true, and REBASE_MIGRATE_ON_BOOT=none still turns the collection schema off for any backend.

  • Functions and cron jobs are values, and loadFunctions and loadCrons read a directory into them. functions: { "send-invoice": sendInvoice } and crons: { nightly: defineCron({ … }) } sit beside collections; the record key is the URL segment or the job id. functions: await loadFunctions(dir) serves what functionsDir: dir serves, and the same holds for crons; onSkip receives 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.

  • composeCallbacks combines several sets of collection callbacks into one, the way the backend combines its own global, collection and property tiers. beforeSave and afterRead chain, beforeQuery narrowings are AND-ed, any false from beforeDelete vetoes, and the after* hooks run in order. From @rebasepro/cms-types, @rebasepro/server and @rebasepro/common. setCollectionCallbacks still replaces a collection’s declared callbacks, and its warning now names composeCallbacks(declared, mine) as the way to keep them.

  • createCallContext builds the context a collection callback receives, so a hook can be unit-tested by calling it. Fields you pass are used as they are; a missing client, data or storageSource throws on first use, naming the field; user stays absent unless passed. From @rebasepro/server and @rebasepro/common.

  • Each backend keeps its own state in backend.services, and backend.verifyCredential(token) checks a credential against it. services.jwt (a JwtService: sessions, JWKS, download and purpose tokens), access (the access model and the personal-key owner resolver), apiKeyStore, rateLimitStore, authLimiters, collections(), topics and queues. createJwtService(config) builds a JWT service on its own, and JwtService, AccessControl, AuthLimiters, BackendServices and JwtConfig are exported types. Every route group sets c.var.services; set it yourself, c.set("services", backend.services), to make a route of your own answer for a backend.

  • setDefaultBackend makes a backend built by hand the process default: the one the module-level API answers for, namely rebase outside a request, verifyAccessToken and the other JWT helpers, getAccessModel, verifyCredential, and topics and queues published outside a request. initializeRebaseBackend does this for its backend.

  • installUnhandledRejectionHandler() is exported, so a backend built by hand can repeat initializeRebaseBackend’s first step. It returns a function that uninstalls the handler, or undefined when one was already installed or REBASE_EXIT_ON_UNHANDLED_REJECTION is 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, as rebase console -e is.

  • backend.services.authAdapter: the auth adapter that judges this backend’s sessions, the one verifyCredential uses.

  • findById(id, { include }) and listenById(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/client exports the types its calls are written in: WriteOptions, WhereFilterOp, WhereFor, LogicalCondition, AggregateParams, AggregateResult, IncludeSpec, RowOf/InsertOf/ UpdateOf, StorageSource and others. A helper that wraps find() or update() no longer has to rebuild them from Parameters<…>.

  • client.offline.status().persisted says whether the browser agreed to keep the offline database. It is asked with navigator.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 typed or() / and() group, FilterConditionFor and LogicalConditionFor, are exported too (see Changed).

  • aggregate() takes a typed orderBy, by group key or alias (AggregateOrderKey). For example orderBy: ["sum_total", "desc"]. The HTTP client sends it, and the in-process accessor now applies it instead of dropping it.

  • client.data.<slug>.trash lists, counts, restores and purges the rows a soft delete stamped. It maps to ?deleted=only, the update setting the stamp to null ({ field } for a custom soft-delete field), and ?hard=true. With offline enabled, the trash is never cached or queued, and a call fails with OFFLINE without the server.

  • auth.restoreOutcome() says how restoring the session on load ended: restored, none, refused or unreachable.

  • 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.runSqlScript and RebaseWebSocketClient.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().unverifiedUser is 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.

  • VirtualTable, ListView and CardView accept undefined entries in data (a row not read yet, drawn as a placeholder) and an onRowsRendered(range) callback; EntityTableController has sparseData and onRowsRendered. A table can hold a sparse window of a large collection (see Changed).

  • The kit has a HeartIcon, and gains the accessibility props Alert role, Avatar onClick, ResizablePanels dividerAriaLabel and ToggleButtonGroup aria-label / aria-labelledby. DateTimeField’s locale is now applied, as the input’s lang.

  • ToggleButtonGroup takes fullWidth.

  • UiLabelsProvider and useUiLabels() 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 DateTimeFieldMode type and UiLabels.openTimePicker.

  • Markdown fields get the Autocomplete slash command from the AI plugin, behind the same gate as Autofill (the service available, getConfigForPath allowing the collection). A field’s own editorProps.aiController still wins.

  • warnDeprecated(key, message) from @rebasepro/common prints 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.

  • Four translation keys name the split-view dividers and the drawer’s CMS / Studio switch: resize_sidebar, resize_editor, resize_list and panel_mode. All four are optional on RebaseTranslations, 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.nameKey is 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, time or timestamp (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.

  • EntityFormPanel and CollectionsProvider put 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 without entityId) and saves it through <Rebase>’s data layer, with onSaved and onClose. Both work under <Rebase> and any React Router router, without <RebaseCMS>, <RebaseNavigation> or <RebaseShell>, and bring their own CollectionsProvider when none is above them. Records open over the page, in a side panel or a dialog, never by URL.

  • The modeController prop of <Rebase> hands Rebase your app’s light and dark mode. Rebase then leaves <html>, prefers-color-scheme and localStorage alone. useBuildModeController({ enabled: false }) builds an inert controller.

  • Several layers can register collections with <Rebase> at once. A CollectionResolverRegistrationContext registration takes an optional owner as its third argument, and UrlController.routesMounted is false where 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, TO lists and clauses, so the rebase db push after 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 TABLE fix and never runs it.

  • rebase db generate names the destructive statements in the migration it writes, and rebase db migrate --refuse-destructive refuses one. The flag dry-runs the pending files and exits 1, applying nothing, when one destroys data. Without it, db migrate is unchanged.

  • rebase generate-sdk --register types server-side data: it writes the generated types and the Register declaration to config/generated/, where both the backend’s and the config package’s compiles see them, and remembers the choice in rebase.json (registerDatabase on 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 dev could only add. The destructive gate is unchanged.

  • REBASE_ATLAS_DEV_URL names the empty database Atlas plans against, instead of the derived _dev_diff. Rebase neither creates nor drops a database named this way.

  • rebase console opens a Node REPL with the project’s data in scope. rebase is what openRebase returns and collections the project’s collections, with top-level await. The project, .env, database and --collections are resolved the way rebase db push resolves 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’s return, 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-downgrade lets a dist-tag move pins to an older release. Without it, rebase upgrade refuses such a move, with downgrade_refused under --json.

  • normalizeEsmSpecifiers(outDir, { write: false }) computes the import rewrite without writing anything. From @rebasepro/cli; rebase normalize-imports --check uses it.

  • A new project ships a root TypeScript config that typechecks scripts/. The root also declares @types/node, so tsc --noEmit at the project root checks something instead of printing its help and exiting 0. Each workspace still typechecks on its own.

  • rebase status reports the branch. It includes branch: { name, database } when the checkout is switched to one, and the human output says so.

  • Three new gates: pnpm check:test-types, pnpm check:casts and pnpm check:test-resolution. Type errors in tests are counted per package and may not grow; the count of as any, as never and as unknown as in package source may not grow; and every test runner must import workspace packages from source, not dist.

  • 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-names fails on any IconButton in package source without an accessible name.

  • A package build fails when its dist contains Vite’s browser stub for a Node built-in. assert-build-output.mjs looks for __vite-browser-external in every emitted file. The published Postgres driver carried one for readline, 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, to users:read holders and to anyone who may write a collection with a userSelect field, 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 only users:read holders can pick. New type PickedUser.
  • An unconditional policy that nothing can reach yet is reported as latent info. For example USING (true) TO anon on 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 one GRANT would make it critical with no change to the policy. It is reported under policy-always-true at info, titled “latent”, with what is missing and the fix. Info is below every --fail-on except info, so default runs are unaffected.

  • anonymous-write-allowed reports Rebase databases. Every request there runs as rebase_user, a signed-out one too when AUTH_REQUIRE=false or 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 that anon inherits is reported too.

  • The property editor warns when a smaller max narrows a varchar or char column. On those column types validation.max is the column’s width, so lowering it is a DDL change: rebase db push asks 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.

  • Live row frames carry the row’s version. etagOf(row) on a listenById() row is the ETag a GET of it answers, so an edit made against what a frame showed can be saved with ifMatch and is refused with 412 if the row has moved on. A successful PATCH answers with the ETag of the version it wrote, and update() 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 with TOO_MANY_SOCKETS and the socket is closed (4002). Unset, as by default, there is no limit.

  • REALTIME_REFETCH_MAX_WAIT_MS sets the longest a change waits for its live frame (default 1000 ms); REALTIME_RECOUNT_INTERVAL_MS and REALTIME_RECOUNT_MIN_ROWS let a big list reuse its total for a while. Instead of counting it on every write; such frames carry meta.totalAsOf. Unset, every frame counts, as before. Each has a realtime.* option.

  • realtime.path on the Postgres adapter, and attachRealtime(server, { path }), set the URL path the realtime socket answers. The default is the base path given to mount() with a trailing /api removed (/api → /, /x/api → /x), which is where the SDK dials. RealtimeSocketOptions in @rebasepro/types carries it to a driver as path.

  • 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: true and that startedAt. A run that cannot start still answers 409. The synchronous call is unchanged and stays the default.
  • openRebase opens a project’s data from a script, a REPL or a test, with no server. It returns dataAsAdmin and sql, the accessors a function gets, withAuth(user) for that user’s row-level-security-scoped data, and close(). Opening changes nothing in the database and reads no environment variable. Against a database no server has booted on, withAuth fails rather than run unfiltered. From @rebasepro/server-postgres; see “Scripts and the Console”.

  • Writes through openRebase and rebase console record entity history. A write to a history: true collection 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 (service for dataAsAdmin, the user’s uid under withAuth). Nothing is created for it. If the history table or its rebase.record_history function is missing, writes to those collections are refused with HISTORY_UNAVAILABLE before any callback runs, and reads still work. A script prunes no entries, because retention is the server’s setting.

  • openPostgresDriver and postgresDriverConfigFromEnv are the driver open and the boot’s environment reading, as two public pieces. createPostgresAdapter is one step above them, and openPostgresDriver reads no environment variable. The adapter’s initializeDriver config takes provision: false, which opens the driver with no DDL of any kind, and realtime: false, which takes no part in realtime.

  • realtime.cdc and directConnectionString on the Postgres adapter config are the explicit forms of REALTIME_CDC and DATABASE_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 Database that rebase generate-sdk --register registers, so a misspelled collection, field or enum value in a script is a type error. openRebase<Database>() and OpenedRebase<Database> name the type per call, or for a helper.

  • 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 ERROR frame, also on AUTHENTICATE) and a subscription. They answered 503 ROLE_LOOKUP_FAILED “Could not verify your permissions”, 500 INTERNAL_ERROR, a 500 with the code ECONNREFUSED, or, on the socket, AUTH_ERROR INVALID_TOKEN, which sent the SDK to refresh a valid session. ROLE_LOOKUP_FAILED remains 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 answered 500 with the socket error as the code (ECONNREFUSED), or 500 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=25 was 8 statements in 2 transactions and is 5 in 1 (4 with ?count=none); with include=*, 13 → 10. The count now comes from the same snapshot as the page. A ?distinct=true read is no longer counted, since its answer never carried meta.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 a limit with no orderBy, 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 belongsTo per level: include=author 4 → 3, include=author.company 6 → 4.

  • PATCH and DELETE no longer load every relation of the row before writing it. The 404 and the If-Match check 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, with If-Match 17 → 10, a DELETE 12 → 8. Bulk update, _batch update 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 no onCreateUser, onAdminCreateUser or custom hashPassword; 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-Match and If-Modified-Since, so /, a deep link or /logo.svg revalidates 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, .gz and .zst siblings 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/docs builds 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 enqueueEntityChange and no jobQueue, 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 resolves false if it runs out (it resolved undefined). onEntityChange with 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 from createRebaseBackend closes one only when passed { server }. RebaseBackendInstance is now RebaseBackend, 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. createRebaseBackend writes no process global; the module-level API answers for the process default, which configureJwt, configureAccess, setDefaultBackend and initializeRebaseBackend set. A backend given no auth.jwtSecret follows 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) or 401 ACCOUNT_DISABLED, as the admin routes already did, instead of UNAUTHORIZED “Invalid or expired token”. createBuiltinAuthAdapter().verifyRequest rejects with that error instead of resolving null. The service key now verifies through verifyCredential as kind: "service-key", as it does on the HTTP routes, where it used to be null.

  • 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 as data:raed throws at registration. An app scope the backend does not declare under auth.scopes answers 500 SCOPE_NOT_DECLARED, instead of 403 SCOPE_MISSING to everyone, admin included, with a message saying admin holds every scope.

  • loadEnv() names what is wrong. It throws an Error reading “The environment is not valid: DATABASE_URL: is required”, with zod’s issues on .issues, instead of a raw ZodError that printed as ZodError { 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 null from verifyCredential. A routine 401 the server marks expected, such as NO_SESSION from POST /auth/refresh for 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.

  • 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_timeout and 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 20000 as an admin, 7.3 s → 4 ms, and count(*) 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 or rebase db push rewrites each policy in place. A deployment that applies only generated migrations keeps the old, equivalent text until its next rebase db generate writes 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 separate SET 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 push and rebase db generate plan 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_USER now fails initializeDriver instead of initializeWebsockets, with the same message. createPostgresDatabaseConnection and resolveChannelBusSetting take an optional trailing env.

  • collectionRegistry is optional on the adapter’s initializeDriver config; no bundled driver reads it, so a script no longer passes collectionRegistry: undefined as never.

  • openPostgresDriver no longer logs how authenticated requests are isolated. It returns the sentence as internals.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. openRebase logs it at debug, and rejects with one readable Cannot connect to PostgreSQL at <host>:<port>: <reason>. when the database is down.

  • 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_notify and 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=off removes 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 same REALTIME_CDC: a schema owner set to off beside an api left on auto leaves the api listening 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 an UNKNOWN_FILTER_OPERATOR refusal, 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_sql on the socket URL (?rebase_caps=…,cancel_sql) and sends CANCEL_SQL { requestId } (an admin-plane frame, database:write) only to a server whose hello lists 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 own upgrade listeners, 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, verifyAccessToken and accessModel on RealtimeSocketOptions, so it uses its own backend’s keys and access model. A socket built by hand without them uses the process default.

  • 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/ui view is out of the preload set. @rebasepro/app and @rebasepro/cms declare "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), so vendor-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 of include: ["*"]. 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 199 COUNT(*)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 (default true) 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 function storagePath or fileName, and every field when a plugin has a fieldBuilder, 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 sharp is 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 dark class the page already had is left.

  • @rebasepro/cms-types depends on @rebasepro/common, for the composeCallbacks it 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. Set hideIdFromCollection: true to show the key in its own column.

  • @rebasepro/forms: a pending onValuesChangeDeferred call runs when the form submits (before onSubmit) 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. getEntityChildViews sets the child view’s singularName from the target collection; the declaring property’s name still names the tab, so dialogs say “Remove this Tag from this record?” and “Add Tag”, not “this Tags”.

  • Importing one component from @rebasepro/ui bundles 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.wrap gets the field as the plugins before it left it, and all the wrappers render. params.Field is the previous plugin’s wrapper, so the first plugin’s is innermost; a plugin that returns null or enabled: false passes the field on unchanged. Before, only the last plugin that returned a wrapper rendered.

  • 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 SELECT no 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”); SqlScriptResult carries truncated: true and the true rowCount. Copy and export include the rows shown. A result too large for one string is no longer reported as an SQL error. client.executeSql is 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/plan lists every file /apply commits in files: 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-destructive and the report rebase db generate prints. A push that would still drop one asks on a terminal and refuses elsewhere; --allow-destructive applies 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 dev no 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 a config/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_Store or editor swap file appearing in config/, no longer restarts the backend.

  • rebase dev reaches “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 … and doctor resolve 189 modules instead of 426; rebase schema generate at 11 collections takes 1.2 s instead of 1.6–2.0.

  • rebase db push on 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 --version and --help load 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 upgrade with no --to, on a canary for example, takes the newest canary unless latest is newer. An exact --to older 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 --check used to throw, with a stack-trace hint.

  • The other commands follow the database the last dev server ran on. rebase dev records whether it ran with --docker. The choice is kept in .rebase/dev-database.json (gitignored) and ranks where --docker does, below every DATABASE_URL, the branch pointer and --database-url; commands say so on stderr. The next rebase dev without --docker switches them back to the managed database, and warns that it is leaving the Docker one.

  • Anonymized emails are random. rebase db pull --anonymize writes user-<random hex>@example.invalid (no longer user<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 --json and --write --json print the graph with check: { file, status } or written: { file, changed } beside version and resources.

  • Starting a bundle with no database URL stops before it boots. rebase start exits 1 when no DATABASE_URL is set, and prints DATABASE_URL="$(rebase db url)" rebase start for 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 backup without --out, and rebase db backups list, use ./backups in the directory you run rebase from, normally the project root: the same directory --out ./backups names. Before, they used backend/backups. A relative --out, and the dump path rebase db restore takes, resolve against that directory; a relative BACKUP_DESTINATION still resolves against the backend directory, where the server reads it. If the new location is empty and backend/backups holds dumps, backups list says where they are.

  • A backup takes its destination only from –out. Use --out ./backups instead of rebase db backup ./backups. rebase db restore refuses 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) writes spa, functions and crons only when they differ from their defaults, and builds the static app with pnpm in a pnpm project. The inferred static app is still named web.

  • A pull replaces the local database’s contents. rebase db pull empties 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-keys and rebase storage against rebase start in production mode, or a server in a container, set REBASE_BASE_URL; see the Security entry.

  • The branch subcommands refuse a flag they do not take. Every rebase db branch action, switch included, names the closest real flag (“did you mean –older-than?”), as db backup and db restore do. They used to ignore it, so --froce ran without --force.

  • Each new project gets its own compose port. rebase init takes 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 introspect on tables with row-level security off, rebase db push --dry-run lists 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.

  • Walking a collection seeks by cursor and skips the count on every page. iterate() and findAll() hand the server’s meta.nextCursor back as after instead of a growing offset, and send count: "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 Transport can take part through the optional setTokenRefresher.

  • 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 a string[] 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))) and cond("views", "==", "many") are now compile errors. Relation and JSON paths are not checked.

  • etagOf and withETag now live in @rebasepro/common. @rebasepro/client still exports etagOf.

  • 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 token option, which HTTP never did. Installing a token getter or calling setToken() 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 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, a HEAD, 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 openAsBlob in the wrong module and always fell back to reading the file. POST /upload still 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/metadata requests and make 1; 100 different files go 8 at a time; at most 2,000 URLs are kept. This covers the SDK’s client.storage, useBackendStorageSource and the admin’s thumbnails, through createDownloadUrlCache and createConcurrencyLimit in @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.maxConnections to 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. transformImage throws ImageTransformUnavailableError when sharp is not installed, with a message naming pnpm add sharp.

  • 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 api role 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_finished is built at the first boot after upgrading (4.7 s on a 2M-row table). A custom store takes part through the optional JobStore.sweepExpired() and CronStore.sweepClaims().

  • A job attempt times out after 15 minutes when jobs.timeoutMs is not set. The worker names the default in one log line at start. Set jobs.timeoutMs for longer jobs, or 0 for 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.

  • The bundle budget fails when a module that should load lazily reaches the login screen. check-bundle-budget.mjs reads 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. --dist runs 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:hooks counts an exhaustive-deps disable that has no -- reason; pnpm check:gates-doc checks the “Tests” rows against the workflow and every package test script against a register in docs/gates.md; pnpm rls:check --strict-baseline fails 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>, not 0.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/config and 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/config runs no query at all. A custom auth repository can provide the optional firstUserIds.

  • 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.

  • 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: use client.setAuthTokenGetter(), which authenticates HTTP and the socket together. It keeps its old meaning, the socket alone, and warns once.

  • loadFunctionsFromDirectory and loadFunctionsWithDiagnostics are deprecated: use loadFunctions(dir), with onSkip for the skipped files. Both keep their old shape and behaviour, and warn once per process.

  • loadCronJobsFromDirectory and loadCronJobsWithDiagnostics are deprecated: use loadCrons(dir), and declareCrons(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 prefix option of bucket() is deprecated; nothing ever applied it. A source declared with prefix: "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>, or STORAGE_PATH__<KEY> for a local one), or put the folder in each storage property’s storagePath. A declaration that sets it still compiles and deploys, and the backend warns once per bucket at boot.

  • DatabaseAdapter.mountRoutes and BackendBootstrapper.mountRoutes are 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.

  • The plugin hooks hooks.modifyCollectionAsync and lifecycle.onCollectionChange are deprecated; neither was ever called. Fetch the data before rendering <Rebase> and build the plugin from it, so its hooks.modifyCollection reads data it already has; subscribe with context.data.collection(slug).listen(…) from lifecycle.onMount, unsubscribing in onUnmount. A plugin that sets either still compiles, and <Rebase> warns once per plugin, naming the replacement.
  • NO_COLLECTIONS on /api/data and /api/docs names the tables that were skipped because row-level security is off on them, with the SQL to enable it, and details.unprotectedTables lists them. It said the database had no tables.

  • A 400 INVALID_SCOPES no longer offers keys:read and keys:write as valid scopes for a key, in its message or in details.validScopes.

  • A backend with a single database driver no longer warns No "(default)" driver provided at every boot.

  • A via relation 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 __type anywhere. Code that read the reference object off an included row reads the foreign-key column, or includes that relation.

  • A distinct listing’s meta.hasMore is false when nothing follows. It was true whenever the page came back full.

  • Two If-Match writes 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 string enum field match its labels. They failed with a 500 on every door. On a number, a date or another non-text field they are a 400 INVALID_FILTER_OPERATOR instead of a database error. citext columns 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 id on every update. beforeSave and afterSave receive previousValues with the row’s key, like values.

  • 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-Attempt when registered as ctx => 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 use defineCron.

  • 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/health was 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 moved REBASE_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 generate warns when a hasMany / hasOne target declares no foreign-key column back.

  • A map property can store objects whose keys start with $. JSON Schema’s $schema and $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, and meta.dropped_fields in 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.ts entry 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/config is no longer served by a process with the auth surface off, such as the functions and worker roles.

  • A functions record of what defineFunction returns typechecks: functions: { "send-invoice": defineFunction(…) } was refused, because FunctionApp was Hono<Env>; it is Hono<HonoEnv>.

  • Storage answers 503 DATABASE_UNAVAILABLE when the database is down, with Retry-After, as every other route does. It answered 401 UNAUTHORIZED, which told the SDK the session was bad.

  • A headless boot names rebase eject for unprotectedTables. The boot log and the NO_COLLECTIONS answer suggested setting baas.unprotectedTables: "serve" as if it were config. It is a server-entry option, reachable only after rebase eject.

  • A server that boots while a long report, transaction or pg_dump reads the database no longer revokes rebase_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 push does 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-run writes nothing of the project’s. It no longer regenerates src/schema.generated.ts, and it names, rather than deletes, the drizzle/*.sql copies an older release committed. The generator’s output is plain text under NO_COLOR and 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_dumpall logs an error at boot naming the missing tools. It failed silently every night. rebase db backup, restore and backups list also 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 or rebase db push drops the truncated policy and creates one policy per operation.

  • Boot gives an isId: "uuid" key the default its table is missing. A table adopted with rebase 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 sets DEFAULT 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: true collections with HISTORY_UNAVAILABLE before any callback runs.

  • readConnectionString on the Postgres adapter config is honoured. It was declared and ignored, and only DATABASE_READ_URL worked.

  • 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 stale If-Match are still refused.

  • The history refusal reads “this database is missing the rebase.entity_history table”. The HISTORY_UNAVAILABLE message said “has no the …”.

  • When schema.generated.ts and the database disagree, the boot warning says to run rebase db push (or rebase db generate). It said rebase schema generate, which leaves the database as it is.

  • rebase db push after rebase schema introspect no longer drops a policy whose name has the shape an unnamed rule compiles to (<table>_<op>_<hash>, as on a database Rebase built), and rebase doctor --policies no longer reports an introspected database’s own policies as orphans.

  • rebase doctor --policies and rebase db push tell 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 in securityRules. Both were “left behind by an earlier push”.

  • rebase db push no longer drops the CHECK and EXCLUDE constraints on a collection’s table. A collection cannot declare either, so on a database adopted with rebase schema introspect the 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 push no 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, so orders_idx[v1] matched nothing and the index was dropped without a prompt, and a dotted name failed every push with too many parts in pattern.

  • rebase db backup, rebase db restore, rebase db branch and rebase schema introspect against a database that is not running print the banner rebase db push prints, “Cannot connect to PostgreSQL at host:port”, instead of a raw connect ECONNREFUSED or a Drizzle Failed query: CREATE SCHEMA ….

  • Listing branches, and asking about one, only reads. rebase db branch list and info created the rebase schema and the branch registry.

  • rebase db branch prune with no terminal and no --yes exits 1 and says to pass --yes, instead of printing “Nothing was changed.” and exiting 0.

  • rebase db push --yes says it proceeded because --yes was passed, not --allow-destructive.

  • rebase schema introspect no 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.sql roles file are readable by their owner only (0600), from rebase db backup and from scheduled backups. They were 0644.

  • A server created by rebase eject keeps local uploads in the project’s uploads/ when it runs compiled, as it does under rebase dev. Compiled, it used backend/dist/uploads, which the next build deleted.

  • The .env.example a new project gets no longer suggests STORAGE_PATH=./uploads. After rebase eject the server runs in backend/, so that line pointed at backend/uploads, not the <project>/uploads the server uses when the variable is unset. It now says where files land by default and suggests an absolute path.

  • pnpm run build in 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’s vite.config.ts sets chunkSizeWarningLimit: Infinity, as the repository’s example app does. An existing project can add the same line under build.

  • rebase doctor and rebase db push no longer report every policy.authenticated() and policy.registered() policy as the insecure uid() IS NOT NULL tautology. The drift check accepted only <> 'anonymous', and Postgres stores the guard as <> ALL (ARRAY['anonymous', 'anon']).

  • rebase dev no longer misses “Server running at” when that line arrives in two chunks. When it missed it, the admin panel never started, .rebase-dev-port was 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 push and rebase db generate no longer end their first step with “You can now run rebase db generate”.

  • A collection that does not evaluate makes rebase schema generate, db push and doctor print the error’s message and “Re-run with –debug for the stack trace”, not a stack trace. --debug now reaches the driver.

  • A new project’s rebase dev and builds print no warnings they did not cause. Vite’s “Both esbuild and oxc options were set” and, from pnpm run example or an ejected server, dotenv’s banner are gone.

  • On a checkout switched to a branch, rebase db branch create, delete, info and prune use the main database’s branch registry, as list does. A branch created there is recorded where list shows it. create still 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 the ALTER … USING to run; a unique added 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 doctor no longer sends you back to db push for it.

  • rebase db push renames relation columns that still carry their pre-0.13 name (categorie_id) the way rebase dev does, instead of listing them as destructive drops.

  • rebase schema introspect no longer writes a Kanban board without orderProperty. It warned on every generate; it declares one only when the table has an order-key column.

  • Re-running rebase skills install keeps skill files you edited (--force replaces them) and removes skills a release retired. A .rebase-skills.json manifest 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-basics skill’s MCP reference lists all 42 local tools and the remote /mcp endpoint’s tools. The scaffold’s agent instructions no longer send pnpm db:push at 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 --git writes the preset’s real generated schema instead of a stub that rebase dev rewrote.

  • Every next step init prints exists in the project it printed it for. rebase init --headless --database-url told you to run pnpm run db:push, a script the headless scaffold does not have. Every next step is now checked per flavour, and the headless steps include the CREATE POLICY that 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 --check wrote 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 dev restart.

  • A dev server on a fresh compose database no longer fails its schema push with read ECONNRESET. rebase dev --docker waits 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 as refresh_tokens.token_hash) gets a distinct value per row, replacements fit the column’s length, NULLs stay NULL, views and generated columns are skipped, and citext columns 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-grants rebase_user after any restore that started.

  • The resource check fails in JSON mode too. rebase resources --check --json exits 1 with an error envelope (resource_graph_missing / resource_graph_stale) on a missing or stale rebase.resources.json, and --write --json writes 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, console and 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-keys and rebase storage named only the PORT in .env.

  • rebase db push asks 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 with createInterface is not a function. Ctrl-C or Ctrl-D at a prompt answers no instead of hanging.

  • A destructive rebase db push that you decline, that cannot ask, or that crashes no longer leaves the <db>_dev_diff scratch database behind.

  • Backup and restore refuse an option they do not take, before doing anything. rebase db backup, rebase db restore and rebase db backups list name the one you probably meant (--targt-db: did you mean --target-db?). A misspelled --target-db was ignored, and the restore went into the live database.

  • A failed restore from s3:// or gs:// 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’s migrate 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 migrate fails on a column (42701) or a schema (42P06) that already exists, it prints the --baseline remedy, and names the migration that failed when that is not the newest one.

  • rebase db push on the managed database while the dev server runs no longer fails with a bare Error: 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 the RLS enforcement active note the scaffold’s LOG_LEVEL=info let through, go to stderr.

  • Input piped into rebase console runs one line at a time, each after the previous line’s await has settled, so the documented two-line example no longer fails with Cannot read properties of undefined. Input after .exit is 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 status no 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 fixed localhost:3001.

  • rebase db pull keeps both connection strings’ passwords off pg_dump’s and pg_restore’s command lines, where any process listing could read them; each tool gets its password in PGPASSWORD.

  • rebase apps init --force on a scaffolded project writes back the rebase.json the scaffold ships. It keeps each app’s name, path and cms from 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.json builds its frontend with its own package manager. The inferred build command was always npm 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 --docker that a DATABASE_URL outranks no longer starts the compose container, on rebase dev, the rebase db commands and rebase console. dev also pushed the schema into that container while the backend ran on the other database. One line now says --docker was ignored, and why.

  • rebase init accepts only a postgres:// or postgresql:// connection string as the database. The prompt asks again (typing n used to write DATABASE_URL=n), and --database-url with anything else is refused before anything is written.

  • Interactive rebase init refuses 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 .env is not sent, the shell’s REBASE_SERVICE_KEY goes only to the linked project, or the token sent was refused. It always said “set REBASE_SERVICE_KEY”.

  • rebase auth reset-password prints the backend’s refusal, a weak password say, as its message and code, not as the raw JSON body. Both paths label a generated password Password:.

  • rebase console --collections and rebase doctor --collections with a missing path no longer warn that applying it would drop every table. Each says what it did not do instead.

  • rebase dev says when it regenerated an out-of-date backend/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 --help left out api-keys get, which rebase api-keys --help lists, and rebase db push --help never said it takes --database-url and --docker.

  • rebase db migrate works on the managed development database. While rebase dev ran it failed with Error: postgres: querying system variables: EOF. With dev stopped it failed with relation "rebase.atlas_schema_revisions" does not exist, then already exists on every later run, and left a stray public.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 until rebase dev restarts. A failure on Atlas’s own revisions table no longer recommends --baseline. A managed database that already ran db migrate on a canary keeps a harmless public.atlas_schema_revisions, which DROP TABLE public.atlas_schema_revisions removes.

  • rebase db branch switch applies on Postgres in Docker. After rebase dev --docker, with DATABASE_URL commented out, db url, console, db backup and dev --docker kept using the main database, so writes meant for the branch landed on main. A switch the managed database cannot honour is reported.

  • rebase db pull no longer reports a false “incomplete copy” when the local database has a table the source lacks, such as rebase.atlas_schema_revisions after rebase db migrate, and a failed restore sends you to “pull again” only when another pull could succeed. --anonymize also overwrites display_name, middle_name, family_name, and a bare name on tables of people.

  • rebase schema introspect reads the project’s database, managed or Postgres in Docker, and honours --docker and --database-url, instead of “DATABASE_URL is not set”.

  • rebase status describes the database the project uses. It reports Postgres in Docker while rebase dev --docker serves it, judges a second database by its own connection string instead of marking a working database("analytics") ✗ for the default’s, and says when the checkout is on a branch.

  • The headless scaffold can migrate. Its backend/ installs Atlas, so pnpm run db:migrate, and the db push / db generate that init --headless --introspect points to, no longer stop on “The atlas binary is missing”. init --database-url adds sslmode=disable to a URL for a database on this machine that names no sslmode, so schema commands no longer fail with “SSL is not enabled on the server”.

  • rebase start beside rebase dev on the managed database. It uses one connection, as dev does, so it no longer logs rebase.cron_claims is unavailable and history-sweep warnings for tables that exist, and it listens on its own PORT. Its hint for a missing DATABASE_URL spells 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 stop under a running rebase 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 dev says what it does. An .env edit restarts the backend. Stopping dev says that the managed database keeps running, stops itself after 30 minutes with nothing connected, and that rebase db stop stops it now. dev --database-url <url> exits 1, naming the flag, when nothing answers there, and leaves the database .env names alone. dev --docker outranked by .env no longer says “Pass –docker to stay on it”, and keeps the remembered Docker choice.

  • rebase db generate on a database that has booted ends with the rebase 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 -e no longer prints RLS enforcement active, db branch no longer ends with a pool-shutdown line, and with the database down db push, db migrate and console print the “Cannot connect” banner once. A managed database with no free connection is said in words, not as read ECONNRESET with its stack.

  • rebase init hints 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. --help says what each mode does about git and the install. rebase upgrade --dry-run --json carries wouldRefuse when the real run would refuse.

  • Scaffold details. backend/tsconfig.json includes scripts/, so tsc --noEmit checks the scripts the Scripts page puts there. The generated .env names the compose database’s own port everywhere. scripts/example.ts says init already wrote REBASE_SERVICE_KEY. rebase build says to commit rebase.resources.json when it changes it. rebase deploy, login, logs and the other cloud commands typed at the top level suggest rebase cloud …. rebase apps config says an unlinked checkout is not linked whether or not dev runs.

  • rebase db pull works on the managed development database, the default setup. It failed at the first table with COPY 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 empty search_path, row_security off) are reset afterwards rather than left on every connection, a running rebase dev included. A source that uses an extension the managed database lacks names rebase db pull --docker.

  • rebase db pull from 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-inference and --no-data-inference work. The driver refused both as unknown options, though introspect told you to pass the first. Both help pages name them, inference no longer prints a DeprecationWarning about concurrent queries, and “Review the generated files…” is not printed when none was generated. rebase schema stale and rebase doctor name what moved, such as posts (column subtitle is in the file, no longer generated), instead of posts (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, start and the other commands parsed the same way: unknown or unexpected option: --form (did you mean --from?). rebase storage orphans no longer prints “1 more no row names are newer than 24h”.

  • rebase db push --dry-run lists 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. After schema introspect it said “No changes” while the push added <table>_default_admin_* policies that let admins read and write every row. rebase db push prints a one-line count of the RLS changes it makes, and shows each statement Atlas applies once instead of twice.

  • rebase schema introspect keeps a table’s access as it was. A table with row-level security on that does not hold the admin baseline gets disableDefaultPolicies: 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_diff database behind. The missing-atlas hint, from the driver and from rebase doctor, installs into backend/ in a workspace (pnpm --dir backend add -D @ariga/atlas), which works from the project root. db migrate --database-url on 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 restore refuses 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, --clean or --create-db --target-db <new>, instead of ending on pg_restore’s schema "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’s DATABASE_URL, .env, a branch, Postgres in Docker or the managed database, instead of “Verify DATABASE_URL in your .env file” whatever named it. db stop and db reset on Postgres in Docker say so, instead of “this project uses its own DATABASE_URL”. db restore --clena suggests --clean, and db backups lst gets a did-you-mean before anything starts.

  • 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.unique when 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 afterSave that 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 declared timezone was 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_side link 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 storageSource the panel has not loaded yet uploads to that source, never silently to the default bucket. A field with storage.public: true stores public files with stable URLs, and every panel upload names its property so the server can apply its maxSize and acceptedFiles. useBackendStorageSource reports 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 constructor or prototype header no longer crashes the mapping step; repeated headers (x, x_2) and _-prefixed columns such as a Mongo export’s _id are kept; a text column holding some numbers (S, M, L, 10, 12) gets an enum that includes them; email and url are 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 null won, 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 introspect declares an unbounded varchar as text too.

  • A collection created with “Import from table” keeps each column’s literal default (DEFAULT true, 0, 'draft', '{}'::jsonb) as the property’s defaultValue, and reads DEFAULT now() on a date as autoValue: "on_create". Before, the New form sent the type’s fallback (false for a DEFAULT true column), and the first push planned DROP DEFAULT on every such column.

  • Record forms, side panels and split views no longer throw outside a React Router data router. NavigationBlockerProvider works 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; useLargeLayout listens only while something reads it.

  • A record form reported each save twice: onSaved, and the side panel’s onUpdate, 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.

  • 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/logs and /api/admin/cron, not the deprecated aliases. The aliases drew Deprecation headers 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 DELETE executed 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.

  • A batch on the server’s own driver announces its rows after it commits, and announces nothing when it rolls back. saveMany, updateMany, deleteMany and batchWrite on 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, or auto falling back), writes made by worker and functions processes reach subscribers on the api.

  • 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 unknown type with UNKNOWN_MESSAGE_TYPE. They were answered INTERNAL_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”).

  • 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 accessTokenExpiresAt caused about 1,900 /auth/refresh calls 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: pass responseType: "text" | "blob" | "response". A non-JSON answer no longer blames VITE_API_URL when its content type says it was sent on purpose. invoke also 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 baseUrl fails with BASE_URL_REQUIRED instead of a network error; storage.getObject() failures are RebaseApiErrors with a status.

  • client.data answers "posts" in client.data, can be turned into a string, and no longer serves toString or constructor as collections.

  • admin.createUser() types the one-time temporaryPassword (and invitationSent, emailDeliveryFailed) it returns, and a generated date column takes a Date on create and update. Regenerate with rebase 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 a TypeError, 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, then and toJSON generate client.data.collectionCollection (and so on), with a warning naming both. The generated client.data.collection, .then and .toJSON never 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, and createMany keeps 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() and client.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 throws NOT_SIGNED_IN without sending POST /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 an aal2 session 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/:id and POST /api/admin/users/:uid/reset-password.

  • A session token whose uid names no account is refused with 401 instead of 503 ROLE_LOOKUP_FAILED “Please try again”. This includes a uid the users table cannot hold, such as generateAccessToken("u1", …) against UUID ids.

  • requireScope mounted with no auth middleware in front of it says so in its 401 and names requireAuth, instead of answering “Authentication required” to a valid session.

  • A VirtualTable cell 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") and TableRow accepts a ref; 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.

  • policy-authenticated-tautology reports a rule whose condition is only policy.authenticated(). Its pattern expected array [ with a space, and Postgres stores ARRAY[…], so it never fired on Rebase’s own policies. It is a high, heuristic finding, so rls-check with the default --fail-on high now 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 rebase schema. A server with a rebase_user role 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-tautology run when pasted, and policy-always-true catches NOT 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.

  • On a self-hosted container, every local bucket’s files survive a recreate. A named local bucket (bucket("media", { engine: "local" })) ignored STORAGE_PATH and resolved beside the runtime’s bundle, on the container’s own filesystem in the compose files rebase init and rebase eject write, so its uploads succeeded and went with the container. It now sits beside the default bucket’s directory: STORAGE_PATH=/storage/uploads puts media in /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 /uploads volume was root’s, so default-bucket uploads failed with EACCES too. 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 the STORAGE_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) and allowedMimeTypes, so rebase db backup to s3:// or gs://, and the backup cron set up as the docs show, failed with PAYLOAD_TOO_LARGE once 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_MISCONFIGURED naming the bucket, and an allowedMimeTypes refusal is 400 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 /metadata customMetadata 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" }) to config/resources.ts no longer hides the files already in ./uploads. The default source stays on ./uploads (or its S3_BUCKET), and media gets its own uploads__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 naming pnpm add sharp, instead of an error with a stack for every thumbnail.

  • inferTypeFromValue calls a Date a date.
  • 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 on rebase.cron_logs could 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_URL at a real Postgres). It suggested REBASE_MCP_ALLOW_REMOTE_WRITES.

  • cron_list_jobs reports refused cron jobs and their reasons. It said the project had none.

  • 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:docs now refuses such a badge on an English page, and release-docs.mjs keeps its words as plain text if one still reaches a release.

  • A private file’s signed URL is no longer handed to the next user in the same tab. The SDK’s createStorage, the admin’s useBackendStorageSource and 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 storageAuthorize hook 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 uid public or download-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 bucket is 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 in POST /api/storage/metadata. A folder/ 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.

  • A one-to-many membership write no longer takes a child that the target’s beforeQuery scope 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, aggregate select or groupBy, or vector_search naming 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. New withRelationReferencesOnly in @rebasepro/common.

  • verifyCredential reaches the verdict the HTTP routes reach. backend.verifyCredential(token) and the root verifyCredential(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 collections array keeps its read policies. config/collections/index.ts declares defaultSecurityRules for 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 dev included. 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’s config/collections/index.ts.

  • Backup and restore never print the database password. pg_dump, pg_dumpall and pg_restore receive it in PGPASSWORD instead 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 as user:***@.

  • The CLI sends the service key only to this project’s backend. With rebase dev stopped, rebase api-keys, rebase storage and rebase auth reset-password fell back to .env’s PORT (3001 in every scaffold) or to a stopped backend’s leftover address, and sent the admin service key to whatever listened there. They now use REBASE_BASE_URL when you set it, or the address the running backend recorded while its process is alive. Otherwise api-keys and storage refuse before sending anything, and reset-password resets through the database.

  • rebase dev --docker never logs into another project’s database. Projects scaffolded side by side could get the same compose port, and --docker took whatever listened there for its own db service, then suggested ALTER USER on it. dev --docker and every rebase db … --docker now 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.

  • GET /api/storage/sources is 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/upload takes file, key, bucket, storageId and metadata_<name>, and ignores anything else, so a guessed path field stored the file at the root key.

  • 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/).

  • 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/).
  • 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 puts requireAuth before requireScope; the scripts page’s snippets live in backend/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 generate and db migrate work 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, and provision covers auth, history and the role), and its install line includes drizzle-orm. scripts.md’s withAuth examples hold on the blog scaffold they name.

  • rebase schema stale is documented, on the CLI reference and the schema page, and rebase db branch delete is 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 from main and 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 push takes --database-url and --docker, as generate, migrate, branch, backup and restore do.

  • 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 push and db 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.

  • rebase db push and rebase db generate write their SQL to .rebase/sql/ instead of drizzle/. This covers schema.sql, policies.sql, search.sql, vector.sql and triggers.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 in drizzle/ 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 reads drizzle/policies.sql, point it at .rebase/sql/policies.sql and run rebase db push or rebase db generate first.

  • rebase dev --generate is removed, because what it did is now the default. rebase dev regenerates the SDK types in generated/sdk/ on start and on every save under config/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 ran rebase generate-sdk. Passing --generate or -g now stops with a message telling you to drop it, and REBASE_AUTO_GENERATE and REBASE_GENERATE are no longer read.

  • A relative --collections path is resolved from the directory you run the command in, on every command that takes one. This is the rule generate-sdk and db backup --out already followed; schema generate, db push/generate and doctor previously resolved it from backend/ instead. The same now applies to schema --output and doctor --schema/--sdk. A script written as --collections ../config/collections and run from the project root must drop the flag (config/collections is the default) or write config/collections; the CLI refuses the old spelling and says exactly this. New scaffolds’ scripts carry no flag. generate-sdk now also accepts --collections (--collections-dir remains an alias).

  • rebase generate-sdk fails on a collection file that does not load, instead of skipping it with a warning. It now loads every top-level file in config/collections/ with the same predicate the runtime boot uses, not the index.ts barrel, 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 (plus defaultSecurityRules), not a registry.

  • rebase init and rebase skills install --mcp configure 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-mcp on npm), instead of npx -y @rebasepro/mcp from the registry. A project already past init gets the devDependency added, with a message to install it, the next time configureAgents or skills install --mcp runs.

  • rebase api-keys create takes scopes. --scopes data:read:posts,logs:read names what the key may do, --roles the RLS roles it runs as, and --expires-in <days> or --expires-at <date> when it stops. --full-access is every scope the service key holds except keys:*. --permissions, --admin and --expires are removed and refused by name with their replacement. rebase api-keys scopes lists every scope this backend knows.

  • createBackupRoutes takes storageFor(destination), replacing its storage option. listBackupObjects, readBackupBytes and openBackupStream throw for an object destination they have no controller for, instead of returning nothing. BackupScheduleStatus.lastRun excludes manual runs, and adds lastManualRun, disabledInCode, refused and historyError. BackupListing adds localDiskOfThisProcess.

  • 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. SchemaCommitPaths and DEFAULT_COMMIT_PATHS now hold only schemaFile.

  • An upsert whose key belongs to a soft-deleted row is refused with 409 ROW_IN_TRASH on every upsert door (?on_conflict=, /bulk, /_batch, the SDK, the socket, and rebase.data/context.data). It used to write the new values into the hidden row and answer 201 Created.

  • An update that changes a row’s key is refused with 400 KEY_IMMUTABLE, naming the field, on every door including in-process rebase.data. It used to answer 500, and in-process the row had already moved under its new key when the write threw.

  • Strict-Transport-Security no longer sends includeSubDomains by 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 set REBASE_HSTS_INCLUDE_SUBDOMAINS=true to keep the old header. See the entry under Security.

  • Permissions are scopes: one resource:action vocabulary 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:posts is data:read on posts alone. Every signed-in person holds the data plane (data:*, storage:*, functions:invoke), because their rows are still decided by securityRules. The admin plane is held by the admin role, and by any role the app declares under auth.roles on 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, and schema:write to 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 answers 403 SCOPE_MISSING with details.requiredScope.

  • API keys hold scopes, not a permission list and an admin flag. permissions: [{ collection, operations }] and admin: true are gone from CreateApiKeyRequest, UpdateApiKeyRequest and ApiKeyMasked, along with isOperationAllowed, ApiKeyPermission and ApiKeyOperation. A service key has scopes and roles — the RLS roles it runs as beside service, where ["admin"] reads every row. Keys already stored are converted on boot to the scopes they held and never more: { collection: "posts", operations: ["read"] } becomes data:read:posts, "*" becomes the data plane, admin: true becomes the admin role and the users, schema, backups, cron and logs scopes. A function grant without write becomes nothing, since calling a function is not a read. "storage" and "functions" are no longer names in the collection field, so a collection called storage no longer shares a grant with the files.

  • schema-admin is no longer an administrator, and roles are no longer stored objects. admin is the one built-in role. A project that used schema-admin declares it with the scopes it should hold, under auth.roles. createRole, updateRole, deleteRole, getRoleById, getUserRoles, RoleRepository, RoleData and CreateRoleData are removed: the Postgres driver’s versions saved nothing, and nothing read the isAdmin, defaultPermissions and collectionPermissions they carried. getUserWithRoles returns role ids. GET /api/admin/roles lists admin and the declared roles with their scopes, and BootstrappedAuth.roleService is gone. The MongoDB driver no longer seeds a rebase_roles collection.

  • The hosted MCP server’s scopes are data:read, data:write and data:delete. Each can be narrowed to collections (data:read:posts), and the tools reach only the collections granted. Grants made with mcp:read and mcp:write keep their reach: they are read as data:read and as data:write data:delete.

  • RealtimeSocketLimits is now RealtimeSocketOptions. It carries the API-key resolver beside the limits, as the sixth argument of initializeWebsockets.

  • The remote MCP endpoint (/mcp) takes the SDK’s own arguments: where (was filter), orderBy as ["field","desc"] or "field:desc" (was orderBy + order), and data (was values). query_collection answers REST’s shape, { data, meta } (was { rows, count, truncated }). An argument a tool does not declare is now refused instead of ignored, and a limit above 1000 is refused, as on REST, instead of silently clamped to 200.
  • A write made through context.data or rebase.data (in a function, hook or seed) now throws UNSUPPORTED_OPTION if it is given ifMatch or idempotencyKey, instead of silently ignoring them. Neither option means anything for a write that never leaves the process.
  • Button and IconButton from @rebasepro/ui are typed against the element they actually render. A prop the component does not use, including IconButton’s old color attribute (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 a scorecard mapping, 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. InsightsPluginConfig now takes sources, functions that each return one record of figures and are called with the period (from, to, previousFrom), and an optional period: { days } (30 by default). An InsightDefinition names its source, its value: { field, format } and an optional comparison: { previous, intent, show }; the plugin works out the change from the two figures, as a percentage or, with show: "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 whose period.days is not a whole number of at least 1, throws when the plugin is created. Gone: ScorecardConfig, ScorecardFormat (now InsightFormat, without showSign), InsightDataResult, InsightsCache, useInsightsData, InsightWidgetSkeleton, and the description and dateRange fields — the row above the figures names the period itself. New: InsightsEngine, resolvePeriod, useInsightSource and InsightsRow.

  • 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_ENDED or TOKEN_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 in AUTHENTICATE before 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 throw PROOF_REQUIRED: pass { password } to keep the password, or { removeUnproven: true }.

  • client.auth.signUp resolves { confirmationRequired, user | null, … }, because with requireEmailVerification on there is no session until the address is confirmed.

  • mfa.enroll().recoveryCodes is null when adding a second factor. The codes are made once, with the first factor; mfa.regenerateRecoveryCodes() replaces them on request.

  • /auth/anonymous/link and 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_REVOKED on every door (/me used to answer 404).

  • 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/client signs 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 init sets 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.json or .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 --yes nothing is set up unless you pass --agent (-a), e.g. rebase init my-app --yes --agent claude,cursor. Before, rebase init only printed rebase skills install.

  • A static app can have a hostname of its own. Its path in rebase.json may 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. /api and the other backend paths answer on every hostname before any app. The app is built for the path part of the URL, and cms stays a path, on the app’s hostname. On Rebase Cloud, rebase cloud deploy registers the hostname on the project and verifies it if its DNS records are already published. If they are not, the deploy prints the records and rebase 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 --mcp registers the Rebase MCP server in each agent’s project config, the way rebase init --agent does. 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,logs mints a key that acts as you, narrowed to one project and those actions; rebase cloud tokens lists them and rebase cloud tokens revoke ends one. Set it as REBASE_TOKEN and every rebase cloud command uses it instead of a login session, with no session file read or written. The capabilities are deploy, logs, env, database and backups; none of them can restore over a live database, which stays with a signed-in owner or admin.

  • rebase cloud cron list and rebase cloud cron logs <job> [--limit N] read a deployed app’s cron jobs and their recorded runs with your rebase cloud login. The run history is in the app’s own rebase.cron_logs, behind the app’s admin gate, which accepts an admin of the app, an rk_ key or the service key, and a project owner signed in to Rebase Cloud often holds none of them. rebase cloud debug logs covers 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 (--host reads another address). --json returns the jobs or the runs as the app reports them. It needs a control plane that serves runtime-token and 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. A rebase cloud tokens key needs --can logs, and one created before this release must be created again to cover it.

  • A record’s actions can be buttons, not only menu items. An entity action declared collapsed: false is 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 as isEnabled when 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.filledByServer on 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’s posts.slug uses 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 }) and rebaseEdits are exported from @rebasepro/forms, for a form that needs to know when someone else changed a field it also touched.

  • CollectionWindow and isPropertyFilterable are exported from @rebasepro/app. Empty-state overrides now also receive isFiltered.

  • 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 admin is 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 holds users: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 holds users: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) and searchString on query_collection and count_documents.

  • Remote MCP’s list_collections returns each collection’s row and create JSON Schemas (required fields, enums, foreign keys) and its softDeleteField.

  • @rebasepro/types exports diffCollections, applyCollectionPatch, nestCollectionPatchPaths, collectionPatchProblems, isCollectionPatch, runtimeOnlyKeyIn and RUNTIME_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 as EXECUTE_SQL with mode: "script", and as RebaseWebSocketClient.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 carries schedule: 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_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 / storageDownloadTokenTtl sets the lifetime of private download URLs (default 300 seconds, at most a week).

  • resolveStorageReferences, storageReference and parseStorageReference are exported from @rebasepro/client and @rebasepro/types, for reading rebase-storage: references out of a markdown or text field.

  • The realtime socket honours impersonate, sent with the socket’s AUTHENTICATE, under the same rules as x-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.state and client.ws.onStateChange() (RealtimeConnectionState). After an outage of about 15 seconds, CONNECTION_LOST is reported once to every live subscription’s and joined channel’s onError. The subscription is kept, and its next onUpdate is the recovery signal.

  • REALTIME_MAX_SUBSCRIPTIONS_PER_SOCKET / realtime.maxSubscriptionsPerSocket (default 1000) refuses the next subscription past the ceiling with TOO_MANY_SUBSCRIPTIONS; an invalid value stops the server at boot.

  • @rebasepro/ui exports isKeyHandled(event), for a component that needs to know whether a keypress was already claimed by something above it. Dialog gains dismissOnBackdrop, initialFocus and onCloseAutoFocus; Sheet gains initialFocus, onOpenAutoFocus and onCloseAutoFocus; BooleanSwitch accepts aria-label, aria-labelledby and id.

  • Select and MultiSelect accept triggerContent, 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. RelationSelector and UserSelector already took it.

  • REBASE_HSTS_INCLUDE_SUBDOMAINS opts back into the old includeSubDomains behaviour. See the entry under Breaking.

  • New docs: “Running more than one instance” and “Backups and restore”.

  • Personal API keys. With personalKeys: true in the users collection’s auth block, 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.scopes on the users collection names app scopes such as project:deploy, with a label and an optional target, and auth.roles gives 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 declared admin. A function checks an app scope with requireScope, hasScope or getScopes from @rebasepro/server/functions; a person always holds app scopes, so they narrow keys. GET /api/auth/scopes lists 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. verifyCredential gives 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 isId and they form one key: rebase db push, the schema check at boot and the generated Drizzle file all write one PRIMARY KEY (a, b), and reads, writes, nested paths and via relations address a row by every key column (company_translation/1:::en_US). 0.23 refused a second isId when 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 a via relation. rebase schema introspect now generates a table keyed on several columns, with isId on each key column. It still skips the table when a key column cannot carry isId or another table points at it. The live schema editor treats re-keying an existing collection as a migration and names the new key. rebase doctor and 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/server exports configureAccess, and accessModelFromCollections to build the model from the users collection, beside configureJwt, so a server or test built without initializeRebaseBackend answers for a person the way a booted backend does.

  • pnpm check:tsconfig-entries fails a tsconfig include/paths/ extends entry that resolves to nothing. pnpm typecheck reads tests/e2e again.
  • 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 answers x-rebase-impersonate with 501 IMPERSONATION_UNAVAILABLE. @rebasepro/types exports the header name as IMPERSONATE_HEADER.

  • sid and exp on access tokens, and isCurrentSession in 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 answers 403 EMAIL_NOT_CONFIRMED until the address is verified; /register then no longer says whether an address has an account. Registration now mails the verification link either way; POST /auth/verify-email is the new door the SDK uses.

  • auth.refreshTokenReuse ("reject", the default and today’s behaviour, or "revoke-session") and auth.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 with ACCOUNT_DISABLED; stored in the new users.disabled_at column, which db push plans.

  • 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), and POST /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 an aal2 session. 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) and confirmEmailChange(token); /auth/me and User carry pendingEmail. New beforeEmailChange hook and email.templates.emailChange / emailChangeNotice. Postgres gains users.pending_email, email_change_token and email_change_sent_at, added at boot and planned by db 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) and REBASE_PLATFORM_TOKEN_AUDIENCE (the project’s id) set accepts an rpt_ bearer token signed by the hosting platform: ES256, iss: "rebase-cloud", aud equal to the audience, a sub, and at most ten minutes between iat and exp. The caller acts as platform:<sub> with no roles, and holds only the scopes in the token’s scope claim that the server allows a platform to hold. Today that is cron:read alone, whatever the token asks for, so it reads /api/admin/cron and its logs and gets 403 from every other admin route. Each accepted request logs Platform token accepted with the sub and jti. 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 every rpt_ token with 401 PLATFORM_TOKENS_OFF.

  • impersonate on createRebaseClient runs every request as another user, named by uid. It is sent as x-rebase-impersonate on each HTTP request and as impersonate on the realtime socket’s AUTHENTICATE. 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 with 400 IMPERSONATION_UNSUPPORTED rather than answer as the administrator.

  • client.offline.pending({ orphaned: true }) and client.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.

  • Realtime and SQL admin: EXECUTE_SQL_SUCCESS carries effectiveRole when a statement asked for a role, and SqlScriptResult has effectiveRole. BranchInfo has a required database field.

  • compileRulePolicies(collection, rule, options) in @rebasepro/common is the one compiler of a security rule into Postgres policies. The schema planner delegates to it.

  • 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.
  • The translation key studio_api_keys_admin_reads_every_row is renamed to studio_api_keys_admin_bypasses_rls. An app that overrides it must rename its override. The keys studio_policy_help_role_authenticated and studio_policy_help_role_anon are removed, with the policy help that named roles no request runs as.

  • POST /api/admin/schema/plan|apply and /api/admin/schema-editor/collection/save accept a patch (operations on key paths) for an existing collection, instead of only a whole collection object; /apply accepts sourceOnly: true and remove: true. A whole collection posted 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 DEFAULT instead of being reported as requiring a migration.

  • validation.max on a text column is no longer reported as a column change in the live schema editor’s plan.

  • useLiveSchemaEditing’s writeSourceOnly option is removed. “Edit source only” is now a committed /apply call with sourceOnly: 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 introspect writes more explicit property keys than before: columnType, precision/scale, defaultValue, required, the key’s real strategy, onDelete and the search block. A uuid key with no database default is now written as isId: "manual" (it wrote "uuid", which made db push add a default that was never there); a serial key is written as isId: "manual" with columnType: "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 push treats 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.cron.listJobs() returns a CronJobListing, which carries the jobs the server refused (rejected) and the files it could not load (skipped, note) beside jobs.

  • Generated database.types.ts entries carry a Slug field (regenerate with rebase generate-sdk; older generated files still work).

  • onSyncError reports a write discarded because an earlier write to the same row was refused, with code DEPENDENCY_REJECTED and the earlier refusal as cause. Before, the discarded write carried the refused write’s own error.

  • An upload into a property’s storagePath that breaks the property’s maxSize/acceptedFiles is 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 an http:// address the browser blocks.

  • getSignedUrl/deleteObject given an s3://bucket/key or gs://bucket/key address the object by key, so a file stored with includeBucketUrl on S3 or GCS can be previewed and deleted.

  • ./tooling/scripts/verify-quality.sh runs every end-to-end suite CI runs, through the new pnpm ci:e2e (lanes vitest, cli, selfhost, admin). No suite stops the ones after it, and nothing is retried. Needs Docker.

  • pnpm check:api-surface passes an additive export change and banks it into contracts/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_Angeles in CI and in verify-quality.sh. A test that needs a specific zone pins it with tooling/scripts/jest/zone-environment.cjs.

  • change-password answers 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 400 INVALID_JSON.

  • onAuthenticated also fires for OAuth, refresh, password reset and MFA; beforeLogin("oauth") and beforeUserCreate run on OAuth sign-in and sign-up; afterLogout fires for SDK and CMS sign-outs.

  • Sessions record the client address the rate limiter resolves (TRUSTED_PROXY_HOPS) instead of a raw X-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.

  • A record opens from a via relation’s tab. Opening a row through a joinPath relation (an order from a product’s Orders tab, through order_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 a table.column spelling, and its columns only by Drizzle property key: a step written as JoinStep documents 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-one via writes had the same table lookup and refused every write; they resolve by position too.

  • The OpenAPI spec says a delete on a softDelete collection moves the row to the trash. It documents ?hard=true on DELETE and bulk delete, and ?deleted=include|only on list, single read, count and aggregate. A column named deleted is 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_STRING names. 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 in drizzle/migrations/ at the project root. rebase db generate writes them next to the backend, in backend/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 /admin and another at /admin/beta, a link such as /admin/beta/settings answered 404 instead of /admin/beta’s index.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 404 through the socket, MCP and rebase.data, as it already was over REST. An update of a missing row no longer runs beforeSave before the 404.

  • 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’s find(), as listen() already did. Naming the relation in fields is no longer a 400.

  • A REST list answer always carries meta.offset. It is 0 when the request named none; before, meta.offset + meta.limit was NaN on 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 with 400 ORDER_BY_FIELD_NOT_SORTABLE, and no meta.nextCursor was ever issued for one.

  • storage.putObject({ file, public: true }) with no key stores 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=true for 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/server ships 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 .dump object in the bucket is refused.

  • An unknown GET under /api answers the JSON NOT_FOUND envelope when a static app is served at /, instead of a plain-text 404.

  • CORS_ORIGINS / FRONTEND_URL match the browser’s Origin with 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_EXHAUSTED with Retry-After (and a connect timeout answers 503 DB_CONNECT_TIMEOUT) instead of 500 INTERNAL_ERROR; /health reports 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 PORT and honours PORT=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, in rebase.cron_logs and the in-memory history, which only the admin-gated /api/admin/cron/:id/logs reads. The process log got one line per run, ✅ [cron] "<id>" completed in Nms, so nothing a job said about itself showed up in rebase cloud debug logs. A managed app’s AI step that fell back with model unavailable (quota_exhausted) left no trace there, and the app wrapped log in 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 at info with a cron field holding the job ID. That copy is redacted like any other log line: the values of keys such as password or apiKey, and the SQL and bound parameters of a logged query error, are replaced. The lines stored with the run are unchanged.

  • Queues and topics declared in config/resources.ts now run. The worker used to start with 0 tasks and every enqueue/publish threw “no queue runtime is installed”: the server’s inlined copy of @rebasepro/types and the project’s own copy each kept their own registry. A third-party driver’s registerDataSourceCapabilities had the same split, and is fixed too.

  • afterSave, beforeDelete, afterDelete and history now receive the row as stored, not afterRead’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_user USAGE on the rebase schema, and on collection schemas, as soon as provisioning creates them. Writes to history: true collections, 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.data delete 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.data write re-runs itself without end is stopped at depth 16 with 500 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 timeoutSeconds of 0 or NaN is refused and listed under rejected, 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 KEY with 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 with Missing required field: "id". The implicit key now defaults to gen_random_uuid()::text; a table created before this gets the default at its next boot, and a key you send is still used.

  • A hasOne relation 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 generated schema.generated.ts booted, but reading the relation with include failed with “There are multiple relations between…” once the child had two links back to the parent, and for every hasOne to its own collection. Both now name the join columns, and the related row is typed as possibly null.

  • 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 5 was refused as an unknown command 5, rebase cloud debug --host staging.example.com as staging.example.com, and rebase cloud projects --name shop create as shop. The same happened at the top level: rebase db --collections ./config/collections push, rebase api-keys --name ci create and rebase skills --agent claude install were each refused as an unknown subcommand. All of these lines now run the action they name. --help on such a rebase cloud line 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 requests and rebase cloud debug boot say 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 --tail without saying so, and its truncated flag covers only its byte budgets. So --since 30d --tail 200000 returned 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/lastTimestamp under --json). A pod that returned the full line limit and whose earliest line is later than the start of the window is marked truncated, with a truncatedReason saying how much of the window was not read. A pod that started recently returns fewer lines than the limit and is not marked. A --tail above 2000 is lowered to 2000 before the request, and the output says so (tail.note). A --tail that is not a positive whole number is refused.

  • rebase cloud debug boot reads 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 --help lists --since, --tail and --previous under the actions that take them, with each action’s own default. The help said “Default: 500” for every action, but requests reads 1000 lines, and it offered --previous to requests, which rejects it.

  • rebase db push stops before a column type change that can lose values — numeric(5,2) to numeric(4,1), bigint to integer, jsonb to text — 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 push no longer fails forever after you change the type of a column a search block 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-run on a synced database prints “No changes”.

  • rebase schema introspect followed by rebase db push no longer plans type changes, dropped defaults, dropped NOT NULLs, a phantom id key 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 doctor checks 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 dev and rebase 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 or git init-ed directory. A directory may already hold .git, other repository metadata, OS litter files or a LICENSE; any other entry is refused by name, with the ways forward. --git reuses an existing repository instead of re-initialising it.

  • Saving a collection during rebase dev restarts the backend once, after the schema and SDK are regenerated. It used to restart twice, and the first boot told you to run rebase schema generate.

  • rebase start warns at the top when it is not running as a production server (NODE_ENV from .env or unset), and says what that means: the first account to register becomes the admin.

  • rebase build builds each static app once. It used to run the frontend’s vite build && tsc twice: once to fold it into the backend bundle, once for its own bundle.

  • rebase build --help lists --output, --no-static and --skip-static-build. rebase db url --help lists --database-url and --docker. rebase normalize-imports --help prints its own help instead of the global one.

  • rebase apps config <app> prints an empty VITE_API_URL for an app the backend bundle serves on its own origin. It no longer prints the address of a rebase dev that has stopped.

  • Printed next steps now work for the reader they target: rebase init --yes ends with an --agent command; rebase start explains how to reach an app that answers only on its own hostname; a failed rebase upgrade install names the command that puts the pins back.

  • The docs, the scaffold and rebase-server --help no longer say boot leaves junction-table RLS to db push. Boot applies it.

  • rebase skills install --agent codex writes 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 .gitignore keeps .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 upgrade leaves files git ignores alone. It read every package.json and pnpm-workspace.yaml under the project, so a gitignored copy — a stale build context under scratch/, a vendored package — was listed by --dry-run and rewritten with the rest. In a git repository it now reads only what git ls-files lists: tracked files, and untracked ones no .gitignore excludes. 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 migrate and off, and either one stopped the backend from starting. It now lists ensure and none, says any other value refuses to boot, and points migration files at rebase 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 offers manual, uuid and a SQL strategy, and says cuid is refused.

  • useBackendStorageSource addresses 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:read scope, every load called GET /api/admin/schema/status and GET /api/schema-editor/status, and both answered 403. On a deployment without storage, every load also called GET /api/storage/sources, which answered 501 and was logged at ERROR. These failures filled rebase cloud debug errors and hid the real ones. The panel now asks for the schema status only when GET /auth/scopes says the user holds schema:read. It asks for the storage sources only when GET /auth/config does not report storage: false. A custom AuthController takes part by reporting heldScopes and storageEnabled. 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: false now 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_LOST into 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 fixedFilter work. 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 collections or views builder 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 sort offers “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/hasOne children, or a one-to-one via target, to the copy. The copy keeps its own belongsTo key and its manyToMany links.

  • Property conditions (required, hidden, disabled, readOnly, min, max, defaultValue, acceptedFiles, maxFileSize) are applied by the record form, and a dynamicProps validation 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/joints opened, 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 reach medico/v2.0.0/joints/j1/movements. A reference filter in the URL into such a collection keeps its whole path, and users/abc123 is no longer read as the collection user.

  • A Firestore collection that declares its own path is read, written and referenced at that path. A collection registered as fs_diagnosis with path: "diagnosis" (beside a Postgres collection called diagnosis), or as medico_joints for documents at medico/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 to diagnosis/abc opened 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 own driver. 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/A or 1,234 in a price column became empty. Text that spells a number exactly, such as 10.00, still counts as one.

  • A file imported into a new collection keeps every column, whatever its headers. First Name and first_name became one property, and so did Price ($) and Price (€); a header row in Japanese, Cyrillic or Greek collapsed to a single property with an empty key. A header that slugs to nothing is now column_<n>, and a taken key gets _2, _3 and so on.

  • 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 dev running 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 authenticated and anon roles, which Rebase never creates.
  • The schema visualizer draws the table db push creates (blog-posts becomes blog_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/logs answers 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:// and gs:// 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: false reads as off, not paused.
    • A refused job is reported with the reason, and the history is shown as unreadable when it cannot be read.
  • 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 and listen() 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 carries impersonatedBy. An API key, the service key or a non-administrator gets 403 IMPERSONATION_FORBIDDEN. A uid with no active user gets 404 IMPERSONATION_TARGET_NOT_FOUND. Every other route refuses the header with 400 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 commit with 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 computed value), 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 a hasMany over 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.ts too, 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 UPDATE are 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. Splitting BEGIN, an UPDATE and ROLLBACK across three separate runs used to commit the UPDATE and 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; a COMMIT inside 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 socket requests are no longer sent twice after a dropped connection. A request already in flight when the socket closed now fails with CONNECTION_LOST instead of being replayed on reconnect — one save() used to make two rows, and the Studio SQL console could run a statement twice. A request queued while disconnected fails with REQUEST_TIMEOUT after 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 /health report it, and /health turns degraded after 60 seconds down.

  • Retained channels on the postgres bus 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. /health lists the connection as cross-instance while 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.

  • A file under a folder named “default” is the file the SDK reads and deletes. For a key like default/photo.png, getSignedUrl, getObject and deleteObject used 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 under default/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/refresh or /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 mode session.refreshToken is "", and the client never sends it as a token. A refresh refused with 400 is now logged with console.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 recommended no-empty-object-type refuses in any project that lints its generated types; it is now Record<never, never>, the same type for include.

  • upsert() without onConflict updates the row already at that key, instead of answering 409, 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-* and Preference-Applied: the runtime and the ejected backend now expose them. Before, etagOf(row) was always undefined cross-origin, and ifMatch writes 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 takes offset to page past them.

  • Generated types no longer let you write a field nobody may write; a non-required column can be written as null, and hasMany/manyToMany memberships (with _pivot) are writable.

  • A typed client refuses an unknown collection accessor and an unknown include relation at compile time, types returning: false results as possibly undefined, and types collection("snake_case_slug") and client.collection(slug).

  • An offline create refused with a 409 (a unique value already taken) is rolled back and reported to onSyncError instead of being quietly dropped from the queue as synced.

  • With offline on, find({ where: { field: undefined } }) and the bare PostgREST string form no longer throw a TypeError.

  • 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 offline on, listen() shows the user’s queued edits instead of the raw server row, and listenById() 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 offline on, 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 for auth.isInitialized().

  • With offline on, 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: 0 the queue waited for the app to call sync(), 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/refresh answered {"refreshToken": ""} with 400 INVALID_INPUT before reading the cookie, and @rebasepro/client sends 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 empty refreshToken now counts as no token on /auth/refresh and /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. deleteExpiredTokens had 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 in rebase.cron_claims as rebase:auth:expired-tokens. The Postgres repository’s sweep now covers magic links, email codes, refresh tokens and MFA challenges as well as reset links.

  • providerId keeps saying how the session was signed in after a refresh. /auth/refresh and /auth/me answered password for every session, so a Google user read google at sign-in and password an hour later, and a guest read anonymous, then password. The method is stored with the session (refresh_tokens.method, added at boot) and carried across every rotation; sessions signed in before the upgrade read password.

  • Remote MCP reads return REST’s rows (ISO dates, a belongsTo as its foreign key), not the admin view model. A row that was read can be sent back in update_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.

  • 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.

  • unqualified-column-in-subquery fires 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.

  • The CLI end-to-end suite passes again: it looks for db generate’s output in .rebase/sql and drizzle/migrations, and it no longer leaves the backend it starts running after the suite exits.
  • 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 with ts-morph, which the image did not ship. The editor reported SCHEMA_EDITOR_MISSING_DEPENDENCY and said to run pnpm add -D ts-morph, which a managed project cannot use to reach the server. The image now installs ts-morph 28.0.0, the version the server pins.

  • GET /api/storage/sources on a deployment without storage answers 200 {"data": [], "configured": false} instead of 501 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 carries configured: true. GET /api/auth/config now includes storage, 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_TYPE and 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 with STORAGE_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) answers 400 INVALID_STORAGE_KEY instead of 500.

  • /api/storage/file/DEFAULT/public/x, public//x and .%2Fpublic/x are served anonymously like public/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. A PATCH that arrives while another is being received now answers 423 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 ETag and a Last-Modified of now, so nothing ever answered 304, every ?width= request re-encoded the image, and with storageRenditionCache on 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’s EISDIR, and /metadata/<folder> answered 200.

  • 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 from rebase.channel_messages. Before, any database login could LISTEN and read every retained channel’s traffic. Ephemeral broadcasts and presence still travel in the payload.

  • Change capture no longer broadcasts changed rows. The rebase_cdc NOTIFY payload now carries only the changed row’s key (plus a junction’s two ids). Previously any database login could LISTEN and 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 with REALTIME_CDC=off.

  • 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() is null, and context.user (null under “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.

  • 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.
  • SVGs are served as image/svg+xml under Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox on every serving path, so an SVG opened directly can no longer run script on the API’s own origin. Before, it downloaded as application/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 own rebase.storage calls 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 as GET /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 /metadata minted a download token for it. The check now runs on the key the route serves. storageAuthorize hooks should test key with the new isPublicStorageKey from @rebasepro/types; isPublicStoragePath stays for client-side paths and URLs.

  • A private file under a folder named default (key default/public/x) is no longer served with Cache-Control: public, and new projects’ config/storage.ts hook no longer treats it as world-readable.

  • storageAuthorize and policy predicates receive the storage source the request is served from. ?storageId=%20private reached the hook as " private" while the private source served, deleted or took the write. The default source is now always undefined, 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 grades auth.jwt() IS NOT NULL and auth.role() IS NOT NULL policies on Supabase as critical. They were graded low and described as excluding anonymous callers, which they do not.
  • Strict-Transport-Security no longer includes includeSubDomains by default; set REBASE_HSTS_INCLUDE_SUBDOMAINS=true to opt back in (see Breaking). Static apps, the CMS included, now send Content-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:write role 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 grants admin. 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 defaultRole that is admin or a declared role holding users:write, schema:write or 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 accepted schema-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-password ends 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 stamps tokens_valid_after in the same transaction, as the API reset does.

  • A rebase db push that 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, while rebase_user already held read and write on a new table through the schema’s default privileges; a push stopped by a refused search block 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 with REBASE_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.

  • An API key on a nested path needs read on every parent collection in the path. A key scoped only to posts that calls /authors/1/posts now gets a 403 until it can also read authors. 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_SIZE and the DB_POOL_* variables. A blank value now means unset and takes the default, and an explicit 0 still counts. See the entry under Fixed.

  • A session lifetime the token signer cannot read refuses to boot. auth.accessExpiresIn and auth.refreshExpiresIn (JWT_ACCESS_EXPIRES_IN, JWT_REFRESH_EXPIRES_IN) take a positive number and a unit, such as 15m, 1h, 30d or 2 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 without unlinkUserIdentity answers that sign-in with 409 UNVERIFIED_IDENTITIES instead. updatePassword must also accept null, which removes the password. The Postgres store implements both; the MongoDB store has no unlinkUserIdentity yet. 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/link needs a solved challenge too. register is one of the default captcha routes. An app that turns guests into accounts must now send the token, in the captchaToken body field or the cf-turnstile-response / h-captcha-response header, as it does for /auth/register. See the guest-upgrade entry under Security.

  • 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 to verifyEmailUrl or magicLinkUrl. A config that set only those booted and sent dead relative links in its password-reset emails. Set FRONTEND_URL or auth.email.resetPasswordUrl to an absolute URL. See the entry under Fixed.
  • A custom job store must take a claim when it completes or fails a job. JobStore.complete(id, claim) and fail(id, error, retryAt, claim) now receive a JobClaim ({ workerId, attempt }) and must change the row only while that claim still holds it: running, locked by that worker, on that attempt. The new optional heartbeat(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.
  • The cron scheduler and store expose the shared job state. CronScheduler gains persistJobEnabled(), fetchJobs() and fetchJob(); CronStore gains optional saveEnabledOverride, fetchJobStates, tryAcquireRunLease, releaseRunLease and fetchRunSummaries — a custom store without them keeps the per-process behaviour — and CronJobPersistedState, CronRunLease and CronJobRunSummary are exported.

  • A grouped aggregate can be paged, and its order is stable. /aggregate used to drop offset, page and orderBy, and its query had no ORDER BY, so every page was the same arbitrary set of groups. It now accepts offset/page and orderBy (a groupBy field or a result key such as count), always ends its ordering on the group keys, and returns meta.hasMore. Paging options on an aggregate without groupBy, or a cursor, get a 400 INVALID_AGGREGATE_WINDOW.

  • SecurityRule accepts a raw withCheck without a using. The new RawSQLCheckOnlySecurityRule variant 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.
  • auth.mfa.challenge and auth.mfa.verifyChallenge take { mfaToken } to finish a sign-in that answered MFA_REQUIRED. Before, the only way to send the pending token was client.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 emits SIGNED_IN like any other sign-in.

  • admin.listUsers() takes limit, offset and search, and returns total. 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.

  • An account with a second factor can sign in to the admin. The server answers such an account’s password with 401 MFA_REQUIRED and 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 optional startMfaChallenge and verifyMfaChallenge.

  • 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 gains verifyEmail.

  • 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/yyyy mask, 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’s Select and MultiSelect gain chevron, MultiSelect gains anchorRef, and DateTimeField and VirtualTableDateField gain pickerButton and inputRef.
  • 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_by and created_at keep the original creator, beforeSave/afterSave get status: "existing" with the previous values, and history records an update. This covers ?on_conflict=, bulk and _batch upserts, the SDK’s upsert() and createMany(rows, { upsert: true }), and a socket SAVE { upsert: true }. An upsert on the key of a row outside the caller’s beforeQuery scope 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 push asks before a column type change that can lose data. The destructive gate only looked for DROP and TRUNCATE, so ALTER COLUMN … TYPE date or TYPE integer was 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 a numeric with room for their digits), or varchar→text. Push reads each column’s current type from the database to decide, and a column it can’t find is gated.

  • 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 MCP cron_trigger_job tool now reject; the skip is still written to the run history and is in details.log, naming the process running the job. PUT /api/admin/cron/:id also accepts enabled: 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.claim takes 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 login in a checkout whose .rebase/cloud.json names a host you have not signed in to confirms that host at a terminal, and answers unknown_control_plane in JSON mode or without one. A script that signs in from such a checkout passes --url or sets REBASE_CLOUD_URL. See the entry under Security.

  • rebase cloud domains works 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, so remove reported success while the host stayed served, and add + verify called a host live that was never served. list now shows every host, add registers one, and verify [domain] and remove <domain> act on that host; naming the domain is required when the project has more than one. domains list --json returns { projectId, tenantHost, domains: [...] }, and add, verify and remove return 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 --output refuses 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 --output is now resolved once, against the directory it was typed in.

  • policy-always-true softens a USING (true) policy only for a RESTRICTIVE policy that gates the same roles and command. Supabase’s documented MFA gate (AS RESTRICTIVE TO authenticated) next to USING (true) TO anon came out medium while anon read every row, so the default --fail-on high exited 0. A restrictive gate now has to cover the permissive policy’s command (ALL needs ALL) and every exposed role it reaches.

  • rls-enabled-not-forced counts 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 when anon, authenticated or a --role owns the table or inherits the owner.

  • Restoring a soft-deleted row with a PATCH works. Setting the soft-delete field back to null, the documented restore, answered 404 on PATCH /:id, PATCH /bulk and _batch, because the update looked the row up without the trash. A restore now finds the trashed row and honours an If-Match taken with ?deleted=include; any other edit of a trashed row is still a 404.

  • A _batch create or upsert, and a create over the realtime socket, that misses a required field is refused naming the field. It is a 400 VALIDATION_CONSTRAINT before 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 null produced a 500, and 42, true, a string or an array were saved as a row’s values.

  • Nested writes honour If-Match, Idempotency-Key and field-operation checks. A nested PATCH or DELETE with a stale If-Match wrote 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=minimal included, 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-Key covers the query string. DELETE /posts/5?hard=true sent 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 /count honours ?deleted=. It counted the live rows while the trash listing beside it showed the deleted ones.

  • An If-Match taken from a GET is accepted on a collection with an on_update date. 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:

    • $push could store a value the enum does not define, or push more elements than max allows;
    • $merge could write map keys the map’s properties reject;
    • a fractional $inc could land on an integer property.

    These are now the same 400 VALIDATION_CONSTRAINT a plain value gets, at every write entry point.

  • GET /api/admin/logs?offset=0 is the first page. It used to be a 400 INVALID_PARAM.

  • The OpenAPI spec says a distinct read has no meta.total. It used to say total counted distinct rows, but the listing leaves total out 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 /api route, the unauthenticated auth routes included. A blank PORT made a production server listen on a random port, and a blank SMTP_PORT or DB_POOL_* became 0 too. A compose file with VAR=${UNSET} is the usual way to hit this. Blank now takes the default, an explicit 0 still counts, and a value that isn’t a number fails the boot naming the variable. configureMiddlewares also refuses a maxBodySize that is not a finite number.

  • Backup downloads stream. GET /api/admin/backups/download read 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 a Content-Length, and object-storage backups no longer make a second in-memory copy.

  • The scheduled RLS audit refuses a bad intervalMs and never runs two scans at once. An intervalMs of 0, a negative number or NaN, 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, and runNow joins the scan in progress.

  • backend.shutdown() stops the metrics sampler. The stop returned by metricsHistory.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.

  • 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=true runs the scheduled RLS audit on a bundle boot. On the runtime image, rebase start and rebase dev it did nothing, and /api/admin/rls-audit kept 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 dev and the control plane write DATABASE_URL__STRASSE, while the runtime asked for DATABASE_URL__STRA_E and 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 by customizeAccessToken never reached rebase.jwt() or claim-based tenancy, over HTTP or WebSocket. A collection with tenant: { from: { claim } } failed every create with TENANT_REQUIRED and read nothing.

  • Changing a user’s email to one another account holds answers 409. PUT /admin/users/:uid returned 500, because neither engine translated the database’s duplicate-email error on update. /auth/anonymous/link could hit the same 500 when it lost a race.

  • Auth rate limits count in the store REBASE_RATE_LIMIT_STORE names. 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 /register and 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.5h or 30 days silently became 400-day refresh tokens. 15 minutes produced a 15-minute token while accessTokenExpiresAt said 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 as undefined in 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-password route does not match. The self-service reset from the same config already linked correctly.

  • A user created through POST /users/bulk, a _batch create or a realtime socket SAVE is created as POST /users creates one. The password is accepted and hashed (or generated), the email is stored normalized, the account is verified, onCreateUser runs, 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.

  • 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 failed context.data write 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 afterSaveError survives 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 a context.data of 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), MCP create_document with an id and a socket SAVE { 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, where tags uses softDelete, 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, so GET /docs/10/history never 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 } } on stock: { validation: { min: 0 } } stored -995 while { stock: -5 } was a 400; a $push past validation.max onto a non-empty array and a $pull below validation.min also 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 400 VALIDATION_CONSTRAINT naming the field and the bound, and two concurrent decrements cannot both get past the floor. Numbers get min, max, moreThan, lessThan, positive and negative, arrays their min/max item counts, and it holds for in-process writes through rebase.data too. MongoDB does not check an operation’s result yet.

  • A beforeQuery scope 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 — with Could 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 same 404 delete has always given. Collections that declare no beforeQuery are unaffected: the pre-read there stays best-effort history enrichment.

  • The driver’s fetchCollection honours logical, include, fields, distinct, withDeleted and searchExplain. It passed on only ten of its props. Over the socket, MCP, or the SDK without a REST fetch service, an or(...) read returned every row while COUNT answered 1, a withDeleted: "only" trash view showed live rows, and include returned 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. On members(project_id, user_id), p1:::bob read whichever p1 row came first, so a beforeDelete protecting 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:desc two at a time stopped after page one; ascending paging repeated a row, and at limit=1 it never ended. This hit REST ?after=, socket startAfter and the SDK’s iterate().

  • 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, and startAfter with a vector search is a 400 VECTOR_CURSOR_UNSUPPORTED.

  • A beforeSave hook’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 with rebase schema introspect, produced REFERENCES "authors" ("authorId"). db push could not build its schema, and at boot the constraint failed with 42703 on every start, junction keys included. Linking columns were also typed from isId alone: a key with columnType: "uuid" got TEXT columns whose foreign key could never be created, and a bigint key 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_status and orders_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.unique only to tables it created in the same run, so a new unique field on a live managed collection accepted duplicates and onConflict upserts 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 time or date column no longer breaks the schema. The default was always written as a full ISO timestamp, so a time column 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" became public.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 belongsTo used as tenant became NOT NULL but kept ON DELETE SET NULL, so the delete failed with 23502, naming the child’s column. It now defaults to RESTRICT, and an onDelete the 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 RESTRICT refusal (23001), or a NO ACTION one (23503), came back as 500 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.tags and posts.featuredTags both resolved to posts_tags, and featured_tag_id was never created, so the second relation failed on every read and write. Planning now names both relations and suggests a separate through table. The two sides of one link still share their junction.

  • Querying a many-to-many from either side through schema.generated.ts works. The generated junction relations used a name the far side never declares, so db.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-items or Sales 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 no schema, so the runtime served (and boot created) an empty table of the same name in public, and the real rows were never read. A junction-shaped table outside public is now generated as its own collection rather than a manyToMany, because Rebase only reads and creates junction tables in public.

  • rebase doctor and db push look for a many-to-many junction in public, 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 push listed the junction under the target’s schema, which hid the real public junction from Atlas, and Atlas planned to create it again.

  • db migrate, db generate and db restore ignore the flags the CLI relays. --docker, --database-url <url> and --debug ended up as positional arguments. rebase db migrate --docker failed with Atlas’s unknown flag: --docker after it had already written the auth schema, db generate --debug named its migration --debug, and db restore --database-url X latest looked for a backup called --database-url.

  • Boot reports a numeric column whose precision differs from its property’s. The check meant to catch a precision/scale property sitting on an unbounded numeric column never ran, because it compared against the wrong name for the type. It now compares the actual precision and scale, reading NUMERIC(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 list showed it, and retention counted it as one of the newest backups to keep while pruning a real one. rebase db backup already deleted it; the scheduled job now does too.

  • db push --dry-run, db generate and db migrate no longer leave <db>_dev_diff behind. Only a push that actually applied changes dropped Atlas’s scratch database, and db migrate created 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.

  • An afterRead that 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 online or 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/posts never refreshed posts or a posts/43 subscriber, and a post saved via posts never refreshed authors/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_notify now keeps every primary-key column instead of id alone. Existing databases pick this up on their next boot.

  • A visitor with no account can listen(), listenById() and observe(). The socket treated the empty token as a refusal and failed every subscription client-side with “user not logged in”, although a server with requireAuth: false serves anonymous subscribers and find() next to the same listen() worked. On a requireAuth: true server, the server’s UNAUTHORIZED now reaches onError at once instead of a 30-second “Subscription timed out”.

  • listen() refuses an undefined filter value and a cursor. The socket sends filters as JSON, where undefined becomes null, so ["!=", uid] with uid unset subscribed to every row whose owner is NOT NULL, widening an ownership filter that find() refuses. after was silently dropped, so page one streamed to a caller who asked for page two. Both now arrive through onError, as INVALID_FILTER and CURSOR_NOT_LIVE, and observe() reports such a query once.

  • 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 IAM signBlob API, which the runtime account may call only with Token Creator on itself, so every private object’s metadata read answered 500 and every getSignedUrl on the client failed with it. Controllers can now describe an object with the optional getMetadata, which GCS implements without signing.

  • A named bucket keeps serving when production drops the local default. A project that declared bucket() next to bucket("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 501 STORAGE_NOT_CONFIGURED and 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/list answers 400 INVALID_LIST_OPTIONS for a bad maxResults or pageToken. maxResults=0, -1 or abc returned an empty page whose next token was the same one, so a while (pageToken) loop never ended. pageToken=-1 returned 500 on local storage, and a negative MaxKeys reached S3.

  • Email refuses to boot unless password-reset, verification and magic links all have an absolute base URL. A config with only verifyEmailUrl booted 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.

  • 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/:id with {"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, or null to follow the job’s own enabled — 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 after timeoutSeconds plus 30 seconds (an hour for Infinity), 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_claims missing 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, and shutdown() 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 its ctx.signal or 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 timeoutSeconds longer than about 24.8 days, or Infinity, 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, Infinity means no timeout, and 0, negative or NaN values 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 timezone on 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.jobs row ended succeeded with no error. It is now dead-lettered after one attempt, with the reason in last_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.

  • /mcp has the body limit and per-person rate limit the data API has. /mcp is mounted outside basePath, 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 applies maxBodySize (413) and the data API’s rateLimit, 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_scope with 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 .env files the way the CLI does, and spawned commands no longer inherit the server’s startup .env. The gate parsed .env with its own regex and stopped at the first value, so a backend/.env with a localhost line above a production one, or a root .env with export DATABASE_URL=<prod>, was cleared as local while rebase db push connected 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 .env as if it were shell environment, which outranks both the branch pointer and the active project’s own .env: after rebase_db_branch_switch the next push still went to the main database, and after rebase_project_switch project B ran against project A’s DATABASE_URL.

  • The MCP destructive gate no longer treats DNS names starting with 127. as loopback. 127.0.0.1.db.example.com counted as local.

  • The MCP rebase://collections/… resource can no longer read a sibling directory. ../collections_private/x got past the path check.

  • rebase_auth_reset_password finds an existing user reliably and reports “not found” as an error. It searched with limit: 1 on a substring match, so ann@x.com was “not found” when joann@x.com sorted first, or when the case differed, and that message came back as a success.

  • 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_GAP error 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_ERROR and the key to retry it under, where OFFLINE_FIELD_OP_UNSUPPORTED advised 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() or aggregate() condition on a column called select, page, hard, fields, where or 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_USED on 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; createMany duplicated 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-key createMany that was never sent is now refused with OFFLINE_UPSERT_UNSUPPORTED, as upsert is.

  • With offline mode on, a projected read no longer strips columns from cached rows. find({ fields }), and listen/observe with fields or distinct, 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/json and the server answered “No file provided”, so every upload made just after an access token expired failed.

  • iterate() and findAll() continue from the caller’s cursor or offset. The caller’s after was sent next to the walk’s own offset=0, which the server rejects with a 400 (CURSOR_WITH_OFFSET), and .offset(40).iterate() quietly started over from the top. A caller’s after now starts a cursor-based walk from that point, and a caller’s offset is where an offset walk starts, over HTTP and in-process.

  • A Date in 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 configured fetch and headers. It called the global fetch with only an Authorization header, so a client with a custom fetch or default headers had every call but this one go where it was told. A refusal is now a RebaseApiError carrying status.

  • call(endpoint, payload) sends false, 0, "" and null. It tested the payload for truthiness, so those valid bodies were dropped, although functions.invoke() sends them.

  • 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 clearOnDisabled property is no longer written as null by 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 DateTimeField also 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, toString or valueOf crashed the import’s type inference; it is now inferred like any other.

  • Signing out while defineRolesFor is 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 an authController waits 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 onSignOut used to fire twice.

  • The Vite plugin finds an absolute collectionsDir and ignores test files. An absolute directory produced an admin with no collections and no error. A *.test.ts file beside the collections was bundled and blanked the admin with describe is not defined. On Windows the collection transform never ran, so server callbacks shipped to the browser.

  • useCollection with inline filterValues subscribes once. Filters written inline, as the hooks guide shows, re-subscribed on every render: an endless loop against the server. useRelationSelector’s fixedFilter had 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.onAuthStateChange fires. It was never called; a sign-out unmounted and remounted the plugin instead. Separately, useRebaseContext returned 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, 0 and "" were written as null, 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/1 to /c/products/12 discarded 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.street column of a spreadChildren map sent only street, and the save erased city. 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.street column looked the property up as a top-level key, which doesn’t exist, so it checked nothing. A value breaking the child’s max or matches was 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 0 lost its clear button and showed a stray “0”.

  • An entity action’s isEnabled also 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 Date has 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 editing address.city erased address.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, lowercase and uppercase change the value that is saved, in the form and in the table, and length is 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.length was 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 email and url the way the server does. They were checked only on strings that also had a validation block, and then an empty string was rejected. So bad emails got through, and clearing an optional email or url blocked Save.

  • Number lessThan/moreThan messages and the date max message name the right bound. The number messages were swapped, and the date max message 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.

  • clearOnDisabled gives back a 0, false or 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’s timezone is 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 documented timezone, 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, video or provider then 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 named id, or one that maps onto the collection’s isId property, 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 onto price lost to the file’s own price column when that came later, and nested remaps were ignored. A column named toString crashed 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.10 became 1.1, twenty-digit SKUs and ids lost digits, and the word null became null), and Excel’s and Sheets’ TRUE imported 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.additionalFields and additionalFields were 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 0100 and -- draft came back with a leading '. The import now strips an apostrophe that comes before a formula trigger, and unescapeCsvFormula is exported next to escapeCsvFormula.

  • BasicExportAction downloads 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 named export.csv.csv.

  • 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, id updated 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: explaining DELETE FROM posts deleted 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 1000 to any text containing the word SELECT. INSERT INTO … SELECT and CREATE TABLE … AS SELECT quietly copied only 1000 rows, and SELECT 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.orders updated public.orders and 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 (slug blog, table posts) the save missed the collection and wrote a new posts file 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 an ownerField or access rule 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 withCheck only when the policy also had a using, and an INSERT policy never has one. So it saved as a rule with no condition, which compiles to WITH 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 structured condition, operations or pgRoles, 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 table to 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 use getTableName in the collection’s declared schema, or public.

  • 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 target thunk, and a relation picked in the property form names its target by slug. So /api/admin/schema/plan answered 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-migration plan, 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 writes target: () => authorsCollection with its import, as it already did for the collection-level relations array. 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 no kind while showing “Belongs to”, and kept the old kind’s through or localKey after 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 TABLE for a new empty table, leaving every row in the old one. The change is now classified needs-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.

  • CollectionsStudioView asks 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-branch header. 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.

  • A disabled boolean in the collection table can no longer be flipped with a click. The table’s switch cell dropped the disabled flag, so one click on a boolean marked admin.disabled or conditions.disabled toggled 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. MultiSelect used disabled only 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. TextField dropped callers’ onFocus/onBlur, so DebouncedTextField never 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 timezone are stored correctly. They were off by an hour; no form sets timezone yet.

  • 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 Slider yet.

  • 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.disabled or conditions.hidden, and dates with autoValue, 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 deploy refuses, 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 answers deploy_in_progress, names the running deploy and says nothing was deployed.

  • rebase cloud db backup list and backup create name each backup by its filename and size. They printed undefined … NaN MB and “(unknown)”.

  • rebase cloud db connect keeps 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 run rebase cloud login.

  • A deploy whose build outlasts the session uploads with a refreshed token. The bundle, --source and rebuild-source uploads used the token read before the build and failed with a 401 once it expired.

  • A bare rebase cloud deploy names 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 -f keeps 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 says status_unreadable only if the status stays unreadable for over a minute.

  • rebase cloud deploy --source refuses when it cannot read the project. It went ahead and could eject a managed project; pass --eject to go ahead anyway.

  • rebase cloud deploy --json prints a JSON error for a managed deploy refused before the build. A broken rebase.json, a declared-resource problem or a --bundle-dir without 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 set says where each deploy path reads a build-time variable. Refusing a VITE_… key, it told you to commit a .env that a --source deploy never uploads. Its help also said --force overwrites 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 deploy in 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 deploy no longer spends 35–55 seconds installing dependencies it does not upload. A deploy that builds its own bundle ran the build’s npm install into it, then packed the bundle without node_modules, so the time bought nothing. It now builds without installing. The upload still leaves node_modules out 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 deploy refuses dependency ranges no single version satisfies, as rebase build does. The deploy built its own bundle without the check and shipped what the build refused.

  • rebase cloud deploy accepts --no-static and --skip-schema. Its own error remedies named them, and both were rejected as unknown options.

  • rebase cloud logs --json prints one JSON object. It printed the human page (emoji header, raw log), so | jq always failed.

  • Telemetry cli.deploy is 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-password resets 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 the db commands do, and a failure exits 1.

  • rebase db pull no longer prints a success tick over a partial or empty database. --clean empties the local database before pg_restore runs, and a failed restore was swallowed and followed by “✓ Local database now holds a copy”. It also never checked that pg_restore was 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 --anonymize has run over whatever arrived.

  • rebase init --database-url writes a password containing $ exactly as given. The URL was used as a String.replace replacement string, so pa$$w0rd reached .env as pa$w0rd and the first rebase dev failed to authenticate, and a $' spliced the rest of the file into the URL. rebase eject substituted the package name the same way and is fixed too.

  • rebase cloud env pull writes values that read back unchanged. Values were quoted with JSON.stringify, but dotenv only unescapes \n and \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 doctor exits 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 upgrade moves pnpm catalogs, and no longer claims “already on” when nothing moved. catalog: pins were reported as dist-tags, and the catalog:/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 --help no longer documents a devDatabase key in rebase.json. The key was never read, so setting it had no effect; --docker is the way to choose the compose database.

  • rls-check --schema with 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-check judges views and junction tables against tables in schemas it does not scan. CREATE VIEW public.files AS SELECT * FROM storage.objects granted to anon produced nothing on a default scan, and a junction table pointing at auth.users was never considered either.

  • rls-check counts column-level grants. GRANT SELECT (id, email) ON users TO anon on a table with RLS off, and Supabase’s column-level UPDATE pattern, produced no finding.

  • rls-check reports 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-check tries TLS before plaintext, as libpq does. Servers that accept both, such as RDS with force_ssl off, were scanned in cleartext.

  • The rls-check report’s “Exit code” line matches the actual exit code. A degraded scan printed 0 or 1 and then exited 2.

  • 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’s update(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 uses update(), and a null removes only that one field.

  • A Firestore save with an empty field inside a list item is accepted. An undefined value became deleteField() 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, so orderBy was 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. checkUniqueField used startAt(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 uses equalTo and 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. listenOne called onError for a missing row, so a record deleted while it was open showed an error instead of disappearing, and a read the database refused never reached onError at all. Now a missing row arrives as onUpdate(null) and a refused read as onError.

  • 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 offset and limit, and search hits are read from the collection’s own database.
  • docsToCollectionTree nests subcollections instead of overflowing the stack. The subcollections function it put on a parent called itself, so calling it on any parent with a child ended in “Maximum call stack size exceeded”.

  • A not(...) filter on MongoDB returns the rows it negates. A not group was compiled as an and, 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}} stored views: {"$inc":1}, and $push, $pull and $merge did 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.

  • A row’s history no longer serves a withheld field in previous_values. Only values was stripped by the caller’s read rules, so a caller without hr read 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 hidden score with a min answered 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 400 FIELD_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, PATCH or DELETE /posts/1/tags/5, a _pivot edit, or the same over the realtime socket, is refused with FIELD_NOT_WRITABLE when 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/contract and the non-public /api/docs for 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/comments wrote 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 notes read internal_notes through /projects/1/notes. The route’s operation is now checked against the relation’s target, and the key needs read on 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/revert passed the stored snapshot straight to driver.save, skipping the access.write and excludeFromApi checks every other write runs. A caller refused PATCH { 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’s FIELD_NOT_WRITABLE / VALIDATION_EXCLUDED_FIELDS. Closed fields that are unchanged, or that the caller can’t read, are left as they are.

  • An include can no longer filter or sort by a related field the caller may not read. ?include={"staff":{"where":{"salary":[">",100000]}}} and orderBy/limit inside an include answered questions about a field that ?salary=gt.… on staff itself refuses. Each include level’s where, logical, orderBy and fields are now checked against the target collection’s read rules, and a forbidden field is a 400 FIELD_NOT_READABLE.

  • A vector search on a vector the caller may not read is refused. ?vector_search= was accepted on an excludeFromApi or access.read-restricted vector, and _distance/vector_threshold let a caller locate the hidden vector with repeated probes. It is now a 400 FIELD_NOT_READABLE, like orderBy on that field.

  • A query cannot filter or sort on the foreign key of a to-one relation the caller may not read. ?bandId=7 (or band_id), a where, an orderBy or a socket filter on it was accepted even when band was read-restricted, which let a caller find which rows point at band 7 one filter at a time. It is now refused with FIELD_NOT_READABLE by 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 belongsTo declared access.write: ["hr"] refused band: { id: 7 } from everyone else but wrote bandId: 7 (or band_id under strictWrites: false), and a relation marked excludeFromApi had the same hole. The foreign key now follows the relation’s write rule under both spellings, answering FIELD_NOT_WRITABLE (or VALIDATION_EXCLUDED_FIELDS for a relation closed to everyone) at every write door: REST, the socket SAVE, 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 _batch updates and deletes, and the realtime socket, could delete or demote the last administrator, skipped beforeUserDelete and afterUserDelete, left a deleted user’s sessions alive and stored an email unnormalised. They now answer LAST_ADMIN and EMAIL_EXISTS as /admin/users does, and PUT /admin/users/:uid checks a demotion before it writes the email or password. A custom auth adapter can implement the new optional prepareUserUpdates, prepareUserDeletions and finalizeUserDeletions.

  • 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-passwordless for 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 with POST /admin/users are now stored as verified, so invitees can still use “Sign in with Google”. A custom auth repository needs the new optional unlinkUserIdentity; see Breaking.

  • A guest session can no longer claim the first admin role, or block the developer from it. POST /admin/bootstrap recognised guests only by the reserved uids anonymous/anon, but /auth/anonymous creates 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’s isAnonymous flag.

  • Upgrading a guest to an account requires the register captcha and runs beforeUserCreate. Calling /auth/anonymous then /auth/anonymous/link created a password account that passed neither check. /anonymous/link now requires the register challenge and runs the hook on the email and password hash being set, storing what it returns as /register does; guest sign-in itself stays challenge-free. See Breaking.

  • The MFA challenge routes refuse a revoked session. POST /auth/mfa/challenge and /mfa/challenge/verify skipped 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 answer SESSION_REVOKED for 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 cookieAuth on, /magic-link/verify, /otp/verify, /anonymous and /anonymous/link returned 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 401 NO_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-link waited for the email to send only for real accounts, had no minimum response time and no per-address limit, and called beforeLogin only for existing addresses. It now matches /otp and /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_REVOKED instead.

  • 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.

  • An update can no longer set the soft-delete field. On a softDelete collection, setting it to a value in an update is a 400 FIELD_NOT_WRITABLE: only a delete stamps it, so the delete permission and beforeDelete/afterDelete always apply. Setting it back to null to restore a row is still an ordinary update, and an upsert never trashes a stored row.

  • An update can no longer rewrite created_by or created_at. An on_create date or user_on_create property in an update’s body, or set by a beforeSave hook, 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 did fetchOne when it fell back from drizzle’s relational query API. A plain user who sent one FETCH_COLLECTION for users, or called MCP query_collection, received every co-member’s password hash, verification token and any access.read-restricted column, while GET /api/data/users withheld them. Both paths now strip against the caller’s roles.

  • A single get’s relations respect the target’s beforeQuery scope and soft delete. fetchOne (socket FETCH_ONE, MCP get_document) filled relations through drizzle’s with, so a tenant-scoped user fetching owners/1 received other tenants’ docs, and trashed ones, that ?include=docs correctly 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 band relation with access.read: ["hr"] was withheld from everyone else, but bandId: 7 was 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 = NULL on 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.

  • The realtime socket holds to the data API’s body and rate limits. A frame over maxBodySize closes 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 its include and 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_FIELD and subscribe_collection did not apply the check REST applies, so a COUNT filtered with passwordHash like '$2b$10$A%' let any signed-in user extract a hash one character at a time. They now answer FIELD_NOT_READABLE with the field named, using the same rule as REST, now exported from @rebasepro/server as assertQueryFieldsReadable.

  • 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.

  • The peer and security floors move past this month’s advisories: hono 4.13.5 (fragment-aware query parsing, bounded parseBody() nesting), nodemailer 9.1.1 (a quadratic address parser, allow-list and recipient-domain bypasses, resolveContent file access) and sharp 0.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 with js-yaml 4.3.2, svgo 4.1.0 and devalue 5.9.4 in build tooling.

  • The resumable upload route refuses the reserved rendition prefix, and a transform URL serves only an image. POST /upload refused 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 as text/html through TUS, and the transform URL served it back inline on the API origin. On macOS and Windows _REBASE/renditions/… also got past POST /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 bucket against the controller’s known buckets, but uploads, POST /folder and TUS only checked the name’s format. On an object store that meant bucket=prod-db-backups wrote into any bucket the deployment’s credentials could reach, while listing the same bucket answered 404. All three now answer 404 UNKNOWN_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 storageId other than the default. TUS asked the storageAuthorize hook 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 filetype and contentType, the check read one and the write stored the other, so a property that accepts only image/* could end up holding text/html.

  • The MCP write tools hold the users collection to the admin user routes’ rules, as the data API now does. delete_document cannot delete the last administrator, runs beforeUserDelete and afterUserDelete and ends the user’s sessions; update_document cannot demote the last administrator and stores the email normalized (EMAIL_EXISTS on a taken one); and create_document creates the user through the auth adapter, so the password is hashed, the email normalized, onCreateUser runs 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_collection only checked that a filter or orderBy field existed. A user without access.read on salary could ask for rows with salary above a bound and narrow it down call by call, or sort by it, and excludeFromApi columns could be used the same way. REST already answered these with FIELD_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_document and update_document tools apply the field write rules. They passed the model’s values straight to the driver, so a user could set an access.write field their roles don’t allow, or any excludeFromApi column, by asking a connected MCP client to. Both tools now run the same check as REST and the socket’s SAVE and return its message to the model as the tool error. They also apply PATCH’s field-operation type check and POST’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 /mcp as 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/grants also refuses a revoked session.

  • 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_REFRESHED fired after SIGNED_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.
  • 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 no authController cleared 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.

  • useBackendStorageSource no 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.

  • SQL run in the Studio editor runs on a connection of its own, reset afterwards. A SET ROLE rebase_user left 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 TO list, which holds database roles, into SecurityRule.roles, which holds application roles. A restrictive tenant_isolation … TO public USING (tenant_id = …) came back as NOT (roles && ARRAY['public']) OR tenant_id = …, so every user passed the tenant gate, and a permissive import matched nobody. The TO list is now written to pgRoles, and left out when it is public.

  • 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: a SELECT … USING (true) became a write grant. TO public arrived as -, the TO list was filed as application roles, and restrictive policies came back permissive, so a restrictive tenant gate became a permissive rebase.roles() check. An INSERT policy lost its WITH CHECK and blocked every insert. The metadata now comes from pg_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.

  • 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 login no 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 .gitignore outside a git repository, and never carries database dumps, .rebase/ or dev secrets. A project with no repo of its own (what rebase init --yes makes) was walked with fixed excludes only, so backups/*.dump (password hashes included), uploads/ and .rebase-dev-secrets.json were 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 .gitignore is 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 .gitignore names. It used tar . with only the root .gitignore as tar globs, so .env.production shipped, and from a monorepo subfolder .env itself did. It now uses git’s reading of every .gitignore plus the same never-uploaded filter. .rebaseignore is still honoured.

  • A failed command’s usage report no longer carries project names or typos. cli.error sent the command and subcommand exactly as typed, so rebase init acme-payroll-internal --headles sent subcommand: "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.

  • An rls-check copy-paste fix can no longer be turned into a different statement by a hostile table, role or routine name. A table named x"; 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 the DO $$ block. Names with control characters are now written as U&"…" identifiers, and every identifier is quoted.
  • A MongoDB create that names a taken id answers 409 instead of overwriting the row. A save with status: "new" and an id was 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 access and excludeFromApi apply to every row the MongoDB driver serves. REST reads, socket fetches and subscription pushes returned every field, so an anonymous caller received fields like salary and apiToken. These fields are now stripped, left out of text search, and refused as a filter or sort key, and the socket’s SAVE checks access.write with the session’s roles instead of as the trusted server.

  • A MongoDB uniqueness check compares one value on one declared field. CHECK_UNIQUE_FIELD passed 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 $expr ran 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 isAnonymous or metadata, so POST /auth/anonymous created a full account that policy.registered() let through, and /auth/anonymous/link answered NOT_ANONYMOUS to 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.

  • Linking an account returns the provider’s profile picture. verify already fetches a photo from every provider that has one, and both sign-in branches pass it to updateUser — but POST /auth/link/:provider asked 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 with photoURL, 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.

  • beforeQuery narrows which rows a read asks for. afterRead sees 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’s callbacks.beforeQuery, or a global one on initializeRebaseBackend, returns a filter or a logical group 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 with 400 UNKNOWN_FILTER_FIELD, whatever configureUnknownFilterFields is set to. Postgres only: a MongoDB or Firestore collection that declares one fails at boot rather than serving every row.

  • search.mode can be "hybrid", which finds substrings and folds accents in one collection. Full-text search matches whole words, so seb never found Sebastian and audit never found Lead Auditor. The plain search finds substrings but cannot fold accents, so munoz never found Muñoz. mode: "hybrid" ORs the indexed full-text match with an accent-folded substring match over the same search.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 changed search block otherwise needs; it adds the unaccent extension 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.before and entity.field.after render around every form field, entity.row.actions in each table row’s hover tools, global.search in the app bar beside the breadcrumbs, and shell.toolbar at the end of the app bar. All five were declared and documented, and registering for one did nothing. UNRENDERED_SLOTS is 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.components took only a component, so declaring one meant importing React into config/collections/*.ts, which the backend loads too. Component now takes a path or a lazy import(), as admin.Field does, 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.

  • The record dialog has its Create and Save buttons at the bottom. In a collection opened with openEntityMode: "dialog", such as users in 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 span set on a property is still honoured, and longer forms keep their columns.

  • The entity.row.actions and field slot props describe what their render sites have. In EntityRowActionsProps, entityId is string | number, path, collection and selectionController are optional, and parentCollectionSlugs and parentEntityIds are gone. In EntityFieldSlotProps, collection is 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.

  • PropertyIdCopyTooltip and LabelWithIconAndTooltip are gone from @rebasepro/cms. Use PropertyKeyHint to show a property’s key beside a label of your own, or LabelWithIcon with its new propertyKey prop.

  • The collection.filter-panel and dashboard.widget slots, with CollectionFilterPanelProps and DashboardWidgetProps. Neither ever rendered. The admin has no filter sidebar for the first to render into; put filter UI in collection.toolbar or collection.widgets. The second named no position on the page; the home page has home.children.start, home.children.end, home.cards and home.card.widget.

  • A search for two words finds a row that keeps them in two fields. Searching sebastian melendez on a collection with first_name and last_name returned nothing: the default search compiled one ILIKE '%sebastian melendez%' per column, and no column holds both words. Typing sebastian returned 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 what websearch_to_tsquery already did for a collection that declared a search block, 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 identify saw the service key’s uid: "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 no rateLimit surface to raise. The service identity now skips the limiter entirely, without spending the bucket of whoever shares its key. API keys keep their own rate_limit, users the user allowance, 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.” LoginView now 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 the EMAIL_NOT_VERIFIED error now carries details: { reason, provider }, where reason is "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 writes size straight into the SVG’s width and height. small is 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 pass iconSize, 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.ts does not mark the secret columns. A project’s own users collection replaces the default, and a copy scaffolded before excludeFromApi was in the template leaves it off passwordHash and emailVerificationToken. 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 as null. 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. Every CollectionRegistry now 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 add excludeFromApi: true is 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 Dialog and Sheet now 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 callbacks passed to initializeRebaseBackend, and export const callbacks from config/index.ts on 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 global afterRead, beforeSave, afterSave, beforeDelete or afterDelete was 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 global beforeQuery beside 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_database beside status: active in rebase cloud status, and suggested rebase 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.

  • rebase upgrade moves every @rebasepro/* package to one release. A project pins the framework in several package.json files, and rebase build could only warn that they were behind. rebase upgrade rewrites every @rebasepro/* entry in dependencies, devDependencies and optionalDependencies, in every package.json under the project, keeping ^ and ~, then installs with the package manager your lockfile names. --to takes an exact version (0.21.0, no registry lookup) or a dist-tag (latest, the default, or canary). peerDependencies, workspace:, link:, file:, git and tag specs are left alone and listed with the reason. Overrides in pnpm-workspace.yaml and package.json are bumped the same way. A link: or file: override wins over every pin, so it is reported, and --drop-local-overrides removes it. Only the version strings change: indentation, key order and line endings stay as they were. --dry-run writes nothing, --no-install skips the install, and --json prints one document with every change. The build warning about old pins, and rebase 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 deploy now packs the source beside every backend bundle and names it in the deploy. In a git repository that is what git ls-files lists from the repository’s top level, so .gitignore is honoured and a package the project links from elsewhere in the repository goes too. A link: or file: 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*, .envrc and every .env and .env.* file are always left out, committed or not; .env.example, .env.sample and .env.template are kept. The VITE_* 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-source skips 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-downgrade deploys a bundle built on an older framework release than the project runs, which the control plane otherwise refuses as FRAMEWORK_DOWNGRADE.

  • Entity form fields are 48px tall again. 0.20.0 took them to 32px (small). The form passes large again, 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.
  • RealtimeService.registerDataDriverSubscription, addSubscriptionCallback and removeSubscriptionCallback (@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’s authContext. unsubscribe(id) cancels it.
  • rebase schema stale reports 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 every rebase build rewrote it. It now compares the file, declaration by declaration, with what the collections generate, as rebase doctor already 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. listenCollection and listenOne fetched 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.
    • listenOne dropped null. A listener whose row was deleted, or never existed, heard nothing and kept the last row it had. It now receives null, as it does on MongoDB.
    • listenCollection passed on nine of the query’s fields and dropped the rest. A listener with an or(...) 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, distinct and searchExplain included.
    • 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.data inside 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 onError fired, 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 }) or listenOne was not told when the read behind the subscription failed. On MongoDB, neither the first load nor any refresh after a change reached onError. 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 call onError with the error as it was thrown: an afterRead that throws a RebaseApiError arrives as that RebaseApiError, not masked the way a WebSocket frame is. A failure is not reported once newer rows have arrived, or after you unsubscribe. RealtimeProvider.subscribeToCollection and subscribeToOne take the same optional onError, 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 onError never 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: an afterRead that throws new RebaseApiError("…", { status: 403, code: "FORBIDDEN" }) reaches onError with code FORBIDDEN and that message. Any other failure, a 5xx included, arrives as Could not load data for "<path>". Check server logs for details. with code INTERNAL_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 size and always drew at 48px. In 0.20.0 and 0.21.0, where the form was small, 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’s size, 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). MultiSelect stood 2px short of a TextField or Select of the same size (48px against 50 at large), because the field’s 1px border was drawn inside its height rather than around it. A bordered MultiSelect is now the control height plus the border, like the others. An invisible one, in a table cell, is unchanged.

  • POST /admin/users leaves delivery to a create hook, and shows the admin the hook’s temporary password. A collection’s auth.onCreateUser or a backend’s AuthHooks.onAdminCreateUser replaces Rebase’s own delivery: the hook sends its own invitation or chooses a temporary password, and reports which. A POST to the auth collection honoured that. POST /admin/users ignored 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 as invitationSent: 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/upload has its own body limit, the storage config’s maxFileSize (50 MB by default), and the docs said it overrode the global maxBodySize (10 MB by default). It never did. The global limit runs first on every route under the base path and answers 413 on the Content-Length alone, 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, whether maxFileSize is larger or smaller than maxBodySize. Every other route keeps the global limit, the rest of /storage included. TUS is unchanged: each PATCH chunk is still capped by maxBodySize, and the whole file by maxFileSize. 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-size with its own HTML 413, before the runtime sees it, so the chart sets ingress.maxBodySize above the runtime’s limit. It was 12m: above the global maxBodySize (10 MB), but not above the 50 MB maxFileSize that POST /storage/upload now 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 now 64m. pnpm check:chart now refuses a default that is not above the larger of the two runtime limits. It reads them from DEFAULT_MAX_FILE_SIZE and from the new RUNTIME_DEFAULT_MAX_BODY_SIZE, which @rebasepro/server exports with the rest of the pod contract. A release that sets ingress.maxBodySize itself keeps its value. If yours is below 50m, raise it.

  • A collection-callback veto over the WebSocket is reported as a veto. A beforeSave, afterSave, beforeDelete or afterDelete that throws, or a beforeDelete that returns false, is answered over REST as a 400 (403 for false) with code CALLBACK_REJECTED, the author’s message, and details.stage naming the hook. Both WebSocket servers answered the same refusal as INTERNAL_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 any RebaseApiError that carries a status. Realtime subscriptions on Postgres had the same gap: a RebaseApiError 4xx thrown from afterRead arrived as “Could not load data … Check server logs”. It now keeps its code and message. The client passes a frame’s details on to the RebaseApiError it throws, so e.details.stage works over both transports. e.status stays undefined for 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, declaredErrorAnswer from @rebasepro/server.

  • Scheduled backups include your users again, and can be restored. createBackupCron left the rebase schema 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 call rebase.uid() and rebase.roles(), so pg_restore stopped with schema "rebase" does not exist. rebase db backup excludes nothing, so the manual and the scheduled backup captured different databases.

    The scheduled backup now excludes nothing either, and excludeSchemas still 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 .dump plus a .globals.sql sidecar holding the database roles its grants and RLS policies refer to, and rebase db restore needs both. The panel, and the /api/admin/backups routes behind it, only knew about the .dump, so a backup downloaded from Studio stopped at its first GRANT when 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 as globalsKey, and /download serves it.

  • FORCE_LOCAL_STORAGE=false switched the production storage guard off. In production a local storage backend is dropped unless FORCE_LOCAL_STORAGE says 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 status read it the same way and called that storage ready. Only a spelled yes forces it now, and the guard and rebase status share one reader.

    A deployment that set FORCE_LOCAL_STORAGE=false while storing uploads on local disk was losing them at every redeploy; its uploads now answer 501 STORAGE_NOT_CONFIGURED until a bucket is configured, or until FORCE_LOCAL_STORAGE=true says a durable volume really is mounted.

  • REBASE_MCP_OPEN_REGISTRATION=0 left OAuth client registration open. The switch that turns off dynamic client registration on the MCP authorization server compared against the literal "false", so 0, no and off left /oauth/register issuing a client ID to anyone who asked. Any spelling of no closes it now.

  • rebase db backup addressed a MinIO bucket as a hostname. The backup, restore and backups list commands passed forcePathStyle: false whenever S3_FORCE_PATH_STYLE was 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, and S3_FORCE_PATH_STYLE__<KEY>=1 is no longer read as false.

  • Boolean environment variables accept one set of spellings. true, 1, yes and on mean yes; false, 0, no and off mean no, in any case; anything else leaves the variable at its default. Seven readers had spelled this seven ways, so DISABLE_DB_ROLE_SWITCHING=1 and REBASE_EXIT_ON_UNHANDLED_REJECTION=true did nothing, REBASE_DEBUG=0 turned debug output on in one command and off in another, and CI=0 read as a CI run. DO_NOT_TRACK=false now reads as a no instead of a refusal; telemetry still sends nothing without your explicit opt-in. The variables loadEnv validates still accept only true, false or empty and refuse anything else at boot, as before. The parser is exported from @rebasepro/types as parseEnvBoolean, and verify:docs now refuses a boolean read that goes around it.

  • A collection’s onResetPassword hook could not actually reset a password. The admin panel showed the temporaryPassword the 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 returned temporaryPassword is now the account’s new password: Rebase hashes it, saves it and signs the user out everywhere before showing it to the admin. onAdminResetPassword follows the same rule, so its hook no longer has to write the password through authRepo as well; one that already does gets it written a second time, which is harmless. The example now generates the password with randomBytes instead of Math.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.0 was 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. A FRAMEWORK_DOWNGRADE refusal 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 deploy read rebase.json from the working directory, so run from backend/ it found none there and told the platform the project declared no apps. It reads the project root’s.

  • 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, and policy.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, PATCH and DELETE let a guest read and write rows a registered() 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 listenCollection or listenOne on driver.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 on rebase.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(), or rebase.is_anonymous() in a policy, with anonymous sign-in enabled, upgrade.

  • 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 no securityRules at 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 with requireAuth: 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 listenCollection or listenOne on a driver scoped to a user, such as driver.withAuth(user) or a function’s requireDriver(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 when rlsUserRole is set (a superuser, a BYPASSRLS role, or the owner of the tables), the first delivery carried every user’s rows, and listenOne handed 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, isAnonymous and 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 securityRule is enforced only for a collection the registry resolves, and MongoDataService maps any path to a physical collection by name. The realtime socket forwarded FETCH_COLLECTION, FETCH_ONE, SAVE, DELETE, COUNT, CHECK_UNIQUE_FIELD and the subscribe_* frames to the driver without asking whether the path named a registered data collection. For a path the registry did not know, AuthenticatedMongoDriver resolved the collection to undefined and every rule check short-circuits open there — authorize(undefined) returns true and the query filter becomes “match all”. So any authenticated client (or, when requireAuth is off, any anonymous one) could name the auth collections as the path and read rebase_users including password hashes, grant itself an admin role by writing rebase_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_FOUND naming 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 beforeDelete veto. DeleteProps carried row.values, and both server drivers took them as the row being deleted: they were what beforeDelete and afterDelete received and what history recorded as the row’s final state. The REST routes filled them from a read of their own. The WebSocket DELETE handler forwarded the client’s frame, so a caller allowed to delete a row could record anything as its final state — and could get past a beforeDelete that refuses on a column, such as the callbacks guide’s if (row.status === "published") throw, by sending values: {}. The in-process SDK sent {} on every delete(id), so every delete from a function, a cron job or a callback recorded an empty row and gave beforeDelete nothing to judge. The MCP delete_document tool 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.row is { id, path } now: values is gone from the type and ignored on the wire, so a published client that still sends it keeps working.

    The socket’s DELETE also stopped forwarding the frame’s collection. The driver merges a caller’s collection under the registry’s, so a key the registry does not declare survived, and softDelete: { field: "title" } turned a delete into an update of title that no beforeSave and no write validator saw.

    One more change came with dropping deleteMany’s own pre-read: a hard deleteMany now 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 a beforeDelete that 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-password and PUT /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.updateUser with a password too. 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 an onResetPassword or onAdminResetPassword hook.

  • A database error during an admin reset set a new password. Minting the reset token shared a try with 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.

  • context.client.data no longer compiles in a collection callback. A callback’s queries go through context.data. RebaseCallContext["client"] is now RebaseCallbackClient, which is RebaseClient without data. The old spelling used to compile and then, on the server, throw Cannot read properties of undefined (reading 'collection') on every call.

    A server-side callback’s client is the rebase singleton, which has had no data since 0.18.0: its admin-scoped plane is spelled dataAsAdmin and nothing else. The type kept saying RebaseClient because 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.data runs with the privilege of whatever triggered the callback, on the write’s own transaction. context.client.dataAsAdmin is still there for a callback that must see what an admin may see.

    In admin.browserCallbacks the panel’s client does carry data, so the old spelling ran there; it stops compiling too, and context.data, the panel’s own data source, replaces it. useRebaseContext().client is unchanged and still has data.

    A driver that was never given the server client — one constructed outside initializeRebaseBackend, or one it missed — now refuses a callback’s read of context.client with a 500 that says so, instead of handing it undefined.

  • SelectionController.selectedEntities is 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 an Entity[]. selectionController.selection is an EntitySelection union — { type: "entities", entities } for rows ticked by hand, { type: "query", query, excluded, count? } for every row matching the filter — and resolveSelection() from @rebasepro/cms is 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 selectedEntities beside 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. selectedCount replaces selectedEntities.length, and is undefined rather than 0 for a full selection against an accessor that cannot count. setSelectedEntities keeps its array form; the updater-function form moved to setSelection. isEntitySelected and toggleEntitySelection are unchanged.

  • cms — say where your admin panel is, and Rebase Cloud will link to it. A static app in rebase.json can 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 in rebase dev’s banner and in rebase 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. /mcp is an OAuth 2.1 resource server and /api/oauth the 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 needs REBASE_PUBLIC_URL, because the issuer and the token audience cannot be taken from a Host header the caller controls. No REBASE_ROLE turns it on, and a managed project cannot switch it on from its own environment. The Helm chart gains mcp.enabled, mcp.publicUrl and mcp.openRegistration. Dynamic client registration is on by default; REBASE_MCP_OPEN_REGISTRATION=false turns it off. A person can list and withdraw the applications they connected (GET and DELETE /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 a kubectl port-forward command nobody outside the platform could run: no route has ever issued a customer credentials for the cluster. db connect opens a listener on 127.0.0.1 and 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 --reveal puts the password in the printed URL.

    db info and debug db print the tunnel instead of kubectl, and say why the host they list does not resolve on your machine; in db info’s output portForward is replaced by directAccess, which carries a kubectl recipe only when the cluster is your own. The tunnel and db info --reveal need 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 _pivot body sets the junction row’s own columns — the role on 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’s through.properties the way a row’s values are, so required, enum, min/max and per-field access.write mean the same there. A body carrying both _pivot and 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 -1 there, 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.

  • 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, n is one keystroke, and both the prompt and the answer name the command that turns it off. rebase telemetry show now 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, CI and a project’s "telemetry": false still suppress the prompt and every event outright.

    A reported failure now says what kind it was. cli.error carried the error’s class name, which is Error for 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 /metrics was 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, /livez and /readyz are no longer counted, by exact path, so an app’s own /healthy-snacks still is. Requests outside the API base path are labelled app instead of other, and other keeps 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.

  • The shared database tier is retired. --db-mode is gone from rebase cloud compute set and rebase cloud projects create, and compute show no longer prints a “Database” row or carries databaseMode in its --json output. 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 dedicated dial 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 says shared still reads back. A client old enough to send --db-mode shared gets 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-instances and --storage — and rebase cloud db create --type byodb still points a project at a PostgreSQL the platform does not run.

  • The rebase eject infra tombstone. The command and rebase.infra.json went 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 that rebase.json declares no app named infra — 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 afterSave that 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.jobs and rebase.entity_history on purpose, so each goes through one narrow SECURITY DEFINER function — rebase.enqueue_job and rebase.record_history, created at boot and executable only by that role. An idempotency-key clash inside a write answers null instead 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 global beforeSave reading collection.slug therefore threw a TypeError which the callback error path then answered as 400 CALLBACK_REJECTED — blaming the author’s own rule for a value the framework had failed to supply.

    Skipping the tier instead was not available: afterRead is 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, with 404 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’s formView can edit the record it shows. FormViewConfig documents 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, with formContext.setFieldValue, save, submit and formex.setFieldValue all 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 includeActions already 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 gets parentCollectionSlugs and parentEntityIds, 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 through formContext.submit().

    Where the record cannot be edited, the context now says so. The stand-in reported disabled: false while throwing on every write, so a Builder that honoured disabled offered controls that could not work; it carries disabled: true beside readOnly: true now, 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 a formView for the default rendering, and dropped additionalFields from 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 admin block 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 stopped rebase dev from starting at all: “Could not regenerate the database schema”, then a backend that never came up. saveProperty had been fixed for this; saveCollection had not, and creating a collection is a saveCollection.

    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 an as unknown as AdminCollection cast, which is the only check they had. The cast is gone: they are satisfies AdminCollection now, so a key in the wrong place is a compile error. The templates also had url: "image" on a boolean flag — the renderer is admin.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.html and markdown.transformPastedText at the top level of the property — the fatal shape above — clobbering the admin.markdown flag that makes the field a markdown field at all. Nothing ever read either one: the editor’s markdownConfig prop 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-examples read the docs and not tooling/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 correct admin: { readOnly: true } in the same literal. The gate now validates 123 collections instead of 87.

  • A tenancy rule over a belongsTo relation compares the foreign-key column. tenant: { field: "org" } over a relation compiled to a comparison against a column called org. The column is org_id — the relation’s local key — so CREATE POLICY failed, and a table with RLS enabled and no policy denies every row. The rule read exactly right, which is what made it quiet. Only belongsTo has 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 read context.client as undefined. Every source’s driver gets it now.

  • A property that declares url: true renders as a link. url: true is 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 off admin.urlPreview alone, 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 gets inputType="url", so a phone offers the right keyboard. urlPreview keeps its own job — rendering what the link points at, an image, a video or a file card — and accepts true again: 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 additionalFields Builder gets the documented context in every view. AdditionalFieldDelegateProps.context is a RebaseContext, 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 read undefined in the form — and in the entity view it was also the context from the first render, never updated. Both pass the live RebaseContext now.

  • An explicit disconnect stops the subscription watchdogs. The realtime client’s disconnect() cleared the socket’s handlers, including the onclose that 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. After close(), 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 Requests on 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-After is 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 build prints 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 printed docker build -f backend/Dockerfile . died on its first COPY, because the build context has to be the repository root. An app’s context in rebase.json may now point above the project, and only context: every other path names something Rebase reads, which a bundle has to carry. rebase build prints 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 billing finds the organization’s billing account. Both readers in the CLI looked for billing_account_id and billingAccount, and the API sends billingAccountId, so every organization reported account: null and 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 rebase schema, 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” beside refresh_tokens, and “Schema Collections” rendered empty. A table the project maps to a collection is the customer’s wherever it lives, and inside rebase the 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’s context.data writes open separate transactions and that a failing afterSave does not roll the save back. On Postgres the opposite is true, and always was: beforeSave, the write and afterSave run in one transaction, and a throw rolls back the write with everything the callbacks wrote. The guide, the afterSave reference and the agent skills say so now, and that MongoDB has no transaction.

  • A realtime afterRead read through the owner connection, past RLS. On Postgres, the afterRead hooks that run on a subscription frame were handed a context assembled by hand, and its context.data was 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 through context.data to 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 client and storageSource, 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 afterRead that reads other rows through context.data, and anything subscribes to it, upgrade.

  • 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=), nested include trees, 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, and aggregate() removes the per-push /count round trip.

  • A write path to match: batch writes, ETags, field operations, upsert targets, return=minimal, defaultValue applied on create, and user autoValues stamped instead of dropped.

  • Schema declarations that reach the generated artifacts: declared indexes now land in the Drizzle file, plus defaultValue, updated_at triggers and numeric precision.

  • include is 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 maxSize and acceptedFiles on the server, not only in the browser.

  • The MCP server is published to the MCP Registry. @rebasepro/mcp has carried its mcpName since it was written, but nothing ever published a server.json against 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.deploy and cli.error were declared and never recorded, so a consenting user reported scaffolding and dev servers but never whether they shipped or whether anything broke. cli.error carries the command and the error’s type, never its message — a message is usually a path or a connection string. rebase init also asks again on a machine that previously declined; it never re-asks one that accepted, and DO_NOT_TRACK, CI and a project’s "telemetry": false still suppress it outright.

  • 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 single SchemaPlan the 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 unmet typescript peer. 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 … TYPE on a column a GENERATED ALWAYS AS … STORED expression reads, and a search block’s tsvector is deliberately hidden from Atlas — so Atlas planned an alter against a column whose dependant it could not see, PostgreSQL refused, and because schema apply runs in one transaction every unrelated statement rolled back with it. One varchar(255) → text widening on a searched column was enough to make rebase db push a permanent no-op, with an error naming neither the generated column nor the reason. Both db push and db migrate now drop the generated column, let the apply through, and rebuild it from search.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: loadEnv stops merging across copies.

  • A literal NUL byte in conflict-target.ts made 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 and meta.nextCursor was never sent, so a client had nothing to page with: neither restFetchService wrapper forwarded cursorFor, and the route can only see what those wrappers expose. The same omission once made /api/data/:slug/aggregate answer 501 everywhere, so a guard now reads the members off the interface and fails if either wrapper drops one.

  • distinct collapsed 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 refuses distinct beside a search query. A distinct read no longer selects the key, and reports no total rather than a row count that describes a different set.

  • A write could be refused for a field its own beforeSave fills in. The create-time required check runs at the request boundary, before the hook, and exempted defaultValue and autoValue but not “a field a hook supplies” — which is the slug-from-title pattern. It now steps aside for a collection that declares beforeSave, leaving NOT NULL as 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. autoValue on a string property and precision/scale on a number one never reached the serializable mirror the panel round-trips through, so opening such a collection and saving turned a NUMERIC(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, and rebase 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/atlas reached it only transitively and is now an optional peer of the driver — nothing in a dlx wants 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 DELETE on a collection declaring softDelete answered 500 and left the row live: a soft delete is a save — it stamps deleted_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=true is 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=only was listing a moment earlier.

  • min and max over 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/max are uncast by Postgres and were not parsed back; they are now, decided by the column’s declared type so a min(sku) of "00123" still comes back as text.

  • A new project’s first pnpm run dev no longer opens with a vite warning about __dirname in a config file its author did not write.

  • The password hash was on the wire. enforceAuthSecretExclusion ran 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 naming password_hash exactly 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.
  • 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 with rebaseEnvSchema.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-exporting z, so an app’s import { z } from "zod" was a different set of classes — and, under our own ^4.4.3 range, a different version, since we build against 4.4.3 and apps install 4.5.x. Merging across the two dropped every ZodDefault wrapper, so each defaulted field came back required.

    Fixed at both levels. zod is externalised, so an app gets one instance whether it imports z from zod or from @rebasepro/server; and loadEnv now 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 that instanceof is false across two copies — true of zod 3, and false of zod 4, which installs a structural Symbol.hasInstance. Its test passed because the fixture built its “foreign” schema from zod/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 push could not finish against a database that had never booted. It died at “Step 3/3: Applying RLS policies” with relation "rebase.users" does not exist — which is the documented bring-your-own-Postgres first run: set DATABASE_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 flagged auth, and could not resolve ./authors onto authors.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.example named a port the project does not publish, with its DATABASE_URL uncommented. rebase init derives a per-project database port and rewrites docker-compose.yml and .env with it. It left .env.example at the template’s localhost:5432 — and left its DATABASE_URL set, which contradicts the rule the rest of the scaffold states in three places: unset means the managed database.

    Copying .env.example to .env is 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.

    init now comments the line out and writes this project’s port into it, and into the sslmode illustration 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.log rather 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/aggregate answered 501 on every deployment. FetchService.aggregate has existed for as long as the route has, and the route reads it off restFetchService — an adapter object that listed fetchCollectionForRest and fetchOneForRest and 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 documents count(), sum(), avg(), min(), max() and groupBy, none of which had ever run over HTTP.

    Forwarded from both. The authenticated one goes through the same read-only withTransaction as 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 eject produced a project that compiled and could not boot. The template’s env.ts took z from "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 published z; 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 raw ZodError naming SMTP_PORT, SMTP_SECURE and APP_NAME — three variables with defaults, none of which the operator had set.

    check:templates now refuses a template that imports zod directly while the runtime exports z, 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/posts answered INTERNAL_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 a 404 UNKNOWN_RELATION now, like an unknown collection: the URL names nothing. Three throw sites, one in nested-path.ts and two in RelationService.

  • rebase schema stale checked 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 .ts entry, node otherwise. Correct while the driver shipped src/; wrong the moment it shipped dist/cli.js, because the files being loaded are the project’s TypeScript. Node cannot resolve ./authors onto authors.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.

  • defineCron required a name the runtime does not. The loader has always read definition.name ?? loaded.id, where the id is the file’s own name — so a cron without one registered happily under rebase dev and then failed rebase build with “Property ‘name’ is missing”. name is optional, and documented as defaulting to the filename.

  • The scaffold’s imports lose the .js extension. A new project’s collections imported each other as ./authors.js — an output extension in a .ts file, on every relative import — and nothing in the scaffold asked for it. Both its tsconfigs are moduleResolution: "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 ./authors compiles and then throws ERR_MODULE_NOT_FOUND under node dist/…. rebase build has always closed that gap for the bundle it writes. Nothing closed it for the plain tsc output that rebase eject runs, or that a self-hoster runs after pnpm 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 stock config/resources.ts documents an import inside a docblock. Both scaffold build scripts run it after tsc, so an ejected project and a hand-built one get what rebase build already produced.

    The scaffold’s backend/tsconfig.json also gains the rootDir: ".." its layout has always implied — the program includes ../config/**, so the emit already lands at dist/backend/src/…, which is what scripts.start runs. TypeScript 6 refuses to infer that (TS5011).

  • check:eject never ran what it checked. It typechecked the ejected project with noEmit, which is blind to the only failure that path has: Node resolving ./authors to 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.
  • 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/posts answered 500 Table not found for collection 'posts' on 0.18.0, and the boot log said Applied 30 additive schema change(s) — the table was there. The driver looks its columns up in backend/src/schema.generated.ts, which the scaffold ships as a stub, and rebase dev regenerates 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-postgres runs 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 .ts files, and resolved only because files listed src. dff34e8688 removed src from 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 no dist/cli.js had ever been built. Every rebase db …, rebase schema … and rebase introspect was 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.js and the four child scripts. They still run under tsx, 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 from src/schema/bin/*, because their own import.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.mjs reads the spawn sites out of cli.ts and requires each one in dist, 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/posts returns 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’s import 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. process is a Node global and the import bought nothing; it is gone. check:runtime-image:boots caught it, and a new assertion in driver-cli-surface.test.mjs now refuses a banner that binds any plain identifier, so the next one is caught on the host without building an image.

  • defineCollection is 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, on defineCollection(, listing each overload’s first failure. So a Postgres collection’s typo reported FirebaseCollectionConfig and MongoDBCollectionConfig at 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 bad admin.display.title on the path, a link field that belongs to another relation kind on that field.

    Two type exports move with it: UnknownPropertyKey is now NoSuchKey, which carries a didYouMean member naming the shape whose keys were valid, and PropertyTypeNotOnThisEngine is new — it is what a relation on 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.

  • defineCollection no longer stops checking the admin block when a property is wrong. The builder declared const P extends PostgresProperties, and a constraint TypeScript cannot satisfy is one it silently falls back from: a single property with a bad defaultValue made P collapse to PostgresProperties, the inferred entity shape collapse to Record<string, unknown>, and display.title, listProperties, propertiesOrder and previewProperties all widen to string. Every check the builder exists to provide switched itself off, quietly, at the first mistake. P is unconstrained now; exactness and the engine gate are expressed inside StrictProperties, where a violation is one error on one property.

  • defineCollection now checks a relation against the member its kind selects. Relation has been a closed union since 0.11 — belongsTo owns localKey, hasOne/hasMany own foreignKeyOnTarget, manyToMany owns through, via owns joinPath — 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 reads localKey, 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. required lives 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 out NOT NULL) while the SDK type generator asked the relation (so the generated Insert type made the field optional). A create() that omitted the relation therefore typechecked and then failed at the database with a not-null violation — and getting both right meant writing required twice, identically.

    RelationBase.validation and ResolvedRelationBase.validation are deleted. Move it up one level, beside every other field’s:

    author: {
    type: "relation",
    validation: { required: true }, // ← here
    relation: { kind: "belongsTo", target: () => authors }
    }

    The boot validator refuses the old key by name rather than ignoring it: silence would have left the surviving required unset, quietly turning a required relation optional in the generated types and nullable in the column. How every reader now asks is import { 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 belongsTo no longer defaults to ON DELETE CASCADE. It is RESTRICT. 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, so onDelete, a field nobody has to write, quietly turned every DELETE FROM authors into a cascade through posts, their comments, and anything hanging off those. The default is now RESTRICT: the delete fails and names the constraint, and onDelete: "cascade" is something an author asks for on purpose. Optional relations are unchanged (SET NULL), and a manyToMany junction is unchanged (CASCADE — the row it deletes is the link, not the target).

    This is a DDL change for existing projects. The next db push will plan a constraint rewrite (DROP CONSTRAINT / ADD CONSTRAINT) for every required relation that never named an onDelete, 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 push prints the statements.

    All three generators moved together (the CREATE TABLE DDL, 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.3 declared no engines at all), so a project on Node 20 installed and ran. The single source is .nvmrc, and check:floors holds 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 install answers an engines mismatch with [WARN] Unsupported engine and carries on, so the failure arrives later, somewhere with no mention of Node in it. check:release-bump now refuses a release that moves an engines field without saying so here.

  • rebase.data is gone at runtime, not only from the type. RebaseServerClient dropped data so 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.data is undefined. Server-side code says rebase.dataAsAdmin when it means the admin plane and context.data when 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 was always use the SDK (rebase.data.<slug>) — the one accessor the server omits, in the file an assistant reads before doing anything else.

  • @rebasepro/cli publishes three exports, not ninety-five. src/index.ts re-exported sixteen modules — initCommand, dbCommand, the whole of commands/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/cli at all. Three now, each with a reason: entry, because bin/rebase.js cannot run without it, and the manifest and bundle contracts, 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. app and cms moved 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.7 now. >=19.0.0 also claims React 20 compatibility on behalf of a component library, which is precisely the claim that turns out to be false. @rebasepro/app’s own typescript peer moves from >=5.0.0 to ^6.0.0: src/vitePlugin.ts imports 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 — >=5 understates it in one direction and promises TypeScript 7 in the other, where that API no longer exists.

  • rebase cloud webhooks create takes --endpoint. --url names the control plane for every command in this family — resolveCloudUrl reads it straight off the raw line, ahead of the environment variable and the link file — and webhooks create declared a second --url for the customer’s endpoint. The two parses are independent, so the documented example sent https://example.com/hook to requireClient as the host to authenticate against: the one command whose whole argument is somebody else’s URL could not run. A test now reads every spec: handed to parseCloudArgs and refuses any key the global spec already covers, because that spec is spread over the globals and silently replaces one.

  • serializeFilter emits 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-tripping gte. This is the rule serializeTuple has always followed; what changed is that serializeLogicalCondition no 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 first db migrate no longer dies on type "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 resources lists them; a host reads a project’s schedules before running it. import { queue } from "@rebasepro/types" joins database, bucket and topic: 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 unlike jobs.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 }) and storage: { 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 (usedBy on 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 dev serves every declared database. database("analytics") in config/resources.ts is the request: the development daemon starts a second PGlite instance for it on demand — without a restart, so rebase studio in another terminal keeps its connection — and exports DATABASE_URL__ANALYTICS. A variable you set by hand is never overridden. --reset removes them all.

  • A declared object store nothing binds is a local directory in development. bucket("media", { engine: "s3" }) with no S3_BUCKET__MEDIA used to answer 501 on the first upload, which made MinIO a prerequisite for uploading one file. In a development process it now stands in as uploads__media, boot says which engine it stands in for, and rebase status shows it in yellow beside the tick. Production never does this: an unbound bucket stays unbound.

  • Kinds own their binding. @rebasepro/server registers a resolver per kind (registerResourceResolver), and boot, rebase status and the gate that holds a kind’s envBases to 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 a cache is 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 — and rebase cloud resources prune database <key>, the one removal a deploy is not allowed to make. What used to be rebase cloud resources (CPU, memory, replicas, cost) is rebase cloud compute: two commands named “resources” that showed different things was a support ticket.

  • The bundle-manifest contract. tooling/contracts/bundle-manifest.json is 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, no delete --all, and every branch is a full-size copy: CREATE DATABASE ... TEMPLATE duplicates 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 safe
    rebase db branch prune --older-than 2w # and anything past two weeks

    It also finds the two ways branches drift from their metadata, which drift in opposite directions: an entry whose database was dropped outside Rebase, which list would keep reporting forever while switch and info fail against a database nothing can find; and a branch database whose entry was never written, because create makes 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 7h is refused rather than read as seven days. Atlas’s <db>_dev_diff scratch databases are reported alongside but removed only with --include-dev-diff.

  • rebase db branch reported branches the managed database had not made. PGlite serves exactly one database, so CREATE DATABASE ... TEMPLATE wrote a pg_database catalog entry and copied nothing. Every step then agreed: create answered ✓ Branch "feature_x" created successfully., and list showed it at 7.1 MB because the catalog entry makes its JOIN pg_database succeed and pg_database_size answer for the one real database. Connecting to rb_feature_x reported current_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 fresh rebase init scaffold, which is the default path. The whole branch domain is now refused there, before the database is started, naming rebase dev --docker and DATABASE_URL as the two things that work.

  • rebase db pull handed back a database the application could not read. pg_dump --no-privileges strips every GRANT, so the copy arrived with the source’s RLS policies and its FORCE ROW LEVEL SECURITY intact 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 with permission denied for table leads — after a green ✓ Local database now holds a copy of …. Anyone who pulled and then opened psql, ran rls-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 same ensureAppRole boot and db push call, 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-url was accepted and ignored. The flag never reached the resolver, so the pull went ahead against the .env database anyway: rebase db pull --from prod --database-url scratch --yes destroyed 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. create copied a 12 MB database in 1.2s and then printed Database: rb_feature_auth and nothing else: there was no switch, no --branch on rebase dev, no REBASE_BRANCH, and not even a connection string to paste. The only way to work on a branch was to hand-edit DATABASE_URL, while the documentation said the CLI updated your local development configuration — it did not, and the .env was byte-identical afterwards.

    rebase db branch switch feature_auth # every later command follows
    rebase db branch switch # which branch am I on?
    rebase db branch switch --off # back to the main database

    The branch is recorded in .rebase/branch.json as a name, never a connection string, so credentials stay in .env alone. It outranks DATABASE_URL in .env — any lower and switching would do nothing on a project that sets one — and loses to --database-url and a DATABASE_URL in 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/anonymous mints a real user row with a real uid, so a guest satisfies policy.authenticated() — which is the point of the feature, and also means “signed in” was true for anyone who pressed Continue as guest. registered() is authenticated() 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 as rebase.is_anonymous(), so a policy can ask without a lookup.

  • rls-check gained policy-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’s users table readable by anyone who could sign up. It is a separate id from the anonymous finding on purpose: different severity, different fix, and --skip should be able to silence one without the other.

  • get(id) reads a row that is expected to exist. findById returns M | 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 throws RebaseApiError NOT_FOUND (404) when it is not there; findById stays 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 behind rebase.dataAsAdmin and context.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 — and linkAnonymous(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 on auth now. Both pair with policy.registered() below: a guest satisfies policy.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_FAILED and CHANNEL_BUS_PAYLOAD_TOO_LARGE all fell through into a console warning while await 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 doctor checks 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, a JWT_SECRET production 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 .env values 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 at info, 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; and logger gained a sink, teed into the ring at warn and above with source read 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. devViews adds a tool rather than being discarded: RebaseStudioConfig declared it and RebaseStudio destructured only tools, 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/cron now carries a rejected array with the id, the schedule and the reason, counted into skipped. 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 of config/index.ts. Anything else was dropped in silence: export const storagePolicies compiled, 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 deploy prints the URL it deployed to. The one thing the command was for was left to a second command. Resolved exactly as status resolves 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 cloud groups gained the --help page the docs promised them. login, logout, whoami, link, unlink, use, open, rollback, cancel, start, stop, restart, metrics and resources all fell through to the index — a list of groups and not one flag — so login --password, link’s positional URL, the -y that stop requires 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, and resources joins the index it was missing from.

  • A headless first run ends in a box. printSummary waited for a frontend URL before drawing anything, so a headless project — the shape whose whole first run is rebase 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 url is the same answer outside the banner: the resolved connection string on stdout and nothing else, so psql "$(rebase db url)" works.

  • The version handshake the compatibility matrix already described. Three documented signals had a receiver and no sender. x-rebase-schema has been listed as “sent by the SDK” since the matrix was written and no client ever put it on a request; the transport takes schemaVersion and sends it now. runtime.version was 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. And rebase cloud requests now send User-Agent: rebase-cli/<version>, from a single cliVersion() replacing two divergent copies that read the manifest by counting directories.

  • rebase skills install has a target for every pointer file the scaffold writes. rebase init writes five instruction pointers and skills.ts knew four agents, neither of which was Codex or Copilot — so a Codex user opened a file their own scaffold had written, followed it to rebase skills install, and got a prompt that did not list them. codex, kiro and copilot join 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, since rebase db push printed the planned SQL only while refusing. rebase_schema_plan answers that question on its own, and hands a destructive plan to a human rather than approving it.

  • rebase cloud login warns 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 names REBASE_CLOUD_EMAIL / REBASE_CLOUD_PASSWORD as the route that does not touch the command line.

  • 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 no storageSource. 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 --force is now --eject, with no alias. --force means 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 --force on cloud deploy is 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 --source alike.

  • A collection’s callbacks runs on the server, and only there. The panel’s own callbacks are admin.browserCallbacks. The Vite plugin has always stripped that block’s bodies out of the admin bundle, so a beforeSave calling a vendor with a key from process.env does 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. afterSave had no client-side call site at all: the body shipped and never ran. afterRead did 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 a direct transport, 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 under admin.browserCallbacks: a separate key, so which runtime a callback belongs to is a fact about the collection file rather than about a dataSources declaration in another one — which is the thing a build-time transform cannot see. Move a browser-side afterRead into the new block; a server-side one already worked and needs no change.

  • saveEntityWithCallbacks and deleteEntityWithCallbacks run callbacks. Both have been named for callbacks they never ran, since they were written, and deleteEntityWithCallbacks went as far as accepting a callbacks prop and dropping it on the floor. They run the collection’s admin.browserCallbacks around the write: beforeSave can block a save the way the server’s does, beforeDelete can block a delete, and afterSave receives the row as saved rather than the values submitted. That prop is gone, as is the callbacks prop on DeleteEntityDialog that 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 dev was 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 answered 400 VALIDATION_UNKNOWN_FIELDS: 'posts' has no column 'subtitle', because the driver looks its columns up in backend/src/schema.generated.ts and nothing had regenerated it. Four voices told the reader four different things to do about it, and the one on screen named rebase 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 idempotent ensureGeneratedSchema call dev already makes at startup runs on a change under config/collections/, and the box says what happened rather than what to type.

  • --no-db starts 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, in prepareDatabaseEnv, which was called unconditionally. So on a scaffolded project — the one project shape where the managed database is what you get — rebase dev --no-db wrote .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 on DATABASE_URL: is required, which is the failure the flag exists to produce. The README and the Quickstart said --no-db meant “bring your own”, which described neither the old behaviour nor the new one: bringing your own is setting DATABASE_URL.

  • rebase dev --docker starts the container and reaches it. The flag changed a banner line and nothing else: the backend was spawned with no DATABASE_URL at 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 at docker-compose.yml. The URL is derived now — composeDatabaseUrl reads the compose db service and interpolates ${DATABASE_PASSWORD:-changeme} the same way compose does, producing byte for byte the string rebase init already writes into .env as the commented-out DATABASE_URL. A compose file that cannot yield one is a --docker that cannot be honoured, and it says so rather than falling back to localhost:5432 and 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 .map under dist carries full sourcesContent. So src was a second copy of the same bytes — server-postgres 7.30 → 5.39 MB unpacked, types 1.35 → 0.84, client 1.28 → 0.87, common 1.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) whose files never listed src in the first place, so the pointer had been dangling for as long as it had existed. check:package-contents refuses src/ 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 with ERR_REQUIRE_ESM, a message naming the loader rather than the decision, and no configuration recovers it. Stated in the README, and @rebasepro/mcp and @rebasepro/rls-check gain the exports map 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 init asks npm once for a version it already knew. Every @rebasepro/* package in this repository ships at one version, and check:publishable-set fails the build on the first PR after a bump if any of them drifts — so the version to pin was decided before init touched 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-skills across 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/node was ^20 in all four scaffold manifests while engines.node says 22.22, so a new project typechecked against the standard library of a runtime it refuses to run on. backend/tsconfig.json shipped moduleResolution: "node" — TypeScript’s node10 algorithm, which does not read exports maps — while its config/ sibling, whose sources the same program includes, has always used bundler. And dotenv was pinned a major behind every package in this repository, so a scaffolded project loaded .env through a different major than the CLI and the runtime do. check:templates now 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.

  • loadDeclaredStorageSources (@rebasepro/server), normalizeStorageSources and DeclaredStorageSources (@rebasepro/types). All three served the storage block of rebase.json, which the manifest validator now refuses: buckets are declared with bucket() and the graph is generated into rebase.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.

  • basePath and baseCollectionPath on <Rebase>. Declared on RebaseProps, documented as “URL prefixes when the admin is not at the site root”, and never destructured in Rebase.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 is apps.admin.path in rebase.json, which rebase build already supplies.

  • UIReferenceView, UIStyleGuide and CrmDashboardDemo leave @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 — and RebaseRouteDefs hardcoded /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 at version: 1.0.0 against a package at 0.17.3.

  • pnpm.onlyBuiltDependencies from packages/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 listed esbuild, which the workspace root deliberately sets to false under a docblock explaining that esbuild’s postinstall swaps its JS shim for a native binary and leaves every pnpm exec esbuild dying on SyntaxError: … 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 database resource kind could not load beside a driver published before 0.17.2. optionKeys gained "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, threw Resource 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.

    database now carries revision: 1, so a copy holding either published literal loses to the current definition with a warning instead of throwing. shipped-kinds.test.ts holds 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. Available stays True and readyReplicas stays 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 active rollouts row, 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-guard cron 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: False plus 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, .error and .logger — CloudNativePG’s field names. The application logger emits {severity, message, timestamp} and has never had a msg, so every notification about an application error rendered Error: (null) Detail: (null) Component: (null). It is now scoped to the app container and reads jsonPayload.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 idempotent apply.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 to media was readable through the default source. A named source now gets uploads__<key>; the default keeps the plain path, so nothing an existing deployment wrote moves.

  • REBASE_TOPIC_URL was a variable nothing read. The topic kind advertised it as a binding and rebase status listed 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_BUCKET in both resources.ts templates, STORAGE_REGION__MEDIA and REBASE_DB_POOL_MAX__ANALYTICS in 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.json says where the code is and who runs the server, config/resources.ts says 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 resources listed 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 to media answer 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 501

    It 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 local bucket, which resolves happily and is dropped in production because a container’s filesystem is erased on restart.

    The verdicts come from resolveDataSources and resolveStorageBackend — 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 slug or table was dropped without a word. CollectionRegistry registers 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 in config/collections looking loaded. Boot now fails, naming both files:

    • posts
    2 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 with getTableName — 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 admin block 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 of admin, on the reasoning that the block belongs to @rebasepro/cms-types and the panel adds to it. But the lists to check against, ADMIN_COLLECTION_KEYS and ADMIN_PROPERTY_KEYS, live in core so that core can read them, and @rebasepro/cms-types type-checks both against the option types. So admin: { 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_CONFIG policy, and every unknown-key message — at any level — carries the near-miss when there is one: “multilne is not a known property admin key and is being ignored. Did you mean multiline?” The suggester is the CLI’s, moved into @rebasepro/utils as isNearMiss and suggestNearMiss.

    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 — format on a number property and renderInForm on 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 id silently removed the enum. The ids are the labels of a Postgres enum type, so a duplicate makes CREATE TYPE "posts_status" AS ENUM ('draft', 'draft') — which Postgres refuses with 23505 on pg_enum_typid_label_index. Boot treated every unique violation on a pg_catalog index as a lost race with a peer pod, skipped the statement, and carried on: the type was never created and the column became plain TEXT. 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 blank label, 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. And pg_enum_typid_label_index is 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 on ALTER TYPE … ADD VALUE, which raises 42710, and the generator writes IF NOT EXISTS anyway.

  • rebase resources --check failed 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 a rebase.resources.json saying 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 build rewrites the file, so a project that builds is honest by construction; a runtime: "custom" project never runs rebase 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-graphs now 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 — graphToStorageSources in @rebasepro/server, which the managed boot path uses, and declaredStorageSources() in @rebasepro/types, which an ejected project’s entrypoint and the frontend use. The server’s copy carried a bucket’s account; the types copy dropped it. So bucket("media", { account: "minio" }) found S3_ACCESS_KEY_ID__MINIO on the managed runtime and found nothing after rebase eject — the source was skipped as unconfigured and every upload to it answered 501 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 declaring database("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 uses resolveDataSources + 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 form database() has had since it shipped was missing here, so configuring the default bucket meant writing the internal sentinel bucket("(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.envBases is what a generator or control plane binds from, and the bucket kind named STORAGE_BUCKET, STORAGE_ENDPOINT, STORAGE_REGION and STORAGE_PUBLIC_URL — none of which the runtime has ever read — while omitting S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY, without which a bucket cannot be reached at all. The database kind named REBASE_DB_POOL_MAX for a resolver that reads DB_POOL_MAX. Both lists now match boot/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.ts called resetResourceRegistry?.(), which is not an export — so the optional call did nothing and the test ran against whatever an earlier test had declared.

  • Every rebase db failure 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 db namespace, not only branch. 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 Drizzle Failed query: wrapper now carries the PostgreSQL error it hides in cause.

  • rebase db branch info exited 0 for a branch that does not exist. It printed ✗ Branch "x" not found. in red and reported success, while delete — answering the same question — exited 1. So rebase 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 beta created a branch called alpha. 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 ... TEMPLATE needs the source quiescent, and DatabasePoolManager only disconnects pools inside the process doing the work — rebase db branch is its own process, so a rebase dev in 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 by application_name, and --force disconnects 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 afterSave on the collection’s callbacks — 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 onto admin.browserCallbacks now, 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 carries alt="undefined …".

  • rls-check reported clean on a table the whole internet could read. The policy-anonymous-tautology check 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 on AND counting parens, <> ALL (ARRAY[…]) and NOT 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 for ALL, UPDATE and DELETE policies, where the same predicate decides who may write.

  • A bundle that vendors its own node_modules booted two copies of the framework. New pods crash-looped with Could 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/types and the bundle’s vendored copy; registerResourceKind keys off globalThis deliberately, so both registered into one registry, and the two specs for kind database had diverged by a single optionKeys entry. 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.

  • zod was 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 an extend schema cannot be built from the wrong one; loadEnv refuses 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 is Failed query: <sql> and not one word of Postgres’s answer — the SQLSTATE, message, detail and hint all sat in .cause and 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 create 404’d because invoke() percent-encoded a function name the CLI had folded a project id into, so storage-provision%2F<id> matched a route that had been live for six weeks.

  • A restored, empty scroll entry asked the API for limit=0. initialItemCount fell back with ??, which catches null and undefined but not 0 — and 0 is exactly the value that means nothing was restored. Any filter combination matching no rows saved data: [], so returning to that view rendered Invalid limit: 0 instead of a table, and a client bug wore an API failure’s clothes. (#37)

  • The default scaffold’s first data read returned 500. rebase init writes no DATABASE_URL, so rebase dev takes the managed PGlite path — and nothing on that path replaced the template’s stub schema.generated.ts (export const tables = {}). The database was fine: boot created every table, /health answered 200 and auth worked, while every GET /api/data/* returned Table not found. rebase dev now generates the Drizzle schema before starting anything, whichever database is behind it.

  • rebase db push against the managed database failed twice over. First pq: SSL is not enabled on the server, because PGlite’s socket server speaks no TLS — its remedy box then told the reader to append sslmode=disable to DATABASE_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, and push, generate and migrate stop up front on that database with the two things that do work.

  • A --headless project read as misconfigured on a clean boot. The source bundle’s manifest declared entry.collections and entry.schema from 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 JSON NO_COLLECTIONS 404 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 dev left the frontend port to Vite while the backend was started with the scaffold’s fixed FRONTEND_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-socket pins its peers to exact versions per release, so ^0.2.9 floated to 0.2.11, which demands pglite-pgvector 0.0.9 while the CLI asked for 0.0.7. The family is pinned exactly.

  • rebase --debug db branch switch feature reported success and left the checkout on the main database. switch writes a per-checkout pointer the CLI owns, and the dispatch found the word at rawArgs.slice(2)[2] — which assumes nothing precedes the command words. --debug is what bin/rebase.js prints 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 later dev, push and 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: refuseAtlasOnManagedDatabase and refuseBranchOnManagedDatabase read the domain at a fixed index and stopped refusing, and the driver reads its domain out of args[0], so rebase --debug db push answered “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-url in silence.

  • rebase db backup list created a backup. list was 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 — while rebase cloud db backup list did what it says. Both spellings list now. In the same shape: --output is canonical and --out is the alias build and cloud env pull already carry, but db backup took only --out and generate-sdk only --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 shut
    rebase schema generate --ouput x wrote the default path, in silence
    rebase generate-sdk --ouput ./sdk same, and the next build imported it

    One 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 push and db generate re-enter the schema and DDL generators with the db line, so a strict parser inside either would reject --allow-destructive on a line where it is correct.

  • rebase schema generate --help regenerated the schema. cli.ts rewrote the subcommand to --help only when the user named none, so the flag travelled into the driver, whose schemaCommand has no --help case, and it ran the generator — overwriting src/schema.generated.ts. introspect --help rewrote the collection files. It also hit requireProjectRoot first, so rebase schema introspect --help in 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. telemetry printed only the help, so a typo was indistinguishable from rebase 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 because push and pull are two edits apart and one is the destructive neighbour of the other, and an abbreviation completes only when it names exactly one command, so res never picks between reset and restore.

  • The top-level help lists the commands that were only in the source. db pull, db stop, db reset, db branch and schema stale all 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 — the case labels, the subcommand comparisons, and the driver’s own VALID_ACTIONS — so a command added without a help line is a test failure.

  • bin/rebase.js stopped 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. Now NO_COLOR / FORCE_COLOR / isTTY, the same three chalk honours. And a usage error no longer ends with “Re-run with --debug for 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.ts and doctor.ts ended their spawn in catch { process.exit(1); } — the error not even bound — so rebase schema generate against a broken tsx exited 1 with no output, and so did rebase doctor, whose entire job is to say what is wrong. db.ts was 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 numeric exitCode or 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_modules is missing, and all three read as Rebase being broken. Now: “Dependencies are not installed — run pnpm install in ”, with the package manager taken from the project’s lock file and the directory named, because the working directory is usually backend/ or frontend/ where the install would create a second, wrong node_modules.

  • Six things a first run showed you, and none of them were true. --yes says no to git init and to installing, which is the right behaviour for CI and the opposite of the interactive defaults, and --help now says so rather than leaving the reader to discover it from an empty node_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.html linked a /favicon.ico that the template does not ship and declared it image/svg+xml, so every first page load logged a 404. generated/ is written by rebase generate-sdk and 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. And rebase.json spelled npm run build --workspace frontend in a pnpm project; init rewrites it from the detected manager.

  • A warning that was always there. Every first rebase dev on a fresh project warned that .env’s PORT and VITE_API_URL were being ignored — about a file the developer had not opened yet, and both halves were wrong. PORT=3001 is 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 \s matches 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. readEnvValue matches horizontal whitespace only.

  • --no-install was in both the README and the Quickstart, and was not a flag. arg has no negation, so the second half of --install / --no-install was not “the default, redundantly stated” — it was an unknown option, and rebase init app --yes --no-install exited 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 --install when both are passed. And the example script the scaffold ships is a script that runs — scripts/example.ts is in the base template while its runner and every one of its imports were in the baas overlay only, so in the default scaffold pnpm example was a script that did not exist.

  • A boot failure names the database, the port and the reason. loadBootEnv restates a ZodError as a list of variables to fix, and its “this one is simply missing” branch tested issue.message === "Invalid input" — zod 3’s wording. Under zod 4 the message carries the types and the received field 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, and rebase dev says 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 SELECT policy 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 RelationService naming neither the collection nor the table, and the absence has two causes worth telling apart — the collection is not exported from config/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.ts keeps its bare Not found bodies: 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 requestLogger had 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=warn silenced every console.log in the process. utils/logging.ts reassigned console.debug, console.log and console.warn to no-ops, irreversibly, because the originals were discarded rather than saved — so a line that ships in the scaffold’s own .env.example muted 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.message is Failed query: <sql> and not one word of Postgres’s answer, which sat in .cause and was discarded, so four unrelated write failures read identically.

  • rebase doctor no longer ends when it cannot reach the database. checkCollectionsVsDatabase threw, the error escaped runDoctor, 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. blocked separates the two ways a phase can be skipped, because “you did not set DATABASE_URL” is a situation somebody may be content with and “the DATABASE_URL you set refuses connections” is not — the report renders them identically and the exit code does not, or rebase doctor goes green in CI against a database it never reached.

  • The dev secrets were called ephemeral when they were not. A generated JWT_SECRET is written to .env and survives every restart; boot announced it as regenerated each time, which is the one property that decides whether yesterday’s tokens still work.

  • CORS_ORIGINS is read in development too. resolveCorsOrigin returned 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. RebaseApiError now carries requestId and retryAfterSeconds — 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 through errorHandler instead of seven near-copies of the envelope written by hand, and the handler is called rather than thrown to, because defineFunction hands users a Hono app and a throw with no onError at the mount point is a 500 where a 401 was meant. client.call() returns the body verbatim like functions.invoke(), instead of unwrapping a top-level data key 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() takes WriteOptions, on the verb where a lost response is most likely to be retried into a second applied edit. And the dead collection_patch handler is gone, with its own drifting copy of the cache-merge rules.

  • page and offset are validated, like limit. 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 RebaseApiError too. The class’s own docblock says a catch block 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’s fetch felt like rejecting with. e.status and e.code were 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, listenById and count are 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 under strictNullChecks, and the realtime page had to write .listenById!(.

  • One leaf encoder for filters, so a group means what it says. serializeLogicalCondition carried its own copy and the copy had drifted from serializeTuple on 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: null went out as the four-character string, so deleted_at.eq.null was a 500 on a timestamp column and silently the wrong rows on a text one.

  • An afterRead that returns nothing no longer erases the row. The collection tier fell back with ?? fetched and the global and property tiers did not — so the same callback, one that mutates row and returns nothing, worked when registered on a collection and replaced every row with undefined when registered globally. Three call sites, one per read path.

  • A beforeDelete that returns false answers 403, not 204. It is typed boolean | void and documented as “return false or throw to block deletion”. Returning false did stop the delete — and the route then answered 204 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 afterRead is a 409 that names the callback. Request-scoped reads open their transaction READ ONLY, so a write from an afterRead — or from anything it calls — is refused by Postgres with SQLSTATE 25006, and that arrived as 500 Internal Server Error, which reads as the database being down rather than as the reader’s own code being refused. 25006 maps to 409 READ_ONLY_TRANSACTION now.

  • afterSaveError gets the error. AfterSaveErrorProps never declared it and neither driver passed it, so the handler the guide has always shown compiled and logged undefined. The caller’s real id comes with it (it was the string "unknown") and so do the previousValues the pre-write read already had (they were hardcoded undefined, hiding an update’s before-state).

  • rebase.email is there even when nothing configured mail. RebaseServerClient declares email: EmailService, not optional, and the property was simply absent whenever the backend booted without auth.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; its send() throws a sentence naming SMTP_HOST, auth.email and the dev sink, and isConfigured() is false.

  • rebase dev watches 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 new backend/functions/new.ts written while rebase dev ran 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/save and stopped — the source changed, nothing said what the change implied, and the database was left to a later db 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 — undefined is 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 a docs/backups.md path 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 to null now and each hook throws with both names.

  • The demo’s useAuth flashed signed-out on every reload. getSession() is synchronous, and in cookie mode a restore is always a round trip — the refresh token is in an HttpOnly cookie the page cannot read — so reading it on the first render answers null whether there is a session or not. The hook awaits client.auth.isInitialized() now, which is the pattern the docs show.

  • rls-check stopped hiding what a scan did not cover. A --role that is not in pg_roles was 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: pg cannot read the libpq keyword form, so host=127.0.0.1 port=1 … connected to the default host while the error named 127.0.0.1:1 — a failure reported against a host the scan never went near. The keywords are translated into explicit Client options, and the ones that cannot be expressed are refused rather than dropped.

  • Six MCP errors an agent could not act on. fetch failed is 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 that rebase dev is 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_TOKEN seeded the default project exactly once, on the first start that found no ~/.rebase/projects.json, so the block a person had just written into .mcp.json was 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 run npx 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.mjs has caught this class since it was written; packages/*/README.md was 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-api gave 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 take uid, not userId, which is what the input schema declares and what the handler reads. rebase-basics opened with two prerequisites that do not exist — sign in to Rebase Cloud, then pick a project with list_projects — and there is no list_projects tool, the MCP server has never read tokens.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 rollback refused every managed project. isRollbackable required an imageUrl, 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’s rollbackTargetOf now: successful AND (image OR bundle).

  • A mistyped rebase cloud action ran the group’s default. Four groups are written as a chain of if (action === "x") with the listing at the bottom, so a word matching nothing fell through to it: storage creat listed the buckets and exited 0, clusters verifyy listed the clusters, resources et --cpu 500m printed 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 answer unknown_command now; and billing --help names the actions billing dispatches, having documented portal and usage against a dispatch that answers setup and checkout, so both documented words fell through to the default and printed the billing account instead.

  • A piped rebase cloud <group> --help said 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 --help answered {"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=0 is 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 — clusters appeared 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 deploy asks 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>, and cli.ts dispatches cloud — the link lives inside that family. So rebase apps config backend on an unlinked checkout, and both of generate-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 every rebase <word> the CLI quotes at a reader and holds the first word to the dispatch list — check-doc-commands.mjs does 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.

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_user role by asking whether its connection role is a superuser, has BYPASSRLS, 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-FORCE policy 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 an ownerField rule. 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.

  • excludeFromApi was 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 a posts.author relation to users, 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.

  • storagePublicRead satisfied 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 global requireAuth, 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, PATCH and DELETE looked 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’s maxFileSize, 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: true API 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 TABLE and ALTER TABLE. Identifier validation ran on the introspection reads and not on the statements built from them, and the live-schema router is mounted regardless of NODE_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, and rebase dev re-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 from pg_class went 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 from REBASE_ADMIN_EMAIL / REBASE_ADMIN_PASSWORD and 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/config advertised needsSetup: true, so the unclaimed hosts were also easy to find. POST /admin/bootstrap offered 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 with SETUP_REQUIRED and says what to do instead, a first account created through open registration is an ordinary account, needsSetup is never advertised, /admin/bootstrap refuses, and boot warns when the table is empty and REBASE_ADMIN_EMAIL is 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 dev and the test suites keep first-registration-is-admin.

  • GET /api/functions handed 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 debug already 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 /.env and /.git/config answered 200; 4xx responses echoed Postgres DETAIL in 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; and customizeAccessToken could overwrite uid and roles, because custom claims were spread over them.

  • rebase auth reset-password with no password set a constant. Both reset paths used the literal NewPassword123! and --help printed 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 in src/, so src swept them in — @rebasepro/client published 27 test files. check:package-contents now 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) and qs both reach @rebasepro/mcp as runtime dependencies, not only build tooling. Both raised, with browserslist alongside them.

  • 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, and create leaves .env byte-identical. It presented DatabasePoolManager’s pool eviction as the guard against is being accessed by other users, when that only reaches pools inside the process doing the work and rebase db branch is its own process — so a running rebase dev blocks branching and always did. And nothing said branching needs a real PostgreSQL server: on the managed PGlite database CREATE DATABASE ... TEMPLATE writes a catalog entry and copies nothing, so the “branch” resolves to the database it was cloned from. rebase db branch info and --from were missing from the CLI reference as well.

  • docs/compatibility.md publishes 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-mongo is 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 init flags 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 set POSTGRES_USER: rebase_app against a postgres://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 are resources, apps init/config, six db subcommands, and every init flag.

  • @rebasepro/server-mongo and @rebasepro/firebase have 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 no backend/Dockerfile: a scaffolded project’s compose stack mounts a bundle into the published runtime image, and the only Dockerfile the CLI writes comes from rebase 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, needsSetup is 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 headline helm install was refused by the chart for the same reason (config.adminEmail has 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 carries NODE_ENV=production and the two REBASE_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 use infra/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, and STORAGE_TYPE=local on 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.md showed { message, code, status } with kebab-case codes, where the server sends { message, code, details?, requestId? } with SCREAMING_SNAKE codes and no status in 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. afterSave and afterDelete have 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 in afterSave that 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 for count(), and there is no estimated flag 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/plain 404. GET /api/collections had its own section in api.md and 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/docs and /api/swagger, named wrongly in every locale, and /api/meta/contract is what actually serves schema metadata.

  • The five RebaseBackendConfig keys nobody could find, and the seven nobody should set. compression, maxBodySize, csrf, cronPersistence and schemaEditor all 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 read init.ts. Seven others are internal and now say so. The configuration page also lists the variables the code actually reads, and DATABASE_URL stops 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 the endpoint option 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 cloud has 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.tsx with no App function, no export, a createRebaseClient at 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, and check:templates fails when the two differ. The plugins, custom-views and frontend-overview samples compile and lead with the composition rebase init actually writes; the defineFunction hover example imports from the portable @rebasepro/server/functions and stops recommending a use() that covers only the routes below it; the functions guide’s first example is app.post("/"), because functions.invoke sends POST and following the page start to finish produced a 404; and rls-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 of parseArgs to keep them there.

  • The rebase cloud recipes are the ones the CLI accepts. The deploy recipe is one command — deploy has read rebase.json since it learned to, so rebase build && rebase cloud deploy --bundle was three things to remember for a path that needs none of them — and the link sequence starts with billing setup and creates the project with flags, because rebase cloud projects create my-app is parsed with maxPositionals: 0 and exits before it reaches the control plane. Rollback says what is restored for each way of deploying, and --provider / --region are 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.

  • A driver published before 0.18 could not load beside the 0.18 runtime, and revision could not have fixed it. 346df48e2 gave the database kind a revision so that an older copy of @rebasepro/types would 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 of revision, 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. revision keeps its job among copies that understand it, and two current specs at one revision still throw.

    The duplication itself is the disease: @rebasepro/server-postgres inlined @rebasepro/types into its dist, so every pod held two registries. It is externalized now, along with @rebasepro/common and @rebasepro/utils — all three are RUNTIME_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.ts reproduces v0.17.3’s function verbatim rather than importing this build’s — the hole that let shipped-kinds.test.ts pass green on the commit the corpus failed — and asserts both load orders. tooling/scripts/test/driver-single-copy.test.mjs reads the built dist and refuses a driver that defines the registry instead of importing it.

  • Three releases published without @rebasepro/agent-skills, and nothing failed. On 2026-08-24 rebase-agent-skills/ moved under tooling/. Both release paths named their publishable packages as literal paths; the shell loops were updated and four pnpm --filter './rebase-agent-skills' were not. pnpm treats a filter that matches nothing as a warning and exits 0 — it prints No projects matched the filters "…", then does the work for the filters that did match. So the bump ran for packages/*, 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/cli depends on it as workspace:*, 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 install has been writing the 0.16.0 set ever since, which means every agent skill authored or edited in that window — including the whole rebase-cloud skill — 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.mjs derives the set from pnpm-workspace.yaml — every member that is not private — and it is the single derivation used by the workflow, by release.sh, and by the workspace-protocol validator, all three of which held their own copy of the list. Publishing takes no --filter at all, since pnpm -r publish already publishes exactly the non-private members wherever they live, so there is nothing left for a directory move to invalidate.

    pnpm check:publishable-set is 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 no files, or when its repository.directory no 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 under tooling/. @rebasepro/agent-skills was 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, while storage and functions/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.ts is 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 — delete rendered red whether or not it was checked, so a read-only key looked destructive.

  • Select announced every option list as “Select an option”. Its trigger hardcoded that string whenever label was not a string, so every such select in the API key panel was identical to a screen reader. It takes an aria-label passthrough now.

  • The demo has been un-deployable, not merely stale. scripts/ moved under tooling/, and app/backend/Dockerfile’s COPY scripts ./scripts kept naming the old path while the RUN two lines below it already used the new one — the two halves of one rename disagreeing, so every pnpm deploy:demo since died at that layer with “file not found in build context”. It copies tooling/scripts rather than all of tooling/, 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 admin all along and this view could neither set nor show it: the local ApiKeyMasked/ApiKeyPermission copies had drifted from @rebasepro/types and 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.
  • rebase db push was impossible for any collection declaring a { type: "vector" } property. The column compiled to VECTOR(n) in drizzle/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 with pq: 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 EXTENSION in the desired state is refused as a paid feature, an extension in a non-public schema makes the scratch database “not clean”, and --exclude filters 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 after schema apply and appends it to migrations. Excluding the column turns out to be enough on its own — a NOT NULL or UNIQUE on it is a property of the column and goes with it.

  • rebase db generate and rebase db migrate were both down for any project with a search block. --exclude is accepted by atlas schema apply and by nothing else, and the guard that added it read as a subcommand test without being one: migrate apply matches args.includes("apply") exactly as schema apply does. Atlas rejects an unknown flag before doing any work, so both commands exited with unknown 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 COLUMN for a search or vector column nobody asked to lose. migrate diff computes the current state by replaying the migration directory — which builds those columns, because that DDL is appended to migrations — and diffs it against a schema.sql that 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 stops db generate rather than being guessed at.

  • A no-op rebase db generate grew the last migration every time it ran. The CREATE SCHEMA rewrite 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.sql that db push chokes 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 split rebase db generate does.

  • rebase dev could 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 vector failed there with extension "vector" is not available, which reads like a broken database rather than a missing import.

  • rebase db push --help applied 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 create had no database, was written status: "provisioning", and stayed there: nothing was in progress, the platform was waiting for rebase 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 deploy meant two different things depending on the runtime. The managed-bundle path stopped at “deploy started” and told you to run cloud 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-follow says otherwise, and takes --wait and --timeout.

  • A cluster’s refusal was printed as if it were yours. Failures arrived as a whole Kubernetes Status object with request headers, an audit id and a flowschema uid. Worse than the noise: a 403 naming a system: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, carry platform: true in the JSON, and say in words when retrying and changing the project will not help. The untouched body stays behind --debug.

  • clusters verify never saw the id it was given. It selected one from rawArgs, which is the whole of process.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 reports permissions.allowed / permissions.denied for a cluster, so the diagnostic for a missing RBAC grant was itself unavailable.

  • rebase cloud projects create --db managed|byodb|none, defaulting to managed, so the sequence every project needs is one command rather than two. --db none is the deliberate opt-out and still prints the command that finishes the job.

  • blockedOn and nextAction on cloud status. blockedOn: null is the load-bearing value — it is the CLI saying that waiting is correct, and the only condition under which polling status makes 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-cloud agent skill, and rebase-deployment now points at it.

  • database({ extensions: ["vector"] }). Declared in config/resources.ts, it lets rebase db push and the boot schema-ensure run CREATE EXTENSION IF NOT EXISTS vector for 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.

  • A changed dimensions on 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 EXISTS would 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.
  • 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 generated tsvector column: a seeded scratch database and an empty one produce byte-identical output in every configuration, and Atlas wipes that database before it plans. The --exclude patterns were always the whole protection.
  • A managed pod could not unpack its own bundle. 0.17.0 shipped a fix for a tar failure 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:node 0775 setgid against a runtime running as uid 1000.

    No flag avoids it. --no-overwrite-dir, --no-same-permissions, --delay-directory-restore and --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 tar chmods 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.

  • check:runtime-image:boots boots the image a fourth way: mode=url into a directory the runtime does not own, which is the shape every managed pod actually has. The gate previously only ever unpacked into /bundle as the image ships it — the one arrangement in which the failure above cannot occur, which is why it stayed green through 0.17.0.

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/admin is now @rebasepro/cms, and @rebasepro/admin-types is @rebasepro/cms-types. “Admin” named two things at once — the whole panel, and the content-management half of it — and the ambiguity had already cost something: spreadsheet views, entity history, users & roles and CSV import were being sold as Studio features because there was no other name for the half they actually belong to. The structure is now three peers under Rebase — Backend, CMS, Studio — rather than a parent with two children. “Admin panel” survives only as a lowercase phrase for CMS and Studio rendered together.

    import { RebaseAdmin } from "@rebasepro/admin";
    import { defineCollection } from "@rebasepro/admin-types";
    import { RebaseCMS } from "@rebasepro/cms";
    import { defineCollection } from "@rebasepro/cms-types";

    Who this breaks, and what to do. Anyone importing either package: change the specifier, and RebaseAdmin to RebaseCMS. There is no alias and no deprecation period — a shim would keep both meanings of “admin” alive, which is the defect being fixed. @rebasepro/admin and @rebasepro/admin-types stop at 0.16.0 on npm and receive nothing after it, so a range like ^0.16.0 keeps resolving to the last release rather than breaking; it simply stops moving.

    Your collection files do not change. The admin: config key is deliberately untouched, along with every identifier named after it (AdminCollection*, Admin*Options, ADMIN_COLLECTION_KEYS), DatabaseAdmin/databaseAdmin, wsAdmin, the admin auth role, and /api/admin. Those name something other than the CMS product: the admin: block feeds a nav drawer Studio shares, and /api/admin serves the RLS audit and API keys, both of which are Studio’s. Renaming them would have doubled the churn to no one’s benefit.

    The panel’s mode value moved with the package, "content" → "cms". It is persisted per browser and migrates on read, so a browser that used the panel before this keeps working instead of holding a mode nothing matches and rendering neither half of the drawer.

  • Resources are declared, not configured. RebaseBackendConfig’s dataSources and storageSources are gone; declare them in rebase.json and the config package instead. A bundle built before this will not boot on a current runtime — rebuild it with rebase build. The runtime contract stays at 1 deliberately; see the note under Removed.

  • A collection still carrying admin.titleProperty is rejected at boot. Use admin.display.title — the same string works there. This can stop a project that starts today, which is the point: silence would mean a title quietly reverting to the derived one with nothing to explain why. Details under Removed.

  • ctx.client in a cron handler is now ctx.rebase, and userId is no longer an accepted identity spelling anywhere — uid everywhere. Both under Removed, with the reason each alias was more dangerous than the rename.

  • rebase eject infra is gone, along with rebase.infra.json and the {"$env": "..."} indirection. Resources bind from the environment on the <BASE>__<KEY> convention, which is the path every deployment already used.

  • rebase build --legacy and rebase start --legacy are now --workspace. The mode is supported, not retired, and the old name said otherwise.

  • Every deprecated API alias is deleted rather than warned about, including WhereValue<T> (use WhereValueFor) and RENAMED_SLOTS. The full list is under Removed.

  • An incoherent Kanban board now fails at boot. A board is two declarations that have to agree, and every way of getting it wrong used to parse, boot, serve rows and render — the only symptom being that dragging did not stick. checkBoardConfig now runs wherever collections load, so the runtime, rebase schema generate, the policy generator and rebase doctor all say it. An orderProperty naming a property that does not exist, or one that is not a string, is fatal; kanban with no orderProperty only warns, and the board still boots without reordering.

    This can stop a project that boots today, and the docs are why. An order key is a fractional-indexing key in base36 ("i0", "i1", "i0i"), so a number can never hold one — but the documentation said sortOrder: { type: "number" } in every locale, and five translated copies additionally nested orderProperty inside kanban, where nothing reads it. All of that is corrected. If you followed it, change the property to a string:

    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’s index.html: a 200 carrying HTML where the caller wanted JSON, from a project that looked deployed and healthy. rebase.json validation, 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: /apidocs is still fine, /api/v2 is 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.

  • rebase eject infra and rebase.infra.json. The command wrote a file documented as being “read before the environment”, and nothing read it: loadInfraConfig and bindResources had no caller outside their own tests, in either repository. The three-tier binder they implemented — file, then environment, then a local provisioner — never ran, and the header claiming the control plane injected such a file was contradicted by the control plane’s own comment saying it deliberately does not.

    Resources bind from the environment on the <BASE>__<KEY> convention, which is the path every deployment has always used. Running the command now names the removal rather than failing as an unknown app. packages/server/src/boot/local-provisioner.ts went with it — it returned STORAGE_BUCKET and REBASE_STORAGE_ENGINE, names the resolver has never read.

    Removing this drops the {"$env": "..."} indirection with it. A self-hoster wiring secrets from Vault or SOPS renders them into the environment, which is what everyone was already doing — the alternative was maintaining a second binding path no deployment has ever exercised.

Breaking: resources are declared, not configured. RebaseBackendConfig’s dataSources and storageSources are gone; declare them in rebase.json and the config package. A bundle built before this will not boot on a current runtime — rebuild it with rebase 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 rebase range 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: randomUUID from node:crypto where the crypto global would do, node:path to 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-orm and pg need 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.isIP became utils/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 than net.isIP sends 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 no node:dns can 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, the rebase singleton, route guards, typed context accessors, configuration readers, waitUntil, ApiError, HonoEnv. Its published bundle imports exactly two things, hono and hono/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 no process, Buffer or require at 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 } or undefined, with roles always an array. Every documented example used to open with const 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, getApiKey and getRequestId come with it. requireDriver(c) replaces c.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 on undefined.

    requireRole("editor", "admin") joins requireAuth and requireAdmin. 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. At SIGTERM a 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. waitUntil is 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 doctor and rebase build now report module-scope process.env reads in the functions directory.

  • rebase build records what each function needs from its host. The bundle manifest gains a functions array — 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>, the functions/<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. planSchemaChange reads the live catalogue before it plans, because whether a NOT NULL can 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 dev needs no Postgres. Getting a project running was docker compose up -d db, then db push, then dev — three steps, each a place to bounce, plus a compose file the developer then maintains. rebase dev now starts the database and pushes the schema itself. The managed database is PGlite behind a multiplexing socket server, and db 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, LISTEN returned 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-on exit 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 to PUBLIC, anon, authenticated, web_anon and rebase_user. A stack whose app role is called app_user gave every check nothing to gate on, so the scan printed a clean report for a database it had not cleared. The report now also lists unrecognizedGrantees — 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 indexes key: the DDL generator emitted index statements for exactly two things, both structures a feature owns rather than queries anyone wrote — the GIN index behind a search block and the ANN index behind a vector property. The plain case, the btree behind a where clause, had no declaration site at all.

    indexes: [
    { on: ["status", { prop: "publishDate", direction: "desc" }],
    reason: "admin list: filter by status, newest first" },
    { on: ["publishDate"], where: { prop: "status", op: "=", value: "published" },
    reason: "public feed is published-only" },
    { on: ["author"], reason: "an author's posts, and the ON DELETE cascade" }
    ]

    So the only way to have one was to write it by hand — which is the other half of this. rebase db push is declarative, so an index on a managed table that is absent from schema.sql is drift and Atlas plans DROP INDEX for it. DROP INDEX is not in DESTRUCTIVE_PATTERNS, so the auto-approved apply took it with no prompt. Measured against atlas v1.2.3 and Postgres 18, not inferred: create an index by hand, re-run an unchanged push, and the plan is a bare drop. Every hand-written index in the field has been living on borrowed time, and since a hand-written index was the only kind there was, that was the only outcome.

    Adding DROP INDEX to the destructive list would have been the wrong fix — once indexes are declarable, removing one from your config should remove it without a scare. Ownership is decided by the name instead, the arrangement policies already use. An index is named <table>_<columns>_ix_<7 hex> (_ux_ when unique), which no other namer here can produce, so a declaration you delete drops as intended and an index Rebase did not create is excluded from the diff and never touched. That also settles the introspection round trip: the existing indexes of a database you point Rebase at are foreign until somebody declares them.

    The hash is over the index’s semantics, not its rendered SQL, so reformatting the generator never renames a live object — and it is what makes a redefinition take effect at all, since CREATE INDEX IF NOT EXISTS matches on the name. (That bug is shipped today one layer over: vector-index.ts leaves WITH (m, ef_construction, lists) out of its name, so retuning an HNSW index is a permanent silent no-op.)

    prop takes a property key, never a column name, because the two differ in exactly the case people index most: a belongsTo resolves to its localKey, so author becomes author_id. where is structured rather than a SQL string — a string could not be checked against the collection’s properties and could not be fingerprinted without putting its own text in the index name. And reason is required, and deliberately not hashed: an index is the only thing a config can declare that costs money forever and whose benefit is invisible from the config, so rewording the justification must not rebuild it.

    Both producers emit them — db push on the ordinary Atlas path, and boot-time schema ensure with CREATE INDEX CONCURRENTLY IF NOT EXISTS. The first cut only did the former, which a managed-runtime tenant never runs; the derived-names contract caught it, with the whole suite green and the round trip through real Atlas clean.

    Not included, each its own subsystem: the deferred CONCURRENTLY builder for a redefinition (today a DROP + CREATE holding a lock), a size-based push gate, doctor’s index categories, introspection adoption, and the drizzle-schema side. See Indexes.

  • A pod contract the chart and the control plane both answer to. Probe paths, shutdown budgets, the bundle mount and the set of topology variables a deployer owns now live in one place that both pod builders read, instead of two hand-written lists that had already disagreed.

  • rebase cloud resources is priced, with no plan left to name, and rebase cloud projects info prints a Storage line — plus a warning or a lockout notice when the project is near or past its limit. The shared pools already enforced a per-tenant disk ceiling by setting CONNECTION LIMIT 0; the tenant’s first signal used to be their database refusing connections, with no number anywhere that would have warned them.

  • One-click deploy blueprints, an MCP registry manifest, and the security post.

  • The eight documentation pages every locale was missing are translated, with validation of what the model returns, and the landing page has a translation script of its own — the marketing pages read no markdown, so nothing had ever translated them.

  • Resources are declared, not configured — and there is one way to do it. A database, a bucket and a topic are all spelled the same way, in the project’s own config:

    // config/resources.ts
    export const main = database();
    export const media = bucket("media", { engine: "s3" });
    export const signups = topic<{ userId: string }>("signups");

    Before this, storage topology was hand-written into rebase.json while database topology lived in TypeScript, and the boundary between them was a fact about what the control plane could read before a build — a platform implementation detail a developer had no way to derive. Worse, a bucket could be declared in both, and the runtime merged them: one engine was kept and the other silently discarded. A declaration accepted and then ignored, which is the class this release removed everywhere it appeared.

    Kinds are registered, not hardcoded, because the cost of adding one is exactly why the last two ended up in different homes — a new kind needed a manifest schema edit, a validator edit and a switch statement, so the cheapest thing was always to bolt it onto whichever home was nearest. cache, queue or search now need none of that. Each kind owns its engine list and custom:<id> is always accepted, which fixes engine having been a free string: "s2" used to pass every check and fail far from the typo.

  • rebase resources lists what a project declares; --write regenerates rebase.resources.json and --check fails 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 a media bucket, has none” on a first deploy, and how a custom runtime (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 own S3_BUCKET__MEDIA while the provider-level variables — credentials, endpoint, region — fall back to S3_ACCESS_KEY_ID__MINIO and 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-once is 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 (or AUTH_EMAIL_OTP=true) adds POST /auth/otp and POST /auth/otp/verify, and rebase.auth.sendEmailOtp / verifyEmailOtp in 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/otp answers identically for an address with no account, so it cannot be used to ask whether somebody is a customer.

    AUTH_MAGIC_LINK arrives 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 beforeSave and afterSave, 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. storageTriggers fires on finalize and delete, matched with the same pattern language storagePolicies uses, 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 a GET write 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/emails serves the same capture, DELETE empties 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-reads NODE_ENV per 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 -d adds pgbouncer on 6432, for the serverless functions, scheduled scripts and BI tools that would otherwise exhaust max_connections long before the database is busy. Documented with what transaction pooling takes away — LISTEN/NOTIFY, session-level SET, cross-statement advisory locks, prepared statements — which is why the runtime keeps its direct connection. SET LOCAL survives, so RLS behaves identically through it.

  • The runtime keeps a little history of itself, and rebase cloud metrics prints it. Drawing “CPU over the last hour” from Cloud Monitoring would have made the panel unportable the day the platform moves, for a feature every self-hoster also wants; metrics-server cannot help either, since it stores only the latest sample by design. So the process samples itself — process.cpuUsage() and process.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 --replicas and --autoscale-max. Autoscaling had columns and no flags, so the console form was the only way to reach it. Two flags on the command that already writes every other dial, rather than a rebase scale verb — a second CLI surface writing the same row, whose --size medium form 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. --replicas is the floor and the spend a project is guaranteed to incur; --autoscale-max is 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. isSchemaEditingAdmin is a structural check — a driver either offers planSchemaChange or 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. planMongoSchemaChange is 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.

  • JWT verification and signing are asynchronous. verifyAccessToken, generateAccessToken, verifyDownloadToken, generateDownloadToken, hashRefreshToken and extractUserFromToken return promises. Nothing about their behaviour moved; the signatures did, and on purpose, before anything forced it.

    Every portable JWT implementation is asynchronous, because crypto.subtle is. So a later swap of jsonwebtoken — for jose, 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 in src and 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.

    jsonwebtoken is 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: jsonwebtoken stamps iat on every token it signs and jose does not, and iat is what the revocation watermark is compared against — tokens minted without it would verify perfectly and simply stop being revocable.

    RateLimiterOptions.keyGenerator and resolveLimit accept 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 returned Promise<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 absent messageId means “not reported”, never “not sent”, which is still signalled by a throw. messageId comes back without angle brackets, since it is a value to store and compare against a reply’s In-Reply-To, and one that sometimes carries brackets is a bug waiting in every comparison.

    EmailSendOptions gains headers. Several things a real sender must do are only expressible as headers and had no route through this interface at all: List-Unsubscribe and List-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-To and References, 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 a Bcc:. 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 custom sendEmail provider is reached, so the custom path is not a way around them.

    Breaking only for code that implements EmailService: a send returning Promise<void> no longer satisfies it. Callers are unaffected — they may ignore the result — and the auth.email.sendEmail hook stays permissive (Promise<EmailSendResult | void>), so an existing async () => {} 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. --vendor keeps 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/config answers one question once, from one handler. Two handlers claimed that path: init.ts registers it directly and only afterwards mounts the auth router, so the router’s copy never ran — and the two returned different payloads, one reporting emailServiceEnabled and magicLinkEnabled where the live one reported passwordReset and magicLink. 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, registration and registrationEnabled were the same boolean under two names, both advertised. registrationEnabled is 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. anonymousLogin is required for the same reason. AuthConfig in @rebasepro/client and AuthConfigResponse in @rebasepro/app are aliases of AuthAdapterCapabilities instead of near-copies of it — the SDK’s copy listed an emailServiceEnabled flag 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.

  • PATCH is the update verb for the data API. PUT was mounted on the same handler and the generated OpenAPI spec described the operation twice — once as patch, once as put marked deprecated — so a client generated from the spec had to choose, and the verb it chose meant “replace” for a handler that merges. The SDK’s update() now sends PATCH, which is what the spec has advertised since 0.14; updateMany was already there.

    PUT still answers, on the same handler, carrying Deprecation: true (RFC 8594). It was removed during this cycle and put back: every published SDK up to and including 0.16.0 sends PUT, so removing it broke clients that had no fixed version to upgrade to — see PUT on a collection answers again under Fixed. There is no Sunset date, because the removal is gated on which SDKs are in the field rather than on a calendar.

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, and display.title also 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.client in a cron handler → ctx.rebase. Its type re-exposed client.data, the alias RebaseServerClient omits on purpose so that the privileged plane is spelled dataAsAdmin on every server surface. A reader who learned client.data in a cron carried it into a collection callback, where context.data is the user-scoped plane: same spelling, opposite privilege.

  • userId as an identity spelling. AuthResult advertised uid or userId from 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 documented userId had stopped working there while getUser() and the JWT verifier still honoured it. uid everywhere.

  • RENAMED_SLOTS, the rewrite that quietly redirected the retired collection.insights and home.card.insight slot names, and the console warning beside it.

  • WhereValue<T>, superseded by the operator-correlated WhereValueFor.

  • tooltipsOpen and adminMenuOpen on both drawer components, error and padding on RelationSelector, and the ignored second parameter of getEntityTitlePropertyKey — all declared, all documented, none of them read.

  • isBootstrapCompleted / setBootstrapCompleted on 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 subscriptions map 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 from types/backend), server-postgres/utils/table-classification (from @rebasepro/common), and the unflattenObject re-export in the admin’s file_to_json.

  • rebase build --legacy and rebase start --legacy are now --workspace. The mode is supported, not retired, and the name said otherwise.

  • UploadFileResult.storageUrl is required. Every controller returns one — S3, GCS and local alike — so the ?? fallback behind it was dead code.

  • dataSources and storageSources on RebaseBackendConfig, and the storage block in rebase.json. All three were ways to declare a resource somewhere other than a declaration. Each is refused at boot, by name, with the replacement in the message — not ignored, because a key that still parses and no longer does anything is the failure this replaced.

    <Rebase dataSources> and <Rebase storageSources> are unaffected: those are props on the React provider, a different surface. Hand them declaredDataSources() and declaredStorageSources() so the list is not written twice.

  • PUT on 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 to latest did not help either. Three CLI commands are one update() — rebase cloud stop, start and restart, all through setStatus — and all three answered 404 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 on restart, 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 no Sunset date, 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 whose update() 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. When SET LOCAL ROLE came back 42501 — 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 role as 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 — applyAuthContext aborts the transaction when the switch errors, scopeDataDriver refuses 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=true is unchanged and remains the sanctioned way to run SQL Editor queries as the connection owner: that is an operator’s decision, not a failure. effectiveSqlRole reports 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 role already holds owner — rebase.sql is trusted server code whose default is the owner connection, and the EXECUTE_SQL WebSocket verb is admin-gated. This is a correctness and assurance fix, not a patched hole.

  • rebase cloud deploy read its own command word as the app name. The command parsed process.argv.slice(2) permissively and took the first positional as the app to deploy — but that slice removes only node and rebase.js, so the first positional is always the string cloud. Every documented invocation therefore refused itself: rebase cloud deploy --bundle answered This repository declares no app named “cloud”. It declares: backend, web. — on any project that did not happen to declare an app called cloud, 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 selectDeployApp and names the apps the manifest really declares, so it reads as a fault in the user’s rebase.json — and rebase apps list calls 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 a rebase build and a stale site at any other moment.

    deploy now parses through parseCloudArgs like the rest of the family, with commandWords: 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 (--bundel no longer deploys) and a second positional, rather than treating either as the app name. --url joins GLOBAL_CLOUD_FLAGS: resolveCloudUrl honours 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 named cloud would have passed against the broken parse.

  • The published types were any for anyone using modern Node module resolution. Every package here is "type": "module", and tsc writes relative specifiers into .d.ts exactly as the source wrote them — extensionless, because the source is compiled by a bundler. Under moduleResolution: "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 import any.

    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: bundler resolution saw 170 value exports, nodenext saw 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.js where the target is a directory, resolved against the filesystem rather than guessed. This is not a trade: TypeScript maps a ./x.js specifier onto ./x.d.ts under node10, bundler and nodenext alike, 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.ts in isolation. Every gate looked at something other than the artifact a stranger installs. pnpm check:dts now looks at that: it installs each built package into a throwaway directory by symlink, imports it, and asks the type checker whether the result is any — 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: url had never worked, and three independent things blocked it. The runtime’s fetch looked for a rebase-bundle.json that nothing has ever written — the CLI writes manifest.json — so no unpacked directory was ever recognised as a bundle; the entrypoint exited 1 before @rebasepro/server was 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.ts removes five @rebasepro/* packages from a bundle’s declared dependencies on the grounds that the image supplies them; docker/entrypoint.mjs supplied one. Custom functions and cron jobs therefore failed to load with Cannot 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 --watch branch of a CLI, and the image’s hand-maintained dependency list does not include it — so @rebasepro/server-postgres failed 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_HOPS was set on the functions unit and never on the api, so a default install ignored X-Forwarded-For and keyed every rate limit to the ingress. The chart also stopped offering migrationJob.mode: push, which the image refuses outright.

  • REBASE_RLS_AUDIT was a topology variable the pod contract did not claim. The runtime reads it to decide which process owns the RLS audit scan, beside REBASE_CRON_SCHEDULER and REBASE_JOB_WORKERS, but it was never added to the list a deployer owns — so a tenant could set it to false and stop their own audit with no error anywhere.

  • Two auth gaps on the WebSocket path. ADMIN_ONLY_TYPES held nine strings while the handler answers ten privileged verbs; the tenth ran SELECT 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, propKey and member with 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, canAddElements and previewProperties were 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 localStorage with typeof 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 billing and resources never printed a price. Both called invoke("pricing/quote", …), and invoke URL-encodes the function name, so the slash became %2F and the route 404’d — every time, since the commands shipped.

  • pg was imported at runtime and declared dev-only, so rebase db pull --anonymize would fail in a published CLI under pnpm’s isolated layout while resolving fine in this workspace.

  • The realtime vectorSearch refusal existed and could not fire, clearFilter reset to defaultFilter so 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-check translated every ECONNRESET into advice about sslmode=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 — exports declared 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’s app/ 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 dev announced 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-Length is 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.

  • frameworkVersion meant two different things — the framework the runtime image ships, and the framework a bundle installed — so cloud status and cloud deployments read 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-stale stopped reporting ten keys that were already correct.

  • 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.MX into 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.tag in the Helm chart (and the api / worker equivalents, or bundleUrl under bundle.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 answered pull 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 (or sharedState.requireSchemaMatch in 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 single all deployment 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-version returns 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/server while 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 an EntityCustomView meant “first among the custom views”, which placed it after a tab it was already after — the record’s own tab is drawn unconditionally first, so start and end only ever ordered the custom views against each other and no collection could open on anything but its form. That contradicted defaultSelectedView, 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 resources shows 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, and resources set sends 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 clusters was 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 add registers from a kubeconfig and points straight at clusters verify, which reports what the control plane found — reachable, what the identity may do, what is installed, and a verdict — and exits non-zero on unusable so 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 doctor reports 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, so rebase dev and rebase db push work while rebase db backup has never once succeeded. Doctor now scans .env, .env.local and the compose files for DATABASE_URL and ADMIN_CONNECTION_STRING and 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 .env is repaired.

  • The default auth emails carry your logo. All five built-in templates — password reset, verification, invitation, welcome and magic link — render email.logoUrl above the card, and the six auth call sites that each carried their own appName || "Rebase" line now resolve branding in one place. The fallback is asymmetric on purpose: appName falls 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 block data: URIs, so a non-http(s) logoUrl renders no logo rather than a broken image.

  • 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 @7xl of 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 functions and worker, whose entire claim is that they touch nothing. Both mounted a websocket server no client could reach, both held a dedicated LISTEN connection outside the pool for the life of the process, and both installed the change-capture machinery at boot: a schema, a trigger function, and a DROP/CREATE TRIGGER pair per collection table.

    That last part contradicted the invariant the runtime otherwise refuses to boot without. REBASE_ROLE=functions and REBASE_ROLE=worker are rejected unless REBASE_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 an ACCESS EXCLUSIVE lock 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 api behind 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 when node_modules is 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, a workspace: range in a monorepo — the tree is thrown away and nothing is vendored, because the init container skips installing when node_modules is present, so a partial tree does not start slowly, it does not start at all. --os=linux --cpu=x64 is 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/h2 had already been walked back to font-medium when the site and the panel were reconciled, leaving the stat variant as the last font-bold and 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-written font-bold is now font-semibold, and the font-black / font-extrabold above 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: transform for the slide-in, and that (like transform, filter or perspective) makes an element a containing block for its position: fixed descendants. 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.sort had 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 no orderBy at all, and the second answer replaced the first. The URL-sync effect mirrors the sort with history.replaceState, which react-router does not observe, so useLocation() kept reporting the search string the view mounted with — the empty one — and a re-render caused by nothing more exotic than a caller passing fixedFilter as 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. hideIdFromCollection is 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 back x-ratelimit-limit: 300 and shared an ip: 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 backup works on a generated scaffold. rebase init wrote a DATABASE_URL whose options value 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_restore behind db backup|restore, and a plain psql "$DATABASE_URL" copied out of the generated .env. It shipped because node-postgres parses URLs itself and accepts the literal form, so rebase dev and rebase db push worked and nothing exercised the URL; the --database-url branch had always encoded it, and no test compared the two. Fixed in init, .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 failed pg_dump also no longer leaves a 0-byte artifact behind, which backups list showed 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.

  • Type names claimed in prose are checked, not just the ones in code fences. Every doc verifier so far read fenced code — check-api-names greps imports, typecheck-snippets compiles 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.mjs reads backticked *Props / *Config / *Options / *Hooks / *Context / *Callbacks names outside fences and requires that something in packages/*/src declares them.

    It found, and the sweep removed: BackendHooks, UserHooks, DataHooks and BackendHookContext, taught across two skills together with a hooks.data config block — none of the four types exists and RebaseBackendConfig has no hooks key 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 *Props names 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; SomethingConfig is 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-commands had globs for the agent skills, the example READMEs and the repository’s own agent instructions — and never for website/src/content/docs/, the published documentation. Two commands lived in that gap for as long as the pages have existed: rebase db studio had a section of its own in both the CLI reference and the schema page, and rebase auth create-user was 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.md is exempt — a changelog records what was true.

  • A first-party GitHub URL must name the repository the package declares. rebase-agent-skills/README.md offered six ways to install and five routed through github.com/rebaseco/agent-skills, a standalone mirror that does not exist: npx skills add, gemini extensions install, claude plugin marketplace add and a git clone all answered 404, and both plugin manifests advertised the same address as their homepage and repository. 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 repository package.json declares 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 linking nvm-sh/nvm is untouched.

  • The release stamps the Helm chart along with the packages. Neither scripts/release.sh nor the stable publish workflow touched charts/rebase/Chart.yaml, so every release moved @rebasepro/server and left the chart on the previous number — and appVersion is the default image tag, so helm install with no image.tag rendered a version behind the one just published. check:runtime-image caught 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:chart lints 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/functions reaches 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 every fail from _validate.tpl and 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. appVersion is the default tag — helm install with no image.tag renders it — so the chart’s own documented minimum viable install is an image reference made to a user. It had drifted to 0.15.0 against a 0.14.1 runtime, which renders a tag nothing has built and lands in ImagePullBackOff. check:runtime-image now 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. createPostgresAdapter rebuilds 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.ts compares the adapter against the bootstrapper’s own key set, so the next capability is covered without anyone remembering to list it.

  • 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 EXISTS the 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 the EXISTS, where it cannot multiply the outer rows. belongsTo is included: author.name is a column of another table either way.

    Every operator works, because the compared value is an ordinary column: >= on a date and ilike on a name mean here what they mean anywhere else. The negative operators keep the rule the id filter already had, for the same reason — != is NOT EXISTS of the positive predicate, never EXISTS of 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-null and is-not-null are 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. count alone 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 BY rather than a LEFT 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 LAST ascending, NULLS FIRST descending. That was already Postgres’s default and is now written out in the ORDER BY, because buildKeysetComparison encodes the same placement and an invariant two functions depend on should be stated in both rather than assumed in one. count of nothing is 0, 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). OrderByTuple is [string, direction], the REST parameter is ?orderBy=key:direction, the driver contract takes orderBy?: string | OrderByTuple[], and a cursor names its keys by string — _score established the same pattern, and this reuses it rather than widening five signatures to carry an object that would be flattened at the end anyway. normalizeOrderBy is where the two spellings collapse into one.

    Both features are declared as capabilities — supportsRelationFieldFilters and relationAggregateSorts — 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 undefined on 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 reads undefined for it, which the sortability check would have read as a column of nulls and called reproducible before handing back rows in id order.

  • The collection.insights slot is now collection.widgets, and home.card.insight is home.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_SLOTS exists to warn about. The old names are retired, not removed, and will go in a future major version.

  • AdditionalFieldDelegate says that it is display-only. value() is async and receives the whole RebaseContext, 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/ui that every app built on it inherits.

    h1 and h2 go 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, and h4 steps 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.

    cardMixin draws surface-700/60 instead of a solid edge, and rounds to rounded-xl. The softer border is not a new opinion — it is the one defaultBorderMixin has 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. paperMixin is 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-lead is the sentence under a page title, which had been borrowing 12px body2 — so the one line explaining what a page is for was smaller than that page’s own table rows. typography-micro is the uppercase field label above a value: the single sanctioned tier below text-xs, and it earns the exception by never carrying a sentence. typography-mono carries tabular-nums, because proportional digits make a column of measurements ragged and a live counter jitter as its glyphs change width. All three are Typography variants, not new components.

    codeSurfaceMixin fixes a surface that had been inverted: code blocks sat on surface-800 (#111) inside surface-900 (#0a0a0a) cards — lighter than the thing containing them, so every inset well read as raised. It is surface-950 now, 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.solid at 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 every solid background, and this palette is Airtable-shaped, so its bright mid stops want dark ink — 14 of 15 hues were wrong. The other 8 came from onDeep (defaulting to pale) on every deep background.

    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/#fff keeps the family looking related — blue.solid gets a dark navy, not black.

    The part that made this more than a colour tweak: an outlined chip 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/darkOutlineText are 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. #0070F4 is tuned as a fill — white on it, it on white. Read as type on a surface-900 card it measures 4.36:1, and every accent link in the product sits on exactly that surface. Dark mode uses primary-light for 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 #0070F4 was 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 state did 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. LoginView keeps 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 and immediate_failed are 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-vars replaces the whole env block on each deploy and the server defaults ALLOW_REGISTRATION to 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-sdk and rebase build read config/collections through 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 a target: () => otherCollection thunk returned the namespace rather than the collection. Resolution saw an object with no slug and 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. customerId and customer were 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 a default that is not one is a genuinely wrong thunk and has to keep reaching the error.

    ResolvedRelation.target is 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 derive targetSlug and a join table; the forty-odd callers that matter read it later — PostgresBackendDriver building 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 () => otherCollection and 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 INSERT was affected, and for a mechanical reason: a refused UPDATE or DELETE simply matches no rows, which was already classified as 403 WRITE_DENIED, while a refused INSERT raises 42501 from a failed WITH CHECK and fell through to the unclassified path. All four spellings of the denial now answer the same status and the same code.

    42501 carries 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 a GRANT stays 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.

  • 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/stream is 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 AbortSignal is never called, so the handler had no way to learn the reader had gone.

  • 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.signingKeys takes PEM private keys. Access tokens are signed by the active one, carry its kid, 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_SECRET stays 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.ts said 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 with SELECT … 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: attempts increments 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 past visibilityTimeoutMs return to pending or 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. idempotencyKey is 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 a limit: the numbers look plausible and describe the first page.

    GET /data/:slug/aggregate?select=count(),sum(total)&groupBy=status, taking the same filters, or/and groups and searchString as 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) is sum_total), because a caller-chosen alias would have to be checked against the groupBy fields. count/sum/avg are 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->>country and metadata->address->>city now 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 into invalid 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|all decides 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.

    all is the default and is byte-identical to the process this server has always booted, so no existing deployment changes. REBASE_FUNCTIONS_UPSTREAM lets the api role 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_EXCLUDE narrow 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-api role left on the default REBASE_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. orderBy accepts [["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.

  • Collection tables are created on every boot path, not only the managed one. ensureCollectionSchema and ensureCollectionPolicies were called from exactly one place — the managed bundle boot. An app shipping its own image boots by calling initializeRebaseBackend directly, 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/data route, 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, set REBASE_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. assertKnownWriteFields and assertWriteValuesValid were called from the REST generator and nowhere else, so the socket SAVE handler took the client’s payload straight to driver.save:

    PATCH /api/data/users/1 { age: 999 } → 400, naming the rule
    ws SAVE { path: "users", values: { age: 999 } } → written

    Everything 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 validation block: min, max, matches, required, and the unknown-field check behind strictWrites. A realtime write that has been storing values your rules reject will now be refused.

  • A policy compiler that quoted values and no identifiers. policyToPostgres quoted the value side of every comparison and the identifier side of nothing, so a column whose name Postgres does not read back unchanged reached CREATE POLICY as a bare word — and there are three ways that goes. "createdAt" folds to createdat, the statement errors, and the collection keeps RLS on with no policy, which denies every row; columnName is used verbatim and rebase schema introspect populates it from the live database, so this is what any camelCase table adopted from an existing project did. order, default and end are syntax errors mid-clause. Worst, user, current_user, session_user and current_date are 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 .ts file inside sql`…` with no escaping, and Drizzle’s sql tag reads the cooked template strings rather than .raw — so JavaScript consumed the escapes first. A rule written using: "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 .sql file, 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, \s and \w went 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 same parseQuery the root list uses and then built their options without it, so GET /authors/1/posts?vector_search=embedding&vector=[…] came back 200 with rows ordered by id 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. countRawEntities forwarded filter, logical and searchString to driver.count and dropped vectorSearch. A threshold is a WHERE clause, not a hint, so a similarity-filtered listing was served narrowed rows beside the count of the unfiltered set — three rows with meta.total: 25 — and paging forward then handed back empty pages while hasMore stayed true, until the offset walked past the inflated total. The standalone /count route answered the same inflated number.

  • ?offset= became a cursor value on every non-Postgres driver. Two paths serve GET /api/data/<collection>: restFetchService when the driver has one, and fetchRawCollection for everything else — mongo, firebase, anything a developer registers. The second passed String(offset) as startAfter, which is a cursor row, and never passed offset at all. The caller got page one every time, with a meta block reporting the offset it had asked for.

  • A subscription is a query, and two of its fields never arrived. logical and offset were accepted at every type-checked boundary and then dropped, because CollectionSubscriptionConfig did 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. An or(...) 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_MS read as “no timeout”. Number("") is 0, 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 with REBASE_FUNCTIONS_TIMEOUT_MS=${SOMETHING} and no SOMETHING in the environment, and a .env line 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=0 announced http://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 wrote 0 into 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 EXISTS reads 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 a REVOKE as 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/register reads 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: neither createUser mapped its driver’s duplicate-key error, so the second request raised a bare 23505 or E11000, 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/enroll is live on MongoDB and landed in the repository’s stubs — six of which threw a bare Error, 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/folder documented a bucket field and never read it. The handler read path and storageId and derived the bucket from the path prefix, so POST /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}/count is 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 — and isLocallySortable reported 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 offset paging repeat and skip rows. It now closes on _id descending, as the Postgres driver has always done. orderBy: ["id", …] also named a field no document carries — rows leave the driver with _id renamed to id, so id is the only name a caller has — and Mongo answered by ignoring the sort. It maps to _id now.

  • 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_ascending and sort_descending were 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 with save_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.

  • A validation.matches pattern that will not compile is now fatal at boot, instead of silently deleting the rule. toPattern rebuilds the RegExp per request and answers undefined when the pattern is malformed, and its caller reads if (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.ts now compiles every validation.matches at 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 RegExp literal is unaffected — the engine compiled it where it was written.

  • BREAKING: the API is camelCase throughout. author_id is now authorId. The wire carried two naming conventions at once, and which one a field landed in was not inferable from outside. GET /api/data/users answered displayName, photoURL, createdAt; GET /api/data/posts, next to it, answered author_id. Both were “the wire names”. These are also the where and orderBy keys 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 columnName renames 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 belongsTo on author served the author_id column under its own name;
    • introspection wrote the raw column name as the property key, with columnName restating 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 posts still shows author_id, no migration runs, and rebase doctor reports 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_id stops compiling and row.authorId starts. This is the good case — the compiler names every call site for you.
    • Hand-written where and orderBy keys. A filter key that no longer resolves is a 400 with UNKNOWN_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 fetch consumers, and anything reading a row by key. row.author_id is now undefined. There is no compiler to find these; grep for the column names your relations derive.
    • rebase schema introspect over an existing database no longer echoes column names on the wire. A customer_id column is generated as a customerId property carrying columnName: "customer_id", and is served, filtered and sorted as customerId. 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 columnName you set still names the column.

  • BREAKING: anonymous sign-in is opt-in. POST /auth/anonymous answers 403 until you set auth.allowAnonymous: true. Anonymous sign-in is registration that never asked: it inserts a users row and assigns defaultRole exactly as POST /auth/register does. 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: false and disableSelfRegistration: true — whose own docstring calls it a “hard kill switch: block self-registration outright” — POST /auth/register correctly answered 403, and two unauthenticated requests produced an email/password account anyway: POST /auth/anonymous for the row and the session, then POST /auth/anonymous/link to 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 true would 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_ANONYMOUS is the env spelling.

    disableSelfRegistration overrides it — an account created without credentials is still an account created by the public. allowRegistration deliberately does not gate it: a public read-mostly app that wants anonymous sessions and no sign-up form is a real deployment, and allowAnonymous: true says exactly that. /auth/anonymous/link is 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/config and getCapabilities() now report anonymousLogin, 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 same defaultRole as a registered one and no policy can say otherwise. That needs is_anonymous in 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 was admin.titleProperty and 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.

    display names 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 and useColumnsIds. The property paths are checked against your own properties the way the rest of the admin block 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.titleProperty still works. It is deprecated, not removed: it shipped in 0.13.0 and is still read at runtime, with display.title winning 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.yml presents rebase build + docker compose up as the way to self-host and pins REBASE_VERSION to the released version, but nothing published rebasepro/server on a release — cloudbuild-runtime.yaml has had a Docker Hub push for months and runs only when someone types gcloud builds submit. So the first command in the file a new project is handed ended at pull 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.mjs keeps 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.mts could 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.

  • updateMany and deleteMany, the counterparts createMany never 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. updateMany takes { id, data } entries, not flat rows carrying their own key: on a table keyed on a sku or 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-row update(id, data) and leaves nothing to infer. deleteMany takes 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/delete rather than DELETE /<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 ignore requestBody on 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));
  • @rebasepro/client-postgres is gone. It was published on every release since the client-postgresql rename — 137 versions, latest on 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/client already is.

    It was also quietly broken. fetchCollection re-listed seven of FetchCollectionProps’ twelve fields by hand and dropped offset and logical, so find() returned page one beside a correctly-narrowed total, hasMore never went false, and findAll()/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/client with a dataSources entry; that is what the admin panel does and what docs/data-sources.md has 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() kept body = {} when JSON.parse threw. 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 and getOne() 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_URL at the frontend’s own host and /api/data/posts lands on the single-page-app fallback, which answers 200 with index.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 RebaseApiError with INVALID_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 introspect opened every generated file with const ordersCollection: PostgresCollectionConfig = {, and that annotation widens properties to Record<string, …>. Every key-shaped field in the admin block is derived from those keys — propertiesOrder, listProperties, sort, display.title, fixedFilter — so annotated, they accept any string. Introspection was emitting a propertiesOrder array 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, which rebase init has always written, and which keeps the keys literal. A propertiesOrder entry naming no property is a compile error, and the compiler names the column the rename left behind.

    Which defineCollection is 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 admin block
    @rebasepro/cms-types defineCollection from @rebasepro/cms-types yes
    @rebasepro/common (a --headless project) defineCollection from @rebasepro/common no
    neither a PostgresCollectionConfig annotation, with a warning no

    The headless flavours emit no admin block, on the collection or on any property. That is a fix rather than a downgrade: @rebasepro/types declares no admin field at all — the augmentation in @rebasepro/cms-types is 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/common is 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 target thunk’s return type is written out, target: (): AnyCollectionConfig => authorsCollection. Without an explicit type on the const, a relation cycle — posts belongs to authors, authors has many posts, or a self-referencing employees.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. createDataRateLimiter was 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, GetObject and its egress bytes, ListObjectsV2.

    The download path was the worst of it. With storagePublicRead: true — a documented, ordinary setting — readAuthMiddleware resolves to a no-op, so GET /file/* was anonymous, unauthenticated and unlimited. One machine looping over a large public object is a full GetObject and 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 --policies reported a clean database with row level security switched off. ALTER TABLE posts DISABLE ROW LEVEL SECURITY leaves every row in pg_policies untouched, and pg_policies was 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 collections for a table Postgres was applying no filter to at all. Requests run as rebase_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 relrowsecurity in 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 next db push would have fixed it; until then, doctor said it was fine.

    Drift now reports rlsDisabled first, 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 public SecurityRule field and the generator emits AS RESTRICTIVE, but the DDL parser captured that group into a discarded slot and pg_policies.permissive was 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>. RebaseProps was generic over USER and not over the database, so its client prop was pinned to RebaseClient<unknown> — and RebaseClient<unknown> is not a supertype of RebaseClient<Database>. The untyped branch of RebaseSdkData is an index signature ([slug: string]: SDKCollectionClient), and no concrete instantiation satisfies it, because RebaseSdkData’s own collection method is not an SDKCollectionClient.

    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 reaching data.products through the prop handed back Record<string, unknown> rather than the generated row. RebaseProps and Rebase now take DB, inferred from the client and defaulting to unknown, so existing untyped callers are unaffected. wrapAsEntityData asks for Pick<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 used extends and went on compiling with the bug restored.

  • Retiring the pre-1.0 auth schema could drop a helper still in use. The cleanup refuses to run while a policy calls auth.uid(), and that half is safe by construction — Postgres records a dependency for a policy that references a function, so DROP FUNCTION refuses on its own. The function half has none: a LANGUAGE sql body written as a string literal is never parsed at creation, so nothing is recorded, RESTRICT has 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 auth schema — anything calling auth.uid(), auth.roles() or auth.jwt() from its body — the schema is now kept, and the boot names the functions holding it so you can repoint them at rebase.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 - then and 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.

    formatRelativeTime in @rebasepro/utils is now the one implementation: the distance is Math.abs, so no branch can see a negative number, and the tense comes from the sign rather than being assumed. It returns null past 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 a useState initializer, 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 .map produces valid JSON that survives JSON.parse and fails one line later. readStoredJson/writeStoredJson cover all four cases — storage that throws on access (Safari private browsing, SSR), text that is not JSON, JSON of a rejected shape, and a setItem over quota. Also fixed alongside: an empty stored tab list left no active tab, a stored tab with no id could never be closed, and a non-numeric stored pane size laid the editor out at NaN.

  • An orderBy whose 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: whatever JSON.parse returned was assigned to an option the REST layer reads as orderBy[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 did 5, true, null and ["name"]. A direction of sideways was 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.

  • PORT was parsed but never checked, so PORT=oops started the dev server on NaN. resolveStartPort range-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. One parsePort now serves both; an unusable PORT warns 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("") is 0, 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 as NaN. Both are absent values and both are now null, which is what the importer’s own validation exists to catch.

  • An offline read ignored orderBy unless 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 for orderBy got whatever the store happened to hold — in the panel, a collection’s sort silently 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 icon on a NavigationGroupMapping did 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. indented survives on DrawerNavigationItem for an app that wants the categorised look, and nothing in the framework sets it. DrawerNavigationItem and DrawerNavigationGroup are exported now, with their props types, since overriding Shell.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”. An add_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.onUpdate reached three navigation-capable calls for one save — the opener’s props.onUpdate, then a replace onto the saved record’s address or closeEditView(), then closeAfterSave(). 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 onUpdate closes the panel and the status !== "existing" branch replaced it afterwards — so the close lost, the new entity’s panel stayed open, and the replace landed in the picker’s slot and destroyed it.

    It raises exactly one panel navigation now. closeOnSave is honoured (declared, documented, passed as true by 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; and props.onUpdate runs 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 a replace() after it writes into the slot below — so the fix removes the pairing rather than sequencing it. Written up as class 28 in docs/bug-classes.md.

  • One column the table could not describe took the whole table with it. propertiesToColumns runs 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 — getColumnKeysForProperty read .type off an undefined map child, getResolvedPropertyInPath read .type off a missing path root, and propertiesToColumns threw 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 choice getSortablePropertyOptions had 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 listenCollection deliberately collapses identical queries onto a single socket subscription while keeping one callback per subscriber — each of which ran its own count. One collection_update fanned 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/count for 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: normalizeToEntityRelation returned null for anything that was not an object and PropertyPreview read that null as a type error rather than as “not fetched yet”, giving a red “Unexpected value” box per row wherever a relation sat in previewProperties, and a silently blank column elsewhere. ArrayOfRelationsPreview dropped 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. getRelationTargetPath reads the target from either form that carries one, the stamped resolvedRelation or the inline relation, 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 of previewProperties, 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 carryEdit now, 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 from sessionStorage at 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 containerRef was never put on an element — the component returned <ListView> directly — so the ResizeObserver never fired and containerWidth stayed 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 getBoundingClientRect on 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 useNavigationBlocker already 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 every validation.required property 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>Update schema derived from the input schema with required dropped — 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 createMany whose ACK was lost duplicated the whole batch. WriteOptions.idempotencyKey was accepted on create and 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. Through create that duplicates one row, through createMany every row in the batch up to maxBulkRows (1000 by default) — and the offline queue replays createMany on exactly this path. upsert: true hid it for callers who set it, and upsert is documented for re-runnable imports rather than for crash recovery.

    POST /<collection>/bulk claims 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. createMany accepts WriteOptions across the client and the contract, and the offline replay passes op.mutationId, which closes the loop.

  • A dead public type, a closed error-code union, and docstrings that had drifted. RebaseBrowserClient was exported and documented as “the shape produced by createRebaseClient()”, and produced by nothing — that factory returns CreateRebaseClientResult. It also hand-duplicated ~16 members of RebaseClient rather than deriving from them, so it was a standing drift source as well as a false claim. Deleted.

    RebaseApiError.code was a bare string while the server emits a known set, so e.code === "NOTFOUND" type-checked exactly as well as the spelling that works. It is RebaseErrorCode now: the nine codes any route can answer with, unioned with string & {} 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> --help ran the command instead of printing a page. Two bugs stacked: cli.ts rewrote 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 --help through as the action would have fixed only the groups that switch on it; the rest take rawArgs and ignore the action, so the flag did nothing and the command ran anyway — cloud env --help failed on “No project specified”, cloud deploy --help began resolving a project, and cloud link --help opened an interactive project picker, a prompt from a flag whose whole job is to print text and exit. --help is 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.ts asserts 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. RebaseServerClient omits data so the RLS-bypassing plane has exactly one name, dataAsAdmin — and cron undid it, typing the same singleton as RebaseClient and handing it back as client. Its own docstring admitted it (“it is only named client here”), and a reader who learned client.data there carried it to a collection callback, where context.data is a different trust level entirely. Cron exposes rebase: RebaseServerClient now, matching the singleton import and defineFunction, with client kept as a deprecated alias typed to keep its data member so existing cron files compile and run unchanged.

    The larger find is the documentation. The callbacks guide stated, in all six locales, that context.data bypasses RLS and has “full database access regardless of the triggering user’s permissions”. It does not: authenticated writes run through withTransaction, 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 hasMany was 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 of properties — 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 every hasMany and manyToMany property in every project grew a stray field.

    getChildViewRelationPropertyKeys is the missing half of getEntityChildViews: 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 resolved relationName rather than on the key, so a relation declared in relations and pointed at by a differently-named property is recognised too. A relation nested inside a map gets no tab, so it keeps its picker; a belongsTo/hasOne is 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 relations that 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’s include: ["*"] 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 a propertiesOrder that 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. getRedundantChildViewColumnIds answers 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 include can 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, readOnly and disabled took a JsonLogicRule, which is Record<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/isDisabled answer 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. getEntityPreviewKeys took the first few properties from propertiesOrder and read that as a claim that they summarise a record; propertiesOrder states 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 stated previewProperties is 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: PropertyPreview rendered admin.markdown as 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. truncate is white-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 custom admin.Preview component, 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. SlotValue took textOnly as 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-types did 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 built dist and so ignored edits until it was rebuilt. admin-types was added later and missed the list, and repeated the same bug.

  • A monthly cron job spun at 112 iterations a second instead of waiting. setTimeout holds 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. scheduleNext had a floor on the delay and no ceiling, so a job like 0 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, a cron_claims INSERT 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 called content, found none and threw — and the catch logged at console.debug and returned undefined, which RebaseRoute renders as null. 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 at console.warn: returning bare null is what made a one-line lookup bug look like missing data, with nothing above debug to search for.

  • optionalAuth returned 500 on backends that do not issue JWTs. A backend authenticating through an adapter — Firebase, Clerk, anything with its own tokens — never calls configureJwt, 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 ask isJwtConfigured() 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 resolveFormLayout now — the same call the form makes — and renders each field through the same FieldBlock and grid span, with a PropertyPreview where 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: additionalFields need 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 take hideLabel, because BooleanPreview printed 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 — #full replaced 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 open was 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; with autoOpenDrawer={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. tooltipsOpen and adminMenuOpen are deprecated no-ops on DrawerNavigationGroup and DrawerNavigationItem.

    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 true is 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 mouseleave when 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-open key, so a second admin on the same origin — a different basePath, 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 div pretending 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 true across 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, autoOpenDrawer was destructured in Scaffold and then never read, so the hover handlers were attached unconditionally: an admin passing autoOpenDrawer={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 — and autoOpenDrawer={false} genuinely turns it off. Second, the open/closed state was plain useState — every reload threw the choice away. It is persisted in localStorage, keyed by the admin’s base path, so the last toggle is what you get back. defaultDrawerOpen still 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_id where 0.12 derived categorie_id, and boot-ensure renames the database column to match — that half worked, data intact. Then relation validation read the project’s checked-in backend/src/schema.generated.ts, which the previous release generated and which still says categorie_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 advising through.targetColumn: "categorie_id", which by then existed nowhere. Following the fix instructions broke the relation for good.

    Three changes. rebase dev now 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 stale reports 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 run rebase schema generate, and no longer suggests pinning the migrated-away column. rebase build was 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.ts is that seam.

  • rebase.dataAsAdmin.projects.find() did not typecheck — nor did data.products.find() on the Entity accessor — for any project without a generated Database type. The untyped branch of RebaseSdkData and RebaseData declared their index signature as SDKCollectionClient | ((slug: string) => SDKCollectionClient), unioning in the collection method’s own signature on the theory that a named property must satisfy the index signature it sits beside. It does not here: collection is declared in a separate member of an intersection. The union bought nothing and cost property-style access — the form the type’s own @example shows, the scaffolded function template uses, and the 0.13 rebase.data migration 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 config workspace. config/tsconfig.json pins types: ["node"] — deliberately, to stop tsc sweeping pnpm’s virtual store — but config/package.json never depended on @types/node, and under pnpm’s isolated layout there is none reachable from that directory. pnpm -r build, and the workspace’s own build script, failed with TS2688: Cannot find type definition file for 'node' one minute after rebase init. check:templates could not catch it: it compiles the collection files with its own typeRoots pointed 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. .env pins REBASE_VERSION to the version of the CLI that scaffolded, which is right for a stable release and wrong for every canary: only stable publishes rebasepro/server, so docker compose up died on manifest unknown — the same dead end as the missing-repository bug the pinning was added to prevent. A prerelease falls back to latest now, 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 own deps.declared at boot, so the current stable runtime boots a canary bundle by design.

  • /api/health answered 404. Health lives at /health, outside basePath, 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 --introspect contradicted 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 CONSTRAINT forever. 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.

  • 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.txt now 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_case columns — rendered from a fixture built to make every naming rule fire at once. pnpm check:derived-names classifies 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 in docs/compatibility.md.

    It pins a second contract hiding inside the first: that rebase db push and 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.ts replays 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.sh records 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.ts builds a registry from a stale generated schema while the database has been migrated; generated-schema-staleness.test.ts pins the detector including its no-false-positive cases; check:runtime-image refuses a shipped compose file naming an image no automatically-triggered workflow publishes; check:templates additionally 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/data route 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 donor node_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.mjs symlinks only @rebasepro/server from 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.fetchWithDrizzleQuery was 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 to row-pipeline-null-relation.test.ts against toRestRow, which is what production runs, and was checked by deleting the guard and watching it fail. resetConsole went too: it snapshotted “the originals” after configureLogLevel had already replaced them, so it captured the no-ops and restored them over themselves. Neither was public — api-surface/server.api.txt is unchanged. Corpus fixtures are renamed after the bundleFormat they carry, since v2 collided with the runtime contract major, which decides something else entirely.

  • Rebase creates exactly one schema in your database, and it is called rebase. The RLS helpers move from auth.uid() / auth.roles() / auth.jwt() to rebase.uid() / rebase.roles() / rebase.jwt(), and the auth schema is removed.

    auth was Supabase’s name, borrowed so that a developer who had written Supabase RLS would recognise auth.uid(). The familiarity was real; the name was not ours to take. Pointing Rebase at a database that already had an auth schema meant applying CREATE OR REPLACE FUNCTION auth.uid() RETURNS text over Supabase’s RETURNS 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 against auth.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. The auth schema is then dropped — each function matched on its own result type and body first, and the schema by RESTRICT, never CASCADE, so anything else living there keeps it.

  • The scaffolded database role is rebase_app, not rebase. Postgres resolves unqualified names through "$user", public, so a role named rebase put the new rebase schema ahead of public for any tool that does not pin search_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 get rebase_app in the generated docker-compose.yml and .env. If you are following a deployment guide you had already copied, the connection string is the thing to update — a stale postgresql://rebase:... against a freshly generated compose file fails with password authentication failed for user "rebase".

  • rebase.data is gone — use rebase.dataAsAdmin. The server singleton had two names for one accessor, and the shorter one gave no hint of what it does: rebase.data and rebase.dataAsAdmin were the same admin-scoped, RLS-bypassing driver. data is 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();

    RebaseServerClient now extends Omit<RebaseClient, "data">, so this is a compile error rather than a silent privilege. The property still exists at runtime, aliasing dataAsAdmin, so an untyped JavaScript caller keeps working instead of failing on undefined mid-upgrade — the type is the contract, and it is the type that changed.

    Corrected since release: this paragraph listed context.client.data in entity callbacks and client.data in 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 through context.data, which runs with the privilege of whatever triggered the callback; in a cron handler, use rebase.dataAsAdmin from its context. Unaffected: rebase.data in 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/common defineCollection
    buildProperty @rebasepro/common a plain property object
    RebaseUser @rebasepro/client User from @rebasepro/types
    RebaseTokens @rebasepro/client AuthTokens from @rebasepro/types
    UserInfo @rebasepro/app User from @rebasepro/types
    Session @rebasepro/app DeviceSession from @rebasepro/types
    AuthApiError @rebasepro/app RebaseApiError from @rebasepro/types
    DatabaseConnection @rebasepro/server DriverConnection
    createApiKeyRateLimiter @rebasepro/server createDataRateLimiter
    resolveChannelBusConfig @rebasepro/server-postgres resolveChannelBusSetting

    Every one is a rename at the import site. The three that are not purely cosmetic:

    createApiKeyRateLimiter skipped 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. createDataRateLimiter covers signed-in users and anonymous callers too, and has been the wired default since it landed.

    buildCollection / buildProperty were 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/common for 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.

    DatabaseConnection is 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 for DriverConnection, and the canonical DatabaseConnection from @rebasepro/types that the package re-exports. Deleting the alias leaves one. If your import resolved to the alias, it was the driver connection and wants DriverConnection; if it type-checks unchanged, it was already the canonical one.

  • Default foreign-key column names were mangled for irregular plurals, and are fixed. generateForeignKeyName singularized by chopping a trailing s off the snake-cased name, which produced categorie_id for categories, addres_id for addresses, never child_id (it gave children_id), and — because toSnakeCase splits on every capital before the chop — ur_l_id for URLs. It singularizes first now, with the package’s real singular(), then snake-cases. Two guards: a double-s ending 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 than ADD 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.

  • firestoreToCMSModel and cmsToFirestoreModel are renamed to firestoreToRebaseModel and rebaseToFirestoreModel in @rebasepro/firebase. They reached consumers through the package barrel’s export *, so this is a breaking rename with no alias — a shim would keep the word in the API it is being removed from. (toCmsRow → toFlatRow moves with them, but is internal to server-postgres.)

  • MongoDB search matched no field. buildSearchConditions selected searchable columns with prop?.dataType === "string". No property in @rebasepro/types has ever had a dataType field — a real collection carries type — so the loop matched nothing for every collection a user could declare, orConditions came back empty, and the fallback turned every search into a $text query, which needs a text index and throws IndexNotFound without one. The suite passed because its fixtures were written with the same wrong key.

  • admin.widthPercentage is gone — use admin.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: 33 and 35 produced 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, otherwise 4. 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.

  • RebaseAuthConfig is gone from @rebasepro/cms-types — use RebaseAuthViewConfig. It was a compatibility alias for a name that collides head-on with RebaseAuthConfig in @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-router 8, and react-router-dom is gone — react-router 8 deletes the react-router-dom package outright. It was only ever a v6-compatibility shim: everything DOM-specific had already collapsed into react-router itself in v7.

    @rebasepro/cms, app, studio and plugin-ai now peer react-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 from react-router. RouterProvider is the exception: it lives in react-router/dom.

    The floors underneath move with it, because react-router 8 requires them: react and react-dom peers go to >=19.2.7 (were >=19.0.0), and engines.node on @rebasepro/cms and app to >=22.22.0 (was >=20). Declaring >=20 while 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 emits import.meta verbatim under module: commonjs. Separately, react-router depends on cookie-es 3, which ships .mjs only, and TypeScript keys module format off the file extension, so it will not emit CJS for a .mjs input whatever module says. 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.cjs in this repo handles both and is a reasonable thing to copy. Vitest is unaffected.

  • rebase cloud deploy --source on 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 --source alone — read as self-evidently a deliberate eject. It is not. --source answers 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: managed to custom, discovered afterwards from rebase cloud status showing frameworkVersion: null. The bare form had been refused for the identical reason since the release below; --source was 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 --force says to.

    rebase cloud deploy --source . # ejected, with a warning
    rebase cloud deploy --bundle # stay on managed — almost always what was meant
    rebase cloud deploy --source . --force # eject on purpose

    The refusal carries code: "managed_project", which is what it already used, so a caller already branching on that code needs no change.

  • rebase db branch keeps the name you give it. Branch names were stripped of everything outside [a-zA-Z0-9_], so rebase db branch create my-feature answered ✓ Branch "myfeature" created — a different name than the one asked for, and the only one list would 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 --from had 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 with Invalid 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-feature from an older release is recorded as myfeature, and that is what list shows and what delete takes. delete and info now 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 at rb_my-feature, which is either nothing or somebody else’s database.

  • realtime.requireAuth: true opened the socket instead of closing it. The connection handler seeds every session with authenticated: !requireAuth, so a requireAuth that resolves false does not skip a later check — it marks each connecting client as already authenticated. Both sockets computed it as

    authConfig.requireAuth !== false && !!authConfig.jwtSecret

    which 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 than auth.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.ts had resolveRequireAuth: no auth configured means auth is required, an AuthAdapter always means required, and only an explicit requireAuth: false opens it. The socket carried its own copy, and the two disagreed on the case that matters most: with no auth configuration at all, /api/data answered 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 on ANONYMOUS_USER_ID ('anonymous'); the request path scoped unauthenticated callers as 'anon'. So policy.authenticated() — the sanctioned, documented way to write “signed in”, the thing the linter tells you to use — compiled to auth.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 INSERT on 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_ID everywhere 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) and isAnonymousUid().

    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 to NOT 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, and policy.not(policy.authenticated()) is the supported way to say it.

  • auth.requireAuth: false no 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? — and false is 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/logs and /api/admin/backups for anyone who could reach the service. A single warn per surface at boot was the only notice, and on a --allow-unauthenticated Cloud 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 a jwtSecret — independent of requireAuth. 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 an rk_ API key created with admin: 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/contract is 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 AuthAdapter and no auth.jwtSecret there 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 one warn per 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/cron reads 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 no auth — or one whose JWT_SECRET quietly 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 >=X is 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. uuid had 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-quote and protobufjs. Re-resolving moved no package version, so the bounds themselves are hardening only.

  • @hono/node-server in the scaffolded backend goes from ^1.19.12 to ^2.0.12, closing GHSA-frvp-7c67-39w9 (a serve-static path traversal on Windows via an encoded backslash). The 1.x line has no patch, and @rebasepro/server already peered ^2.0.12 — a new project was being handed an adapter two majors behind the server consuming it.

  • customProps in the collection editor was marked deprecated by accident. It carried a @deprecated Superseded by span tag that belonged to widthPercentage and slid onto the next field along when that one was deleted. customProps is live — it is how a custom Field or Preview receives its props, and PropertyFieldBinding reads 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 denormalised ejectsManagedRuntime boolean for CI to test directly; both fields are always present, so false never has to be told from absent.

  • deploy printed 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 one progress() 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_mode is a record of what the last deploy made a project, and it carried DEFAULT '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: custom was 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, and rebase cloud status and 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 to custom before use, so nothing else changes. Existing rows are deliberately not backfilled — a row saying custom today 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 out for all but one of them, thirty seconds in. The socket was healthy: probed directly, six concurrent subscribe_collection frames all answered inside 15ms. The frames were never sent.

    ensureAuthenticated published 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 under auth_${Date.now()}, the one request id with no random suffix, so attempts in the same millisecond collided in a Map and only the last survived. One promise settled; the frames waiting behind the others never reached the socket. Client-side navigation skips the path (isAuthenticated is 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. handleDragOver moves 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 to onItemsReorder, 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-indexing keys 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: under en_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-1 item defaults to min-height: auto, so the view holding the board grew to the board’s full content height — 1230px inside an 883px area — and the ancestor’s overflow-hidden cut 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 Date instance, 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_COLORS from four tones per hue to one, which left every colorScheme="blueDark" resolving to undefined — 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, and ChipColorKey is a real union rather than keyof 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 url a statement about the data — it feeds format: "uri" into the OpenAPI contract — and moved presentation to admin.urlPreview. The example’s admin: { url: "image" } had both halves in the wrong place, and expanded likewise belongs in the admin block.

  • User is exported from @rebasepro/client and @rebasepro/app. The removals above tell a caller to import User from @rebasepro/types, which was not an instruction a browser app could follow: it installs the client (or app) package alone, and @rebasepro/types is 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. User now sits beside RebaseSession, AuthTokens and DeviceSession, 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.form is 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 / #history URLs open the inspector on the pane they name.

  • Two gates for things that were rotting silently. pnpm check:examples typechecks examples/*, which were in no pipeline and no root script — pnpm build covers ./packages/* and ./app only — 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 way pnpm typecheck does, so they catch a class of drift the source-resolving gate structurally cannot see.

    pnpm check:generated regenerates the committed website artifacts (llms.txt, sitemap.md, the changelog mirror) and fails on a diff. llms.txt had been sitting a commit behind the docs it summarises.

  • rebase.json is rebuilt around one authored runtime — the manifest had four unrelated fields named mode, 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 of backend/src/index.ts, which every scaffolded project had, so every project predating the manifest silently landed on the custom runtime. App types reduce to backend and static: the admin is an ordinary static app, because RebaseCMS takes its collections as a build-time prop, so a platform-hosted admin was precluded by the component’s interface rather than merely unimplemented. Top-level runtime becomes rebase, so the word means exactly one thing. In the bundle manifest, mode becomes kind: "backend" | "static", entry.static becomes a list, and entry.admin is 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>/collections exists.

    Static apps declare a path and 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 default base: "/" but served at /admin (blank page, every asset 404, no server error) fails rebase build; serveSPA orders longest-path-first and excludes siblings, so a miss under /admin can no longer be answered with the site’s index.html; and folding appends to entry.static rather 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 — removing backend.mode from 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 a collectionsDir pointing 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/contract and /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: outside packages/cms and admin-types these 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 RebaseTranslations type are renamed with them. One collision worth knowing about: studio_sql_admin already existed as a different string, so studio_sql_cms became studio_sql_collections_label rather than being merged onto it.

    packages/firebase is deliberately untouched: FireCMS, firestoreToCMSModel and the optional DataDriver.delegateToCMSModel are 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 in CMD ["pnpm","start"] — running the entrypoint the managed runtime never loads, and which is no longer scaffolded. docker compose up on a fresh rebase init was 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/server with ./dist-bundle mounted — so one container serves the API at /api and the admin at /, same origin, no CORS between them and no nginx. The frontend image and its nginx.conf are 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 a docker-compose.custom.yml together and does not touch the scaffolded compose — so going back stays a one-line change in rebase.json rather than a restore from git.

  • Nine presentation options move into a property’s admin block — fixedFilter, includeId and includeEntityLink on a reference or a relation; widget on a relation; sortable and canAddElements on an array; previewProperties on 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 in properties.ts that 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-config reads them off ADMIN_PROPERTY_KEYS, so nothing is silently ignored.

    widget is the one to check first, because it was never working: AdminRelationOptions already declared it and the admin only ever read that one, so every top-level widget: "dialog" had been quietly rendering a select. Moving it into admin is what makes an existing declaration take effect.

    Two options that look like the same case stayed on the property, and deliberately: propertiesOrder, because sortProperties in @rebasepro/common reads it recursively and a driver calls that — a core package cannot see the admin block at all; and keyValue, because it says the map has no declared shape, which is what the OpenAPI generator emits additionalProperties from.

  • The SQL-only fields are rejected on a document-store collection — table, relations and disableDefaultPolicies are declared on PostgresCollectionConfig alone, and columnType/columnName are omitted from the Firestore and MongoDB property maps. A MongoDB collection could be written with a table name and a columnType: "bigserial", and nothing anywhere read either.

    DataSourceCapabilities had been reporting this all along — supportsRelations and supportsColumnTypes are both false for 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 through registerDataSourceCapabilities is included rather than excluded by a hardcoded "postgres".

    securityRules is 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 to CREATE POLICY, MongoDB translates it into a filter AND-ed into every read and write. supportsRLS answers whether an engine generates policies, which is a different question from whether it honours a rule.

  • rebase cloud deploy needs no flag on a managed project — a bare deploy used to be refused with “redeploy it with rebase 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 declares runtime: "managed", the flag is redundant and the bare command builds and ships a bundle. --source and --bundle-dir are explicit acts and still win, and the refusal stays for the case it was written for: a manifest that says custom deploying over a project the platform runs.

  • rebase cloud status says 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 @rebasepro release 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 — LoginView takes an onNewsletterOptIn prop 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 in LoginView rather 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.

    NavigationGroupMapping takes an icon now — 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 12px surface-600 to 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. autoOpenDrawer looks like the prop for this and is not: it expands on hover, and always has, though RebaseLayout documented it as “auto-open the drawer on load” while Scaffold documented 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 — Scaffold accepted one and rendered it in the drawer and top bar, but nothing passed it down, so the prop was unreachable from RebaseShell — the component a scaffolded app actually mounts. Threaded through RebaseShell → RebaseLayout → Scaffold.

  • An entity action’s icon can be a Lucide name — EntityAction.icon was React.ReactElement, alone among a collection’s icons; admin.icon and entityViews[].icon were strings already. An element cannot be written in the config package 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 through getIcon at every render site.

  • A collection’s entityActions may name an app-level action by key — resolveEntityAction has always accepted string | EntityAction, the collection editor stores exactly these keys, and the sibling field entityViews is 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 onClick and 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-writing S3_BUCKET__MEDIA — and only on the managed runtime, because the ejected template parsed STORAGE_TYPE itself 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() and findAll(), so nobody hand-rolls the paging loop — find() with manual limit/offset was the whole pagination API, so every consumer wrote the same loop and wrote it wrong in the same two ways: terminating on rows.length >= limit, which mistakes an exactly-full final page for a middle one and drops everything after it, and capping findAll-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 from meta.hasMore alone. Offset paging is the default and drifts under concurrent writes — cursor: "id" switches to keyset seeking, built out of parameters find() 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 — isFilterableRelation allowed only belongsTo, the one kind with a column on this row. The driver compiles manyToMany, hasMany and hasOne into a correlated EXISTS now, so the affordance returns for them; via stays out, its join path having no stated inverse. A to-many relation also answers array-contains and array-contains-any — a to-many is the list, so “contains X” is == and “contains any of” is in, the same EXISTS under a different name. Before, the admin rendered those controls and the driver returned a 400 behind them.

  • supportsVectors on a data source’s capabilities — VectorProperty carries a dimensions and 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, and vector is now excluded from their property maps alongside relation.

  • 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 readOnly field the panel let you edit, a relation that answered []. The renames were never the problem; a rename with no runtime signal is.

    assertCollectionConfigs runs 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_SWAGGER defaulted to a flat "false", which reads as a safe default and was not one: the runtime is how every scaffolded project boots, so /api/docs and /api/swagger 404’d for projects that never asked for that. rebase init prints “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 explicit true or false wins in both. Unset in development resolves to undefined rather than true, 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: false was a dead end on a fresh database — GET /auth/config reported registrationEnabled while needsSetup, the login UI showed the first-admin form on the strength of that, and POST /auth/register then refused it. POST /admin/bootstrap could 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. disableSelfRegistration stays a hard kill switch above even bootstrap, and /auth/config stops advertising registration when it is set instead of pointing the UI at a form that can only 403.

  • @rebasepro/server loaded 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/server arrives transitively. Every custom function imports defineFunction from @rebasepro/server, so functions held the bundle’s copy while initializeRebaseBackend() initialized the image’s. With the instance in a module-local variable, rebase.data, rebase.dataAsAdmin and rebase.storage threw “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 where query parameter was never read — the OpenAPI document publishes where on every GET /api/data/{slug} and the relations docs use it to narrow a subcollection list, but parseQueryOptions never looked at it. It was also missing from reservedQueryKeys, so it fell through to the per-field ?field=op.value loop 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 same deserializeFilter the 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.

  • serveSPA 404’d routes that merely shared a prefix — exclusion was a startsWith, so /api excluded /apidocs and /admin excluded /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. apiBasePath is 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 — sanitizeErrorForClient only knew how to unwrap Postgres errors, so a thrown ApiError lost its message, its code and its status on the way to the client and took a logger.error line with it. That is the whole diagnosis for a realtime subscription: the admin list prefers accessor.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’s expected flag. 5xx is unchanged: still a generic message, still logged at error, so internals stay server-side.

  • rebase schema generate emitted a schema that does not compile — rel.localKey is a column name and the generated Drizzle object is keyed by property. They coincide until a property is camelCase — userId stored in user_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 through resolvePropertyKeyForColumn; the belongsTo branch 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-s3 in 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 @rebasepro package, not just the one that needs it — the first cut of the singleton fix redirected every @rebasepro package 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-postgres pointed the database driver at a copy with no chokidar and the pod crash-looped. @rebasepro/server is the only package that both needs the redirect and is provably safe to redirect.

  • Two published packages imported dependencies they never declared — @rebasepro/firebase declares firebase as a peer dependency, but every source file imported the scoped subpackages — @firebase/app, @firebase/auth, @firebase/firestore and four more — which appeared only in devDependencies. Rollup externalises every bare specifier, so those imports survived into the published dist verbatim. 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/inference shipped the same way with two packages.

  • A scaffolded project could not run rebase build — backend/tsconfig.json and config/tsconfig.json left types unset, so TypeScript swept every reachable node_modules/@types and treated each folder as an implicit type library. Under pnpm that reaches the virtual store, where packages hoisted for peer resolution live — dompurify among them, pulled in transitively by the admin editor — and every scaffolded project failed with TS2688: Cannot find type definition file for 'dompurify'.

  • A custom runtime was built a bundle it never deploys — rebase build had no runtime check, so an ejected project — whose artifact is an image built from its own Dockerfile and entrypoint — still got a dist-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 via serveSPA.

  • The headless scaffold’s backend had nothing to compile — moving storage.ts into the config package left backend/src/ empty in the headless flavour: it declares no collections, so there is no generated schema, and the entrypoint moved behind rebase eject. The tsconfig still said include: ["src/**/*"], and tsc reports an include matching nothing as TS18003 — 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: true is what grants a key the admin role: the admin-gated routes, and the RLS default_admin policies. @rebasepro/client declared its own CreateApiKeyRequest without 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. Passing admin: true was 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_at was a string from Postgres and a Date from MongoDB — the same interface name in two driver packages, plus a third spelling in the admin’s useHistory hook, so nothing could read history without first choosing a driver. EntityHistoryEntry in @rebasepro/types is the wire shape and carries a string. MongoDB’s Date was 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. excludeFromApi is 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-level relations had 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 were strictWrites, disableDefaultPolicies, filterOperators and urlPreview; url was being dropped too, and it feeds the generated OpenAPI contract.

  • Importing a table wrote a relation shape the framework no longer accepts — pgColumnToProperty existed in two copies. The one the collection editor called emitted the pre-union flat relation (cardinality/direction, replaced by the kind tagged union) and CRUD verbs where SecurityOperation takes SQL ones, typed any[] 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-null family, where as Record<string, string> where a filter is an [operator, value] tuple, and orderBy as 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 from ALL_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 EXISTS subquery 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. Inside db.query.findMany, which aliases the root table, that produced

    EXISTS (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 joinPath branch 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_password got two Reset Password buttons — the injector skips its action when the collection already has one, but it read .key off 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 in list 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 and not-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.5 parses 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_FIELD naming 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’s localKey, whose default is snake-cased and singularised, so userProfile is user_profile_id and users is user_id — and an explicit localKey is anything at all. With unresolvable fields now failing closed rather than widening, that guess turned an ordinary belongsTo filter 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() and data.jobs.find({ include: […] }) disagreed. With include the 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. findById was always the second. The generated types described only the envelope, so a column the schema calls a foreign key was typed string and 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 — isFilterableRelation hardcoded the four kinds the Postgres driver compiles. Only Postgres declares supportsRelations today, so the claim happened to be true, but it was a fact about a driver stated where no driver could see it. It moves to DataSourceCapabilities beside filterOperators, 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 — RelationFieldBinding demanded a top-level relations array 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: resolveRelationProperty handles 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 createRebaseClient built 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, since setToken, setAuthTokenGetter and 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.

  • buildCollection and buildProperty are removed — not deprecated, removed. Both were FireCMS-migration shims that had been superseded by defineCollection, and keeping a deprecated alias around in a framework that has not shipped 1.0 only buys two ways to write the same thing.

    buildCollection was 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. defineCollection uses a const type parameter to capture the literal, which is what puts your property keys into completion for admin.titleProperty, admin.sort and admin.propertiesOrder. buildProperty wrapped 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 prefer defineCollection in new code. The scaffold templates and every docs example now use it.

  • where and orderBy are now checked against the row type — FindParams was not generic, so its where was FilterValues<string> and its orderBy an untyped OrderByTuple. Passing a generated Database to createRebaseClient typed 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 a map/jsonb column; its root must be a real column. include is unchanged — relation names come from relations, not from the row type, so nothing in Database can check them.

    M defaults to Record<string, unknown> all the way through, so an untyped createRebaseClient() behaves exactly as before. The chain that has to stay intact is createRebaseClient<DB> → SDKCollectionClient<M> → FindParams<M> → FilterValues<FieldPath<M>>; a non-generic alias anywhere along it silently flattens M back to the default, which is precisely how the re-export in client/src/transport.ts (export type FindParams = TypesFindParams) hid this. e2e/baas-typecheck/src/sdk.ts now pins it with @ts-expect-error, so pnpm check:baas-types fails 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 by string, because a Partial<Record<FieldPath<M>, …>> is read-only under a generic M (TS2862) and cannot be built up in place.

  • The admin block’s key fields are now checked against the collection’s properties — titleProperty, sort, propertiesOrder and listProperties reject 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.ts merged the block on as admin?: AdminCollectionOptions with no type arguments, so M fell back to its default Record<string, unknown>, Extract<keyof M, string> widened to string, and every key-shaped field accepted anything. defineCollection computed 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 map property ("profile.displayName" — the root is checked, the path below it is not), a child-collection column ("subcollection:orders"), and an additionalFields key. That last one needs an explicit cast, because AdditionalFieldDelegate.key is a plain string and nothing carries those keys into the type:

    import type { AdditionalFieldKey } from "@rebasepro/cms-types";
    propertiesOrder: ["title", "score"]
    propertiesOrder: ["title", "score" as AdditionalFieldKey]

    Only defineCollection turns the check on — it is what supplies M. A plain const x: PostgresCollectionConfig = { … } annotation infers nothing, so these fields stay permissive there, exactly as before. A type-level test in packages/cms-types/test/admin_collection.test.ts now pins all four fields with @ts-expect-error, so the seam cannot reopen without a build failure.

  • CollectionConfig reports Postgres in its type errors — CollectionConfig is a union discriminated on engine, and Postgres collections omit engine because it defaults to "postgres". An incomplete Postgres literal therefore matched no member, and TypeScript elaborated the failure against the last constituent — MongoDB. Leaving out name, the most common mistake there is, told a Postgres user of a Postgres-first framework that they were missing engine on a MongoDBCollectionConfig. Postgres is now last in the union, so the same mistake names PostgresCollectionConfig and only the field actually missing. No runtime or assignability change; error text only.

  • Admin-panel presentation moved into an admin block — 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 from collections.ts — so a backend that never renders anything still pulled the React layer into its type graph, and @rebasepro/types could not be a backend contract while it depended on React.

    @rebasepro/types is now the React-free BaaS contract; the presentation layer lives in a new @rebasepro/cms-types that depends on it, and nothing in core depends back. pnpm check:baas-types typechecks a full BaaS project — backend, driver, collection file, SDK reads and writes — with react mapped 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 with defineCollection from @rebasepro/cms-types — it captures the property literals, so admin.titleProperty, admin.sort and admin.propertiesOrder complete over your own property keys instead of string.

  • 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 a localKey wrote the foreign key onto the parent row, because a to-many has no single row to point at; a many-to-many carrying foreignKeyOnTarget claimed a column on the target that the junction table owns. Both compiled, and both were shipped.

    Relation is now a closed union discriminated on kind, and the link moves under a relation field 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), and via (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.

    via is the only kind that still states a cardinality, 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. direction is gone: which side holds the key is what the kind says. inverseRelationName is gone with it; the schema generator finds the counterpart by scanning the target’s relations.

    scripts/codemod/relations-tagged-union.mjs migrates a codebase — it rewrote 232 declarations across 46 files here. It refuses to guess: anything it cannot decide is marked kind: "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 ResolvedRelation with defaults already filled in and writable / shared decided 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 a via could 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_tags is a table, whether author_id is a column, or whether a joinPath actually 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, so posts/1/tags answered [] — 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.table is 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 EXISTS subquery 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-tautology is critical on Rebase and PostgREST but only low on Supabase, where auth.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/:childId links 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.

  • geopoint and binary are real field types in the admin panel — both were in the property model with nothing behind them. geopoint was 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. binary resolved to the plain text field, which offers multiline, markdown and email (none of which mean anything for bytes) and whose editor merges type: "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 finite 0 and 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_input joins 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 build now emits dist-bundle/: compiled collections, functions, crons and schema plus a generated manifest.json recording the runtime range it needs, a schemaVersion hash, its declared dependencies, and whether it uses native modules. @rebasepro/server boots it (bootFromBundle, bin rebase-server), and docker/server.Dockerfile publishes 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.yml is that, ready to run.

    A repo-root rebase.json declares topology only — the runtime compatibility range and the apps this repository contributes (backend, static, admin, mobile). Schema, rules, hooks and functions stay TypeScript in config/, which is the point of the product and does not move into JSON. rebase link accepts 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, and rebase generate-sdk --from <link|url> reads it instead of importing local config/. 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 the schemaVersion it was generated against so drift is detectable; GET /api/meta/schema-version is 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 500 on 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 deliberate rebase db push, with its dry-run and confirmation gate. none opts out.

  • Storage authorization can look up ownership — storageAuthorize received 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’s config package, which depends on @rebasepro/types alone and cannot resolve @rebasepro/server at 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 / storageSources as 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 — /metrics in Prometheus text format, off unless REBASE_METRICS=true and gated by REBASE_METRICS_TOKEN: request counts and latency histograms per surface, plus process heap, RSS and uptime. Self-hosters can scrape it directly.

  • rebase build folds a single static app into the backend bundle — the runtime already served a SPA from entry.static; nothing put the assets there. So a project whose container served its site at / and its API at /api lost 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-static opts 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, and findById answers for a row only ever seen inside a find. 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 carries fromCache, hasPendingWrites and partial, so an interface can say what it is showing. Tabs share the local database and the outbox over a BroadcastChannel, and only one replays the queue at a time. client.offline gained status() and onStatusChange() for a sync indicator, and isOfflineError() 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. createMany is not keyed, only create. Where navigator.locks is unavailable two tabs may both replay the queue.

    See Offline & Local-First Sync.

  • 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/* answers 501 STORAGE_NOT_CONFIGURED with the fix in the message, and everything else — data, auth, realtime — keeps serving. 501 and not 503, so the client’s offline queue does not retry uploads that can never land.

    The scaffolded backend matches: it configures S3 for STORAGE_TYPE=s3 and now GCS for STORAGE_TYPE=gcs, and falls back to local disk only outside production (or with FORCE_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 pull wrote 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, tsc checks the generator rather than the code it prints, and the existing tests asserted with toContain, which passes happily on a field the type no longer has. So introspection went on writing cardinality, direction and a top-level target long after Relation stopped 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 through and 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) and Direction (owning/inverse) selects. Both had been pointed at relation.kind without the controls being rethought, so their options went on writing the old vocabulary: choosing “One (has-one)” set kind: "one", choosing “Owning” set kind: "owning". Both also rendered from value={kind} while comparing against "one", so a belongsTo relation 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 a localKey — 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 empty joinPath that joins nothing; and the relations table declared five header cells while rendering four, so kind appeared under a “Cardinality” heading and “Direction” had no cell at all.

    The JSON path was never affected — validateCollectionJson checks kind against 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 — target is a () => CollectionConfig thunk, 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 with target still a string, while every consumer in the codebase calls target(). The cast to Property at the end of that function erased the difference, so it compiled and shipped. Serialization is now switched on kind and assigned without a cast, and fromSerializableCollectionConfigs rebuilds 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 relationName straight off the authored relations array. 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 untyped object. To-one relations are left out: posts/1/author resolves, but documenting it as a paginated list describes a response the client never gets.

  • A custom Field or Preview attached as a lazy import rendered nothing — the documented way to attach one is admin: { 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 both import(...) and the require(...) that CommonJS transforms produce.

  • rebase dev could 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 to server.listen(port, host, cb), and that form registers the handler as a one-shot listening listener 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 announced http://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 PORT in .env had 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, then PORT, then affinity).

  • A bundle build said nothing about ignoring backend/src/index.ts — rebase dev runs 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 build and deploy --bundle now name the file, say it is neither compiled nor shipped, and give the two ways forward: move the routes into backend/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 deploy with 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 --source deploy 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 --bundle as what was meant; --source . and the new --force both eject deliberately, and an explicit --source deploy of a managed project warns before it does.

  • deploy --bundle could not skip type checking — rebase build has --skip-type-check and buildBundle already accepted the option; only the deploy argument spec lacked it, so iterating meant building by hand and then pointing --bundle-dir at the result. The flag is accepted on deploy now and threaded through.

  • 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.yml had 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 broken rebase 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.yml that 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.1 rather than localhost, which resolves to ::1 first on macOS while the server binds 0.0.0.0.

  • The CLI init e2e leaked its frontend. rebase dev supervises 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’s tsx watch server gets a new pid whenever it restarts, so “any new listener” would have been a way to kill it).

  • rebase cloud link was broken from a fresh checkout — three prompts still used inquirer’s removed list type, so running it interactively died with Prompt 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 build produced bundles that could not boot — TypeScript emits import specifiers untouched, so a project on moduleResolution: "bundler" compiled from "./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.

  • The authenticated principal is uid everywhere — the identity had two names. uid was the domain model’s: the User type, the AuthenticatedUser adapter contract, the driver scope, and the RLS layer, where policies read auth.uid(). userId was 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 defensive a ?? b read to cope. uid wins because userId was confined to four server-side packages while uid is 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 uid claim and c.get("user") returns { uid, roles }. Anything reading payload.userId or user.userId must 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, module and the import condition all point at index.es.js; the require condition is removed. A CommonJS consumer must import() or move to ESM.

  • id is an address, not a column — the synthesized id was written into rows on the way out, where it collided with the data three ways: it renamed the key (a sku primary key was served as id, with sku absent entirely), it changed the type (an integer key reached the SDK as "42"), and it destroyed real values, because drizzleResultToRow spread it last so it would win over a raw id column. Rows now carry their own columns under their own names and types. Code reading row.id on a table not keyed on id must 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. The id case is called out specifically: create(data, id) writes the id argument as an id column, which is meaningless for a table keyed on sku, so the error names the real key instead of sending someone hunting for an id they 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 to auth.uid() IS NOT NULL, a tautology on the user path: applyAuthContext coerces 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 permissive auth.uid() IS NOT NULL until db push runs again — nothing re-applies policies at container boot, so redeploying and restarting change nothing. rebase doctor --policies reports it: alongside the name-keyed diff it scans the live qual/with_check of 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 of pg_policies directly to confirm those. Then run db 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 SELECT policies. 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-owner rebase_user role, so Postgres RLS binds every statement: SELECT, INSERT, UPDATE, DELETE. A collection’s securityRules are now the entire authorization model; callbacks (beforeSave and 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 SECURITY is gone, since the user role is already a non-owner. The opt-out is disableDefaultPolicies. Isolation is provisioned at boot and on db push / migrate; a privileged connection that cannot be isolated fails boot with the exact setup SQL, and connecting as superuser or BYPASSRLS warns 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. core was frontend-only React while server-core was the actual core of the product; they shared a word and were otherwise unrelated. client-firebase depended on admin/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-ai

    Unchanged: types, utils, common, client, ui, admin, studio, cli, plugin-insights.

  • @rebasepro/auth removed — it was one hook and an API helper whose only dependency was @rebasepro/types, and it always had to be installed alongside core anyway. useRebaseAuthController, fetchAuthConfig, createAuthConfigCache and clearAuthConfigCache now come from @rebasepro/app, beside the RebaseAuth and LoginView components they are used with. The auth system was never here — it lives in @rebasepro/client (client.auth) and @rebasepro/server.

  • defaultSecurityRules moved off the server config — it lived on RebaseBackendConfig, was applied to the in-memory registry, and enforced nothing: db push generates the Postgres policies — the only thing that actually enforces access — from the collection files, and never sees the running server. Declare it in config/collections/index.ts instead, where the loader reads it and both the runtime and db push see the same thing. Its old doc also claimed collections without rules were “unrestricted”; they are locked to admin-only by the generator. In baas mode there are no collection files and no db push, so the database’s own RLS is the whole model and there is nothing to default.

    // config/collections/index.ts
    export 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" on RebaseBackendConfig is 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.

  • Presence and broadcast channels in the SDK — the realtime engine had supported join_channel, broadcast and 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, since on() 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 provides track / 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, so join() sends an explicit presence_state to 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 existing RealtimeService, matching Supabase Realtime’s WAL-tailing model: an idempotent AFTER INSERT/UPDATE/DELETE trigger per managed table emits pg_notify, a dedicated LISTEN client 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_CDC is auto by default: on where the connection supports it, silent fallback to app-level otherwise (wal degrades to trigger — 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. requireAuth and publicRead are 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 /metadata rather than /file/*, because /metadata mints 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.executeSql with hand-bound parameters, which is where injection bugs live. createMany(rows, { upsert: true }) is available on both the HTTP and server-side clients, and as POST /api/data/:collection/bulk. Every row still runs the normal pipeline — callbacks, relations, RLS — because saveMany reuses save(); the win is that the batch shares one transaction and one round trip. upsert is INSERT ... ON CONFLICT DO UPDATE on 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 through relation makes the generator create a table nobody declared, and those were the one kind of generated table with no RLS at all. Since rebase_user holds 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 correlated EXISTS subqueries 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_VERIFIED rejection 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 authenticated POST /auth/link/:provider now attaches a provider identity to the current account, with a matching client linkProvider(). 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 409 IDENTITY_ALREADY_LINKED when 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 setTimeout and 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 an s3:// / 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). A rebase.backups client surface and server routes expose the same operations, and the scaffold’s .env.example documents the settings.

  • rebase cloud reaches operational parity — project slugs replace UUIDs across every user-facing surface (--project takes the subdomain the console URLs show; raw UUIDs still resolve for old scripts and link files), plus rebase cloud debug for diagnosing deployed projects and rebase cloud storage create / attach. rebase init gains real --project / --setup-key handling — 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_roles when picking values for SecurityRule.roles, which matches the strings on the users table via auth.roles(). Choosing public or rebase_user there compiled to a condition no user could satisfy. fetchApplicationRoles now sits alongside fetchAvailableRoles across the SQLAdmin surface, 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 from information_schema; the drizzle tables the query layer needs are built in memory, so no generated schema.generated.ts is 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. introspectionSchema on the Postgres adapter selects a schema other than public.

  • 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 optional collections option exists only to pin non-obvious slugs.

  • rebase init --flavor baas — scaffolds a headless project: backend/ alone, no config/, no frontend/, and no UI package in the install tree. Without --flavor, init asks: BaaS + admin (default) or BaaS only.

  • rebase doctor --policies — diffs pg_policies against 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. Reuses generatePostgresPoliciesDdl — the same function db push applies — 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 rewrites qual/with_check on storage, and a check that cries wolf gets ignored. They are still scanned, for one shape — the fail-open auth.uid() IS NOT NULL tautology, 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:headless imports 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:names fails 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 place workspace:* 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 password and provider fields the users table does not declare as columns. The skip was total, so POST /api/data/users with an undeclared emial returned 201 and dropped the field, while the same typo on posts was 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 names password); everything else is validated as on any collection. An auth collection with a custom onCreateUser hook opts out, since the hook then owns the body’s shape.

  • POST /auth/refresh with 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 answered 400 INVALID_INPUT and logged a warning for every anonymous page view. Absent-token is now 401 NO_SESSION, logged at debug; a present-but-malformed token is still a 400. ApiError gained an expected flag (and an unauthenticated() factory) so a routine outcome no longer looks like an incident in the logs.

  • The generated docker-compose.yml could not boot — 63108aa90 made the server refuse to start with local storage under NODE_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 sets NODE_ENV=production and does mount a durable named volume at the storage path, which is the exact case the check tells you to acknowledge with FORCE_LOCAL_STORAGE=true — but the template never set it. So docker compose up, the “recommended for production” path in every scaffolded README, crash-looped the backend with Failed 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-url shipped a compose stack with the password changeme — DATABASE_PASSWORD was only written on the branch that generates a local database. Supply your own --database-url and it was omitted entirely, so docker-compose.yml, which interpolates ${DATABASE_PASSWORD:-changeme} into both POSTGRES_PASSWORD and the backend’s connection string, fell back to the literal default — on a db service that publishes a host port. The password is now generated in both cases; the supplied URL is untouched.

  • rebase init told you things that were not true — the next steps were assembled from the flags you passed rather than from what actually happened. --introspect without --install printed “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 the schema introspect and schema generate commands that finish the job. In the same pass: the cd hint used the project’s basename, so init apps/my-app said cd my-app — a directory that does not exist from where you are standing — and init . told you to cd into a directory you were already in; both now use the path you typed, and in-place scaffolds print no cd at all.

  • rebase init --help printed the wrong help — init was missing from the dispatcher’s namespaced-command list, so --help fell through to the global command index. --template, --flavor, --yes, --database-url, --introspect, --project and --setup-key were 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. init now has its own help, and a test fails if a flag the parser accepts goes undocumented.

  • --git left the work half-done — it ran git init and stopped, leaving every scaffolded file untracked on whatever init.defaultBranch happened to be, so the first git diff was noise and the first commit was the user’s problem. It now lands an initial commit on main, authored by the user’s own git identity where one is configured. .gitignore is in place before the commit, so .env and its generated secrets are never in it while .env.example is.

  • --template was 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 --template does 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 our clientId via tokeninfo before the identity is trusted, and ID-token paths read the real email_verified claim instead of hardcoding it. On Microsoft, emailVerified is derived from a provider-provisioned mail mailbox rather than asserted true, 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/bootstrap was 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/users returned every user their own passwordHash and emailVerificationToken. 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 them ui.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 bodyLimit was registered after the routes, so Hono never ran it. A wrapper router now applies it in front. Storage also accepts API keys under a new storage permission namespace (read/write/delete), where rk_ 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, with rk_ 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. verifyAccessToken now refuses any token with a purpose claim 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 a securityRule writes a policy under a new name, and policies.sql only DROPs the names it is about to CREATE. Because Postgres ORs PERMISSIVE policies together, a superseded USING (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_timeout fires 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 the finally releases the client back to the pool with no error. pg-pool then re-pooled it mid-transaction with the app.* 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_TYPE defaults to local, 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=true remains 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_one are in the expectsResponse = false set, 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 init failed when installed from npm, hung on a non-interactive terminal, and defaulted to the wrong package manager; pnpm start now 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_CONFIGS are read lazily, and the two WhereFilterOp definitions 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, ensureAppRole grants rebase_user SELECT/INSERT/UPDATE/DELETE across the schema, and nothing enabled RLS, because that only happens via db 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 the ALTER TABLE needed 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 — pgRoles sets a policy’s TO clause, 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’s authenticated/anon/service_role.

  • The demo app’s collections were empty — every collection but users granted pgRoles: ["authenticated"], a Supabase role name, while requests run as rebase_user. RLS filtered every row; authors and posts granted TO public, which is why they were the only two showing data. They now use the documented API (select: public, writes admin), the same shape rebase init scaffolds. The generated drizzle/policies.sql carried the same policies and is regenerated — it is what db push applies, 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) got jwt malformed on every connect and silently received no realtime events.

  • collection-file → UI package imports no longer drag React into the backend — users.ts imported resetPasswordAction from @rebasepro/cms, so the Node backend loaded the entire admin bundle at boot. The action is already injected frontend-side for auth collections, making the import redundant. @rebasepro/cms is also gone from the config and backend templates, and @rebasepro/core/ui from @rebasepro/auth — none were imported.

  • 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 writes REBASE_SERVICE_KEY into the scaffolded .env with a regex, and the regex put \s* before the variable name — where \s matches newlines. While the CLI shipped that line commented out, the # anchored the match and it worked. 259ef0b7a made 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 dev falls 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 via E2E_BACKEND_PORT / E2E_BAAS_BACKEND_PORT.

  • Every init template 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.ts takes 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.ts pins the reporting defects above so they cannot return: next steps that contradict what happened, a cd that points at a directory that does not exist, undocumented flags, an uncommitted --git tree, 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 included src, 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 as Permission denied on "posts" — a failure with no visible connection to its cause. The suite now checks that every linked package’s dist/ is newer than its sources and, if not, names the packages and the build command instead of running.

  • 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 ENTITY from its message names. The Entity type 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>
    EntityView EntityCustomView
    EntityCollectionView DataCollectionView

    Callback API (CollectionCallbacks) — beyond the rename, the parameter shapes changed:

    Old Param New Param Notes
    entity (in afterRead) row Now a flat Record<string, unknown>, not an Entity<M> wrapper
    entityId (in save/delete) id string | number
    previousEntity previousValues Partial<EntityValues<M>>
    afterCreate / afterUpdate afterSave Use status: "new" | "existing" to distinguish

    Migration 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_ENTITY FETCH_ONE
    SAVE_ENTITY SAVE
    DELETE_ENTITY DELETE
    COUNT_ENTITIES COUNT
    subscribe_entity subscribe_one
    collection_entity_patch collection_patch
  • Unified <Rebase> data props — Removed the data and driver props. There are now exactly two ways to provide data: client (server transport) and dataSources (everything else). A dataSources entry keyed "(default)" with a driver replaces client.data as the default source — this is how a fully client-side app (e.g. Firestore-only via RebaseFirebaseApp) is wired. Migration: driver={x} → dataSources={[{ key: "(default)", engine: "firestore", driver: x }]}; data={x} had no known users (custom backends implement DataDriver, 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 Binding suffix. This is a search-and-replace migration — no behavioral changes.

    Types (@rebasepro/types)

    Old Name New Name
    EntitySidePanelProps SidePanelBindingProps
    sideEntityController (on RebaseContext) sidePanelController
    sideEntityController (on EntityActionClickProps) sidePanelController
    "Entity.FormActions" (override key) "EditView.FormActions"
    "Entity.DetailView" (override key) "DetailView"
    "Entity.Preview" (override key) "RecordPreview"

    Components (@rebasepro/cms)

    Old Name New Name
    SideEntityProvider SidePanelProvider
    EntitySidePanel SidePanelBinding
    EntityEditView EditViewBinding
    EntityEditViewFormActions EditFormActions
    EntityDetailView DetailViewBinding
    EntityView RecordViewBinding
    EntityPreview RecordPreviewBinding
    EntityJsonPreview JsonPreviewBinding
    DataCollectionView CollectionViewBinding
    EntityCollectionBoardView CollectionBoardViewBinding
    EntityCollectionCardView CollectionCardViewBinding
    EntityCollectionListView CollectionListViewBinding
    DataCollectionViewActions CollectionViewActions
    DataCollectionViewStartActions CollectionViewStartActions
    DataCollectionTable CollectionTableBinding
    EntityCollectionRowActions CollectionRowActions
    EntitySelectionTable SelectionTableBinding
    EntityBoardCard BoardCardBinding
    EntityCard RecordCardBinding
    useEntityPreviewSlots usePreviewSlots
    SideEntityControllerContext SidePanelControllerContext

    Bridge key (@rebasepro/core)

    Old Key New Key
    "sideEntityController" "sidePanelController"
    sideEntityController (on StudioBridge) sidePanelController
  • Client split into server/browser variants — RebaseClient is now split so the RLS-bypassing accessor is explicit: use rebase.dataAsAdmin (server-only) for admin-scoped, RLS-bypassing access, and rebase.data for user-scoped access. The public API surface was curated to hide internal plumbing.

  • update/delete throw on not-found — SDK update() and delete() now throw when the target row does not exist, instead of silently returning undefined.

  • deleteAll is 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 canonical User type.

  • 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 correlated EXISTS subquery — no per-row afterRead lookups. Adds policy.existsIn({ collection, where }) and the policy.outerField(name) operand for correlating the subquery to the outer row.

  • Built-in email → user lookup for invites — opt-in auth.allowUserLookup exposes an authenticated POST /auth/find-user and a client rebase.auth.findUserByEmail(email) that returns a minimal public profile (uid, displayName, photoURL only). Removes the hand-rolled dataAsAdmin server function that invite flows previously required. Off by default (enables user enumeration by signed-in users).

  • Mount the admin under a path prefix — RebaseCMS accepts a basePath so 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.transport is now optional: entries with a client-side driver default 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-core helper that encapsulates graceful shutdown: drains via backend.shutdown(), runs onCleanup (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.estimated flag on realtime first-paint updates. When listen() emits its immediate heuristic metadata, the emission now carries estimated: true. Redundant second emissions are skipped when the authoritative count matches the heuristic, and count failures no longer silently pretend to be authoritative — the estimated flag remains as the signal.

  • Concurrency-safe refresh-token rotation — token rotation now uses an atomic INSERT … ON CONFLICT DO UPDATE instead of a DELETE-then-INSERT. Concurrent /refresh calls (which cookie-mode boot can fire at once) previously raced into a unique_device_session violation and returned 500, breaking the session. The client also single-flights concurrent refreshes.

  • Cookie session restore — /auth/refresh now returns the user object, and the client restores the user (falling back to /me) instead of leaving a blank uid. 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-postgresql ships src/ — the driver package now packs src alongside dist, fixing ✗ Could not find CLI entry point for @rebasepro/server-postgresql for rebase db push / schema generate in published/packed installs (the CLI runs src/cli.ts via tsx; no dist/cli.js is built).

  • Malformed request bodies — the API now rejects malformed JSON bodies with 400 and 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 the afterUserCreate auth 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 --collections resolves to a missing path; and rebase dev now surfaces when it overrides the project’s .env PORT / VITE_API_URL with its derived per-project port.

  • Scaffold hardening — the frontend Vite config ships resolve.dedupe for React / React Router so a locally link:ed Rebase checkout doesn’t load duplicate React copies (which broke the admin’s data router); .env.example documents sslmode=disable.

  • Strict collection accessors — When a collections dictionary is passed to createRebaseClient, unknown property accessors on client.data now throw immediately with a nearest-match suggestion instead of silently producing a 404 later. Use data.collection("slug") for dynamic slugs.
  • 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 / buildProperty in favor of defineCollection. Both are marked @deprecated and will be removed before 1.0.
  • Removed — Unused <Rebase apiKey> prop (it was never consumed by the component).
  • Fixed — Duplicated sentences in propertiesOrder JSDoc; rewrote subcollection: description to cover both Firestore and Postgres.
  • Unified Policy & Filter Engine — Replaced ad-hoc permission checks with a centralized evaluatePolicy system and Policy type. This system translates high-level security rules into both frontend conditions (for UI gating) and backend-specific filters (Postgres RLS, Firestore security rules). Includes policyToPostgres and securityRuleToConditions utilities, ensuring the admin UI matches database enforcement by construction.
  • defineCron authoring helper — Typed identity wrapper for cron job files (parity with defineFunction). Demo app now ships a working cron job (refresh-product-stats).
  • Multi-Backend Storage Sources — Introduced a first-class StorageSource system allowing a single project to use multiple storage backends (S3, GCS, Local, Firebase) simultaneously. Added GCSStorageController for native Google Cloud Storage support with TUS resumable uploads. Managed via StorageSourcesContext and StorageRegistry, 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 new invoke_function MCP 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 PropertyConfig system that handles complex relations and references consistently across all data drivers (Postgres, MongoDB, Firestore).
  • Editable UI Table — Significantly enhanced VirtualTable with native editable cells (VirtualTableInput, VirtualTableSelect, VirtualTableNumberInput, VirtualTableDateField). Added a new SelectionStore and SelectionContext for 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, and rebase-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/client and @rebasepro/core, simplifying integration into existing applications. Consolidated data controllers, improved type inference, and refined the Rebase component props for better developer experience.
  • NPM Publishing Safeguards — Added validate-no-workspace-protocol.sh and check-packages.sh scripts to the release pipeline. These prevent publishing packages with workspace: 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, and afterDelete hooks 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 any types across the core packages, replacing them with strict types or narrowing guards (e.g., isSQLAdmin) to improve overall codebase robustness and prevent runtime errors.
  • Security Policy Tests — New test suites for evaluatePolicy, policyToPostgres, and securityRuleToConditions covering 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 VirtualTable editable fields, selection logic, and keyboard accessibility.
  • Schema Gates — Added collection_registry_property_gates tests to validate property resolution and permission-based visibility gating at the registry level.

  • Multi-Datasource Architecture — Introduced a first-class DataSourceDefinition / DataSourceCapabilities system that lets a single Rebase instance route collections to different database engines (Postgres, Firestore, MongoDB, or custom drivers). Collections declare a dataSource key, and the frontend router, backend driver registry, and collection editor all resolve capabilities from the same definition. Includes resolveDataSource(), createDataSourceRegistry(), registerDataSourceCapabilities(), and a new DataSourcesContext React 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 generic CollectionDataController<T> — no coupling to entities or the CMS data layer — making them usable in custom pages, standalone apps, and third-party integrations. Includes a CollectionViewToolbar with view-mode toggle, search, filters, and pagination.
  • Headless Entity Forms — Decoupled EntityForm, EntityFormActions, and EntityFormBinding from the admin package internals. Forms now accept pluggable field bindings and layout props, enabling standalone entity editing outside the CMS shell. Added PopupFormField for inline editing and extended form layout controls.
  • Auth Hooks Expansion — Significantly expanded the AuthHooks interface with new lifecycle hooks: beforeLogin, afterLogout, onPasswordReset, beforeUserDelete, afterUserDelete, onAdminCreateUser, onAdminResetPassword, and transformAuthResponse. The transformAuthResponse hook lets developers inject external tokens (e.g. Firebase Custom Tokens) or project-specific metadata into every auth response. Added AuthMethod type covering all authentication methods.
  • Custom Auth Adapter — New createCustomAuthAdapter() factory for plugging existing auth systems into Rebase with minimal config. Only verifyRequest is 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 the transformAuthResponse hook 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/client api-keys.ts). Keys are stored with hashed secrets; the full key is only returned on creation.
  • Atlas Migrations (replaces Drizzle Kit) — Replaced drizzle-kit with Atlas for schema migrations. Added generate-postgres-ddl-logic.ts that produces raw SQL DDL (with enums, RLS policies, and indexes) from collection definitions. Migrations are now version-controlled SQL files under drizzle/migrations/ with an atlas.sum integrity file. CLI rebase db commands updated accordingly.
  • Improved RLS Editor — Overhauled the Studio RLS editor with better policy visualization, shared table-classification.ts module (classifying tables as rebase-internal, junction, or user), 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 ECONNREFUSED error handling with actionable diagnostics, and fixed chalk CJS 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 skills command 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 EntityPersistService input validation, and added .dockerignore / .gitignore rules 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 legacy formex .yarn/cache artifacts, updated CONTRIBUTING.md, README.md, and AGENT.md. Deprecated export documentation moved to docs/DEPRECATED_EXPORTS.md.
  • UI & Ergonomics — Multiple ergonomic fixes across the admin panel: improved Sheet/Dialog focus management, refined DrawerNavigationGroup and breadcrumb context, stabilized navigation resolution hooks, and cleaned up BreadcrumbsContext and CollectionRegistryContext.
  • Multi-Datasource Tests — New test suites for buildRoutedRebaseData, resolveDataSource, collection_registry_datasource, routing_integration, multi-datasource-routing, and routed-realtime-service.
  • Auth Tests — Added tests for custom-auth-adapter, transform-auth-response, and extended auth-routes tests covering magic links and lifecycle hooks.
  • Postgres Tests — New auth-default-policies tests, extended cli-helpers-extended tests, connection tests, databasePoolManager tests, doctor-extended tests, and generate-postgres-ddl tests.
  • UI Tests — Added views.test.tsx covering the new headless CollectionView, ListView, CardView, and TableView components.
  • E2E Tests — Updated Playwright E2E tests for collections, studio features, and the new API keys flow.

  • CLI Init Crash — Fixed rebase init crashing with UnknownPromptTypeError: Prompt type "list" is not registered after entering the project name. The inquirer v14 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.
  • Interactive Prompt Validation — Extracted prompt question building into a testable buildInitQuestions() function and added unit tests that validate all prompt type values against the installed inquirer version’s registered types. This prevents prompt-type regressions from shipping silently when inquirer is upgraded.

  • 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/client and reconciled data controllers for cleaner imports.
  • Observability — Integrated structured request-logger middleware and an X-Request-ID correlation header to trace client requests across core backend services.
  • Code Quality & Testing — Added robust unit/integration tests across @rebasepro/ui components, 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→aal2 token upgrade on challenge verification, and an onMfaVerified auth hook. Auth routes extracted into dedicated mfa-routes.ts and session-routes.ts modules.
  • Component Override System — New ComponentOverrideContext and useComponentOverride hook 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 skills auto-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), and invoke_function for calling custom backend functions. Automatic package-manager detection for dev server commands.
  • Server Init Refactor — Decomposed the monolithic init.ts into 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), and init/docs.ts (OpenAPI serving).
  • Entity Form Improvements — Enhanced EntityDetailView and EntityEditView with better field-binding support, added PopupFormField inline editing, extended EntityForm with additional layout controls, and added replace option to navigateToEntity.
  • Drizzle Schema Generation — Improved generated schema logic with richer column-type support and cleaned up EntityPersistService by extracting reusable persist utilities.
  • Documentation & Website — Added llms.txt, updated sitemap.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 useEntityPreviewSlots resolution and adding proper hydration logic in RelationPreview and PropertyPreview components.
  • 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.ts module 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.json files across the monorepo, fixed dependency declarations, and corrected plugin-insights version reference.
  • VirtualTable & UI — Refactored VirtualTable and VirtualTableHeader for better resize handling and simplified render logic. Improved Dialog focus management and LoginView/ErrorView layout.
  • Admin Package Tests — Added component-level tests, data export tests, data import tests (including get_import_inference_type and transforms), and extended navigation utils test coverage.
  • PostgreSQL Tests — New relations.test.ts for relation service, pg-error-utils.test.ts for error extraction, and expanded drizzle-conditions.test.ts and generate-drizzle-schema.test.ts.
  • MCP Server Tests — Extended test suite covering new storage, cron, and function tool handlers.

  • 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 0002 schema 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-mongodb and server-postgresql.

  • Unified Authentication — Redesigned default auth routing, eliminated the defaultUsersCollection construct, and streamlined default view redirects.
  • Email Config — Added custom SMTP_NAME parameter configuration in SMTP email delivery properties.
  • Layout & Sizing — Resolved side navigation alignment glitches, added scroll-overflow fixes in entity data grids, and corrected ReadOnlyFieldBinding form fields.
  • Missing Build Configurations — Added missing tsconfig.prod.json compiler files and stabilized workspace-level packaging dependencies.

  • Role Model Simplification — Removed roles as an independent table/collection, simplifying permissions into a standard DB enum column directly in the users table.
  • 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.

  • 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 AuthOverrides to AuthHooks.
  • Advanced Query Operators — Introduced array-contains-any and not-in filter clauses for postgres client drivers.
  • Error Boundaries — Wrapped main application routes in a robust ErrorBoundary with specific full-page and authorization error layouts, and attached global listeners for unhandled promise rejections.
  • Stricter Typing & Logging — Replaced broad any usages with type-safe unknown keywords, and migrated core controllers from console.log to the structured monorepo logger.

  • 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.json during canary package releases.
  • CLI Scaffold — Fixed CLI template installation bugs, repaired Docker database image configs, and restored correct properties inside template collection schemas.

  • Lockfile & Build Issues — Fixed a missing integrity hash for the xlsx dependency in the lockfile, and resolved frontend build failures by adding @types/node and vite/client type definitions.
  • SQL Editor Component — Updated the SQLEditor component for improved stability and rendering.
  • E2E Test Runner Improvements — Replaced the execa dependency 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.ts in git and fixed glob inclusions in tsconfig files to prevent template compilation errors.

  • Postgres Vector (pgvector) Support — Added a vector property 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 EntityDetailView for read-only displays, new FilterChip components, 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_SWITCHING and ADMIN_CONNECTION_STRING options with troubleshooting documentation.
  • License Update — Relicensed the project under the MIT License.
  • 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.

  • Removed lodash dependency — Replaced lodash/cloneDeep with a custom deepClone utility in @rebasepro/utils. This eliminates the external dependency and fixes npx create-rebase-app failing due to missing lodash at runtime.
  • New deepClone utility — A lightweight deep-clone function that preserves function references and class instances (Date, GeoPoint, etc.), designed specifically for Rebase collection objects.
  • 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:major for releasing from the command line with the same pipeline.
  • Canary releases — Every push to main publishes a canary version to npm (@canary dist-tag).
  • Fixed navigation utility tests to assert the correct call signature with undefined options parameter.
  • Updated package descriptions to reflect the Postgres-based architecture.

🎉 First public release of Rebase — an open-source headless CMS and admin panel for Postgres.

  • 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-app to 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.
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