v0.16.0
Added
-
A relation picker can create the row it is looking for. The list ends in an Add … action that opens the target collection's form in the side panel, over the form you are already filling in; saving it closes the panel and leaves the new row selected, with no second trip through the picker. Until now a relation could only point at something that already existed, so a company that was not in the list meant abandoning the form, going to that collection, creating the row and starting again — and on a record being created, everything typed so far was lost.
A search that matched nothing is the name you were looking for, so it seeds the new row: typing
EDU.MXinto the picker and choosing Add "EDU.MX" opens the form with that already in the title field. Only when the collection's title lands on a plain string property — putting free text into an enum or a relation would be worse than not prefilling — and the action itself appears only when the user could actually insert into the target, the same permission the selection dialog's own Add button checks.The create form does not take the URL. It is a detour inside the form you are in, and a record that does not exist yet has no address to restore; pushing one made closing it a pathname change, which is exactly what the unsaved-changes blocker watches — so a successful save raced the panel clearing its own dirty flag and often answered with "There are unsaved changes", with the URL stranded on the target collection.
The dialog widget already worked this way — except for the URL, which it took too. Its Add button is fixed with it, so both create-in-place paths now leave the address bar where the form is.
-
A unit of a split deployment can be released on its own.
functions.image.tagin the Helm chart (and theapi/workerequivalents, orbundleUrlunderbundle.mode: url) holds one unit at a build of its own, so a fix to a custom function no longer restarts the API. Empty by default: every unit renders one image and one bundle, which is still the shape to prefer. Pinning only the tag inherits the repository, because the common case is one project and one image with one unit held back.Two units on different builds are two sets of collections against one database, and only one unit provisions it. So the rule, stated in the values file and in the docs: the unit that owns the schema rolls first, and a unit may lag but must never lead. A unit running ahead queries columns that do not exist yet and relies on RLS policies nobody applied — the first is a SQL error on one route, the second is an empty result with a 200. A unit running behind is the ordinary state of any rollout in progress. The migration Job renders the release-wide image, so it always leads the pinned units by construction.
-
The Helm chart is published.
helm install rebase oci://registry-1.docker.io/rebasepro/rebase— an OCI artifact beside the runtime image, pushed by the same release job under the same credentials and verified pullable from outside with no credentials, exactly as the image is. Until now the chart existed only inside the repository, so installing it meant cloning first: the same defect the runtime image had in 0.13.0, when the first command a new self-hoster ran answeredpull access denied. A guard asserts an automated workflow publishes it, so it cannot regress quietly the way its predecessor did.The chart carries the same version as the runtime rather than its own. It ships with the runtime and its default image tag was already held to the runtime's version; two numbers would mean working out which pairs with which, and there is no useful answer to that question. Both are gated against
@rebasepro/server. -
The runtime records the collections schema version it applied, and every other process checks itself against it. The process that provisions writes a version into
rebase.schema_meta; every other process computes its own from the collections it loaded and compares. On a disagreement it names both versions, says which way is safe, and serves anyway — during a rollout that disagreement is correct, because the units that have not rolled yet are supposed to be behind.REBASE_REQUIRE_SCHEMA_MATCH=true(orsharedState.requireSchemaMatchin the chart) refuses the boot instead, for a deployment that would rather not serve at all than serve wrong.The stamp lives in the database rather than behind an HTTP call to the api, and the difference is not stylistic. Asking the api needs its address configured on every other process — a variable whose absence disables the check silently — and makes booting depend on another process already being up. It also asks the wrong question: two processes can agree with each other while both disagree with the database, and the database is what they are all about to query. It is additionally the only form that works for a
worker, which has no reason to know any URL, and for a singlealldeployment scaled to three, where there is no api to ask.Both sides of the comparison are computed from the collections in hand, never read from a bundle manifest. A version a build declares about itself is not evidence that the database agrees with it —
/api/meta/schema-versionreturns exactly that declared value, which is why comparing that endpoint to that manifest is a check that passes on a bundle whose declared version is nonsense.What it cannot do is tell you which side is ahead: a schema version is a hash, so it reports disagreement and never direction. That is why the rollout order is a documented rule rather than something the runtime enforces.
A driver older than the runtime has neither hook — the image supplies
@rebasepro/serverwhile the driver comes from the bundle — and that is treated as "this driver does not record a version" rather than as a boot failure. The check starts working when the project's driver is next updated. -
An entity view can sit in front of the record's own tab.
position: "start"on anEntityCustomViewmeant "first among the custom views", which placed it after a tab it was already after — the record's own tab is drawn unconditionally first, sostartandendonly ever ordered the custom views against each other and no collection could open on anything but its form. That contradicteddefaultSelectedView, which has always been able to name a custom view as the landing tab. A cover — a read-only summary of a record, the thing an operator opens a row to see — now renders before the form that edits it. A view that says nothing still lands after the record, so nothing moves for a collection that never asked. -
The record count sits in the collection toolbar. It lived in the breadcrumb trail, which the app bar owns, so a collection rendered without an app bar simply had no count and no way to see how many rows a filter resolved to. It now ends the toolbar's leading group, after the filter, sort and preset controls — the count is what those resolve to, and it reads as one sentence with them. Hidden wherever the toolbar goes icon-only, because a passive readout is the first thing that should give up its room on a strip that scrolls.
-
rebase cloud resourcesshows what a project is given, and changes it. The dials print with "plan default" where one is unset, so chosen and inherited are visible rather than inferred, andresources setsends only the dials named — a patch carrying every field at its default would overwrite dials set from another client. Nothing here validates a value on purpose: the rules belong to the target cluster (Autopilot bills a 250m/512Mi floor and rewrites anything outside a 1:1–6.5:1 memory:CPU band; a Hetzner or EKS node has neither), so a CLI carrying those numbers would be wrong for two of three providers the day it shipped. -
A cluster can be registered and verified from the command line.
rebase cloud clusterswas list-only, so registering one meant inserting a row by hand and finding out whether it worked when a customer's first deploy failed inside provisioning.clusters addregisters from a kubeconfig and points straight atclusters verify, which reports what the control plane found — reachable, what the identity may do, what is installed, and a verdict — and exits non-zero onunusableso it works as a gate in a runbook. Registration stays admin-only, matching the collection's RLS: a cluster record carries a credential that can create namespaces and read every secret in them. -
rebase doctorreports a connection string libpq cannot parse. Fixing the generator does nothing for the projects it already generated, and this defect is invisible day to day — node-postgres accepts the string, sorebase devandrebase db pushwork whilerebase db backuphas never once succeeded. Doctor now scans.env,.env.localand the compose files forDATABASE_URLandADMIN_CONNECTION_STRINGand prints the corrected string to paste back. The compose files are checked on their own account: a deployed stack's scheduled backup cron reads its connection string from there, so it stays broken after.envis repaired. -
The default auth emails carry your logo. All five built-in templates — password reset, verification, invitation, welcome and magic link — render
email.logoUrlabove the card, and the six auth call sites that each carried their ownappName || "Rebase"line now resolve branding in one place. The fallback is asymmetric on purpose:appNamefalls back to "Rebase" because an unconfigured app has no better name to show, and the logo does not follow it, because the alternative is mailing Acme's users a Rebase mark from Acme's domain. It must be a PNG on an http(s) URL — mail clients do not render SVG and blockdata:URIs, so a non-http(s)logoUrlrenders no logo rather than a broken image.
Changed
-
Discarding an edit is undoable. Discard and Clear sit beside Save, throw away everything typed since the record was opened, and until now did it permanently: the reset replaced the form's undo history with a single entry, so the ⌘Z that would have brought the edit back had nothing to step into. The identity bar's version does not even stop to ask — one click, one lost form. Both now go through the history rather than around it, and both raise a confirmation carrying an Undo, which is the only place the way back can be offered: the form has no undo button, only a shortcut nobody has a reason to guess at.
What is stepped back into is the edit, not just its values. The entry the reset leaves behind carries the touched map as well, because the draft backup is extracted through that map — restore the values alone and the record comes back looking pristine to everything that asks, including the backup that is supposed to survive a reload. The step also re-publishes the form's version, so a field holding state of its own — a markdown editor, which re-seeds only when that moves — comes back with the rest instead of staying cleared over a value that has already returned.
Ordinary undo is untouched: stepping back over a keystroke deliberately does not re-seed every field, which is why the two are distinguished at all. A reset the form performs on its own — after a save, on a new record — still clears the history, since there is nothing behind it worth returning to.
-
The form's metadata rail widens a little where there is room for it. 304px to 336px past
@7xlof form width — the same container signal the content column already widens on, so a side panel inside a large window keeps the narrow rail rather than taking a viewport breakpoint's word for it. 304 was picked against the narrow end, where the extra 32px went to the gap beside a chip; on a full-screen form it comes out of the gutters instead, and the status select and date picker in there are the same controls as in the column. -
Realtime is a runtime surface now, and the roles that serve no websockets no longer pay for it. It was neither a surface nor role-aware, so every role ran it — including
functionsandworker, whose entire claim is that they touch nothing. Both mounted a websocket server no client could reach, both held a dedicatedLISTENconnection outside the pool for the life of the process, and both installed the change-capture machinery at boot: a schema, a trigger function, and aDROP/CREATE TRIGGERpair per collection table.That last part contradicted the invariant the runtime otherwise refuses to boot without.
REBASE_ROLE=functionsandREBASE_ROLE=workerare rejected unlessREBASE_MIGRATE_ON_BOOT=none, on the grounds that exactly one process owns schema DDL — and then the driver ran schema DDL from all of them anyway, from a code path that never asked the role. Nothing was corrupted (each statement is idempotent, and the multi-statement string is atomic), but every rollout took anACCESS EXCLUSIVElock per table per pod for no reason.Writes made by those processes are still heard. Capture is database triggers, so a change is published by the database rather than by whichever process made it: a function that writes a row still wakes every subscriber on the
api.The driver is told two things separately — whether this process consumes change events, and whether it owns the DDL — because they genuinely come apart. An
apibehind an external migration Job subscribes without provisioning. -
A bundle's dependencies are installed once, by
rebase build, not on every pod start. A managed pod's bundle lives on an emptyDir, so it was re-fetched and re-installed on every start — an eviction, a node failure, an OOM, a runtime rollout — and that install is 35–55 seconds of a 40–60 second cold start. It is therefore the price of every unplanned restart a tenant suffers, not a startup detail. The pod side needed no change: the init container already skips installing whennode_modulesis present. Native code is never vendored (a compiled binary is only valid for the platform it was built for), and a failed install is never fatal — an unvendored bundle is what every project shipped before this existed. Nor is an incomplete one accepted: if the installed tree does not contain the database driver — which happens when the project declares it at a version no registry can serve, aworkspace:range in a monorepo — the tree is thrown away and nothing is vendored, because the init container skips installing whennode_modulesis present, so a partial tree does not start slowly, it does not start at all.--os=linux --cpu=x64is the load-bearing flag, and not because of native modules: the dangerous case is a pure-JS package whose real work lives in a platform-specific optional dependency, esbuild being the one everybody meets. -
A new logo and mark, everywhere the panel draws one.
RebaseLogo, the favicon it sets, the docs header, the site's own icons and the example apps'. -
Semibold is the ceiling of the type ladder — no weight above 600.
h1/h2had already been walked back tofont-mediumwhen the site and the panel were reconciled, leaving the stat variant as the lastfont-boldand a comment announcing a "display tier" rule nothing implemented any more. The display end separates itself by size and tracking, not by weight: a 30px 700 beside a 30px 500 elsewhere in the same product reads as two type systems rather than one ladder, which is exactly what shipped — the marketing site at 500, the panel's stat tiles at 700. Swept, because a ceiling nothing enforces is a preference: every hand-writtenfont-boldis nowfont-semibold, and thefont-black/font-extraboldabove it came down with it. -
A driver ahead of the runtime is reported too. Version skew was one-directional: a driver behind the runtime was named at boot, a driver ahead by a minor was silent — and that is the pairing a floating runtime range produces when the image lags the packages a project builds against, with half a feature present in the bundle and the other half missing from the harness, in a process reporting itself healthy. Patch leads stay silent on purpose: pinning one fix forward is deliberate, and warning about it trains people to ignore the line.
Fixed
-
A select in a side panel opens where you can see it. The panel hands its descendants a portal host — itself — so their popups open inside the modal, where the focus and scroll locks let them be used at all. It also carried
will-change: transformfor the slide-in, and that (liketransform,filterorperspective) makes an element a containing block for itsposition: fixeddescendants. A select dropdown is fixed and positioned in viewport coordinates, so inside the panel those coordinates resolved against the panel instead and every list came out displaced by the panel's own left offset — far enough, on a right-hand panel, to open past the edge of the screen. Open, correctly stacked, and nowhere anyone could see it, which is indistinguishable from a select that ignores clicks.Popovers, menus and date pickers in the same panel were unaffected: their positioning measures the offset parent and subtracts it. Only the select's item-aligned placement does the arithmetic against the viewport itself, which is why one control looked broken while its neighbours did not.
-
A collection's default sort survives its own mount.
admin.sorthad no effect on any collection view: rows arrived in the table's natural order however the sort was written, while REST and the realtime socket both ordered correctly when asked directly — which is what made it look like a transport bug. It was not. The table controller subscribed twice, once with the sort read off the collection and then immediately again with noorderByat all, and the second answer replaced the first. The URL-sync effect mirrors the sort withhistory.replaceState, which react-router does not observe, souseLocation()kept reporting the search string the view mounted with — the empty one — and a re-render caused by nothing more exotic than a caller passingfixedFilteras an object literal parsed it and cleared a default no user had touched. An explicit?__sort=in the URL still outranks the collection's default, and back/forward still syncs. -
A card no longer prints the row id above its own title. Every card in the grid led with a truncated uuid sitting over the product name;
isId: "uuid"is the default for a Rebase collection, so that line was noise on most of them, and at a card's width it is too short to copy — which is the only thing an id on screen is good for. The card was the odd one out: a list row and a board card show an id only when nothing else names the record. That fallback is untouched, so a record with no readable name still gets its id.hideIdFromCollectionis not the lever for this and stays exactly what it was: the table reads the same flag for its ID column, where an id is genuinely useful. -
One signed-URL request per file, not one per thumbnail. A collection view draws one thumbnail per row and rows share images far more than not — 200 blog posts illustrated by 20 hero files. Each thumbnail minted its own download token on mount, and the URL cache is only written when a response lands, so it deduped nothing during the burst: 100+ requests for 20 distinct files, which spent the whole rate-limit budget on one page view and made every image on the screen fail together with a 429. The in-flight promise is now shared per cache key — 20 requests, all 200. Deliberately not a longer-lived cache: a signed URL is temporal, so a later mount refetches exactly as before and only the concurrent duplicates are removed.
-
The storage limiter counts a signed-in caller as signed in. A request to
/api/storage/*carrying a valid admin JWT came backx-ratelimit-limit: 300and shared anip:bucket with unauthenticated traffic — everyone behind one NAT together — where a signed-in caller should have had 1000 keyed by uid. The same token on/api/data/*reported 1000 correctly, which is what made it look like a quirk of the demo. The limiter reads the user off the context; on the storage router it is registered before the routes, and the JWT middlewares live inside them, so both the key and the limit fell through to their anonymous arms. It now derives the uid from the bearer token itself when the context has none, and uses it for bucketing only — pre-resolving the user into the context instead would change authorization, not just accounting, letting a Rebase-signed JWT satisfy a deployment that delegates auth to Firebase or Clerk. An unverifiable token buckets by IP exactly as before. -
rebase db backupworks on a generated scaffold.rebase initwrote aDATABASE_URLwhoseoptionsvalue carried a literal=(?options=-c%20search_path=public&sslmode=disable). libpq splits a URI query parameter on the first=and rejects any further one, so every libpq caller failed on a fresh project —pg_dump/pg_restorebehinddb backup|restore, and a plainpsql "$DATABASE_URL"copied out of the generated.env. It shipped because node-postgres parses URLs itself and accepts the literal form, sorebase devandrebase db pushworked and nothing exercised the URL; the--database-urlbranch had always encoded it, and no test compared the two. Fixed ininit,.env.example, both compose templates and the deployment skill — the compose files on their own account, since a self-hosted stack's backup cron failed the same way. A failedpg_dumpalso no longer leaves a 0-byte artifact behind, whichbackups listshowed as an ordinary backup and pruning ranked by timestamp alone, so the corpse held a protected slot while a real backup aged out under it.
Testing & CI
-
Type names claimed in prose are checked, not just the ones in code fences. Every doc verifier so far read fenced code —
check-api-namesgreps imports,typecheck-snippetscompiles the fences outright. A markdown table is neither, and a reference table is the shape nobody runs: it is where the agent skills had drifted furthest.check-prose-types.mjsreads backticked*Props/*Config/*Options/*Hooks/*Context/*Callbacksnames outside fences and requires that something inpackages/*/srcdeclares them.It found, and the sweep removed:
BackendHooks,UserHooks,DataHooksandBackendHookContext, taught across two skills together with ahooks.dataconfig block — none of the four types exists andRebaseBackendConfighas nohookskey at all, so an agent following it wrote configuration that type-errored or, in plain JavaScript, was silently ignored;AdminCollectionConfig, deleted on purpose and still the annotation one skill told agents to write;EntityOverrides, for a collection option no config type has; and six*Propsnames in the component-override table, in all six locales, for an override map that is not typed per key at all.The suffix filter is the whole design. A bare capitalised word in backticks is as likely to be a product name, an HTTP verb or a column type as an identifier;
SomethingConfigis a claim about this repository's types nearly every time. That is what makes it precise enough to be blocking rather than a backlog. -
The documented CLI is checked against the CLI.
check-doc-commandshad globs for the agent skills, the example READMEs and the repository's own agent instructions — and never forwebsite/src/content/docs/, the published documentation. Two commands lived in that gap for as long as the pages have existed:rebase db studiohad a section of its own in both the CLI reference and the schema page, andrebase auth create-userwas the first line of the auth example. Six locales each, because the translations are generated from English and inherit whatever it says. Neither command has ever existed; both exit 1. Pointing the existing check at the docs needed no new parser, only the glob nobody had added.CHANGELOG.mdis exempt — a changelog records what was true. -
A first-party GitHub URL must name the repository the package declares.
rebase-agent-skills/README.mdoffered six ways to install and five routed throughgithub.com/rebaseco/agent-skills, a standalone mirror that does not exist:npx skills add,gemini extensions install,claude plugin marketplace addand agit cloneall answered 404, and both plugin manifests advertised the same address as theirhomepageandrepository. The one path that worked,rebase skills install, was Option 1 and the only one needing no repository at all. Checking that a URL resolves would need the network, which a gate must not; checking that it names the repositorypackage.jsondeclares needs nothing, and is what actually went wrong. Only first-party-looking URLs are checked — an owner within an edit or two of ours, or a repo named after this bundle — so a skill linkingnvm-sh/nvmis untouched. -
The release stamps the Helm chart along with the packages. Neither
scripts/release.shnor the stable publish workflow touchedcharts/rebase/Chart.yaml, so every release moved@rebasepro/serverand left the chart on the previous number — andappVersionis the default image tag, sohelm installwith noimage.tagrendered a version behind the one just published.check:runtime-imagecaught it, but only after the fact, on the next run. Both paths bump it now, beside the package bump, and refuse the release if neither field matched. -
The Helm chart is checked. It shipped with no coverage of any kind: no lint, no
helm template, nothing in CI — and its failure mode is a cluster that comes up looking right.pnpm run check:chartlints it, renders the five topologies it documents, and reads the decisions back out of the manifests: the roles, who provisions, that the worker gets no Service, that/api/functionsreaches the functions unit in one hop through the ingress rather than two through the api's proxy, that a static app takes its own image and carries no Secret. It then extracts everyfailfrom_validate.tpland requires a case that reaches it, so a refusal added later fails the check until it is covered. -
The chart's default image tag is held to the runtime's version.
appVersionis the default tag —helm installwith noimage.tagrenders it — so the chart's own documented minimum viable install is an image reference made to a user. It had drifted to0.15.0against a0.14.1runtime, which renders a tag nothing has built and lands inImagePullBackOff.check:runtime-imagenow treats the chart as the user-facing reference it is, hermetically against@rebasepro/server's version and, under--live, against the registry. -
A third adapter wrapper is held to the capability list.
createPostgresAdapterrebuilds the bootstrapper field by field, exactly as the two wrappers in the runtime do, and nothing was holding it to anything. It silently dropped both new schema-stamp hooks: every layer type-checked, nothing threw, and the runtime did what it does with any missing optional capability — skipped — so the stamp was never written on any real boot. A check that never runs is indistinguishable from a check that passes.packages/server-postgres/test/adapter-forwarding.test.tscompares the adapter against the bootstrapper's own key set, so the next capability is covered without anyone remembering to list it.