v0.15.0
Added
-
A filter can reach through a relation to a column of the related row.
where: { "applications.status": ["in", ["applied", "reviewing"]] }— "has a related row whose column satisfies this", which is the form every queue screen is written in and which previously could not be said at all. Relation filters compared the related row's id and nothing else, so the only way to ask the question was to fetch every row and filter in the browser: a filter the client applies after paging is not a filter, because the page was already chosen without it.Compiled to the correlated
EXISTSthe question already was, with the predicate moved off the target's id and onto one of its columns. A many-to-many reaches one table further than the id filter does — that one stops at the junction, which already holds the value it compares — so its subquery joins the target to the junction inside theEXISTS, where it cannot multiply the outer rows.belongsTois included:author.nameis a column of another table either way.Every operator works, because the compared value is an ordinary column:
>=on a date andilikeon a name mean here what they mean anywhere else. The negative operators keep the rule the id filter already had, for the same reason —!=isNOT EXISTSof the positive predicate, neverEXISTSof a negated one.EXISTS (… AND status != 'hired')asks "does some application differ from hired", which is true of nearly every candidate with more than one application and answers nothing anybody asked;NOT EXISTS (… AND status = 'hired')asks "is there no hired application", and makes==and!=partition the rows the way a filter implies they do.is-nullandis-not-nullare deliberately not a complementary pair on a relation column. They mean "has a related row whose column is unset" and "has one where it is set" — both true of a candidate with two applications, one of each. Making the second the negation of the first would make it "no application has an unset status", which is true of a candidate with no applications at all: the very rows a queue exists to exclude.A relation that does not exist, or a column the target does not have, is a 400 naming the target's real columns — never a dropped condition, which would widen the read to every row.
-
A sort key can be an aggregate over a to-many relation.
orderBy: [[{ relation: "applications", field: "created_at", agg: "min" }, "asc"]]— candidates, longest-waiting first.countalone answers the other half of the queue family: clients, busiest first.min,max,count,sum,avg.This is the half that could not be worked around. A relation filter can be approximated by denormalising a flag onto the row — a trigger, a backfill, and a promise to keep it correct on every write to the related table. An ordering cannot be approximated at all once the result set is paged, because the client only ever holds one page and the page was chosen by the wrong order. It is why a project ends up with a 600-line custom view beside the collection it is about: not because the rendering needed customising, but because the query could not be expressed.
Compiled to a correlated scalar subquery in
ORDER BYrather than aLEFT JOIN LATERAL, because the same expression has to serve the keyset comparison behind cursor paging — and if the two are not the same expression, paging and ordering disagree and rows are skipped. Cursor paging works: there is no aggregate stored on the cursor row to compare against, so the driver recomputes the cursor row's value in SQL from the id it does have, as a subquery pinned to that id. Pinned rather than correlated, so Postgres evaluates it once for the statement rather than per row.Rows the relation reaches nothing from land at a defined end —
NULLS LASTascending,NULLS FIRSTdescending. That was already Postgres's default and is now written out in theORDER BY, becausebuildKeysetComparisonencodes the same placement and an invariant two functions depend on should be stated in both rather than assumed in one.countof nothing is0, not null, so those rows sort as zero. The id stays the last key, so the order is total and paging over it neither repeats nor skips.The object form is the authoring surface; on the wire the key is a single string,
min(applications.created_at).OrderByTupleis[string, direction], the REST parameter is?orderBy=key:direction, the driver contract takesorderBy?: string | OrderByTuple[], and a cursor names its keys by string —_scoreestablished the same pattern, and this reuses it rather than widening five signatures to carry an object that would be flattened at the end anyway.normalizeOrderByis where the two spellings collapse into one.Both features are declared as capabilities —
supportsRelationFieldFiltersandrelationAggregateSorts— and both default to false for an unclaimed driver. Firestore and MongoDB declare neither. A wrongly assumed filter capability widens a read to every row; a wrongly assumed sort capability answers 200 with rows in whatever order the database pleased, which reads as a sorted list.The offline overlay refuses both rather than answering them wrongly. A dotted filter key resolves to
undefinedon every cached row, which would exclude all of them — a 200 with an empty list, indistinguishable from "nothing matched". An aggregate is not a field on the row either, so every cached row readsundefinedfor it, which the sortability check would have read as a column of nulls and called reproducible before handing back rows in id order.
Changed
-
The
collection.insightsslot is nowcollection.widgets, andhome.card.insightishome.card.widget. The old names described one plugin's use of the slot rather than the slot, which is any widget strip above the table or on a home card. The prop types follow:CollectionInsightsSlotProps→CollectionWidgetsSlotProps,HomeCardInsightSlotProps→HomeCardWidgetSlotProps.A contribution registered under an old name is redirected to the new one and still renders, with a one-time console warning naming the replacement. Slot names are matched by string equality, so a plain rename would have left every plugin still on the old name compiling, registering, and rendering nothing — the same silent nothing
UNRENDERED_SLOTSexists to warn about. The old names are retired, not removed, and will go in a future major version. -
AdditionalFieldDelegatesays that it is display-only.value()is async and receives the wholeRebaseContext, so it can read another collection and its result is cached per record — which makes it read like a computed column when it is not. It runs in the browser, once per row, after the page has already been fetched and ordered, so its result can never take part in choosing which rows came back or in what order. The doc comment now says so, and points at the two things that can: an aggregate sort or a relation filter for a value derived from a relation, and a real column for anything else. -
The type ladder spans three weights, and a card's edge is a hairline. Two visual changes to
@rebasepro/uithat every app built on it inherits.h1andh2go to 700. The ladder capped at 600, so a page title and the section heading inside it were the same voice at two sizes and a screen had no clear first thing to read. UI chrome — nav, labels, buttons, table headers — stays at 600, andh4steps up to semibold because at 20px medium sits close enough to body copy that a long page reads as one undifferentiated column. This costs nothing on the wire: both faces already load as variable fonts, so the whole weight axis ships whether or not it is used. The rule it replaces was written when static weights meant every step was another download.cardMixindrawssurface-700/60instead of a solid edge, and rounds torounded-xl. The softer border is not a new opinion — it is the onedefaultBorderMixinhas always carried, and the SaaS console alone was overriding the card's own border to reach it at 53 call sites. The component was wrong and every caller knew.paperMixinis deliberately unchanged: a menu, dialog or popover sits over unknown content and needs a definite edge, where a card in the document flow does not. Page surfaces get a hairline; floating surfaces keep theirs. -
Three type tiers the product kept improvising, and an inset surface for code.
typography-leadis the sentence under a page title, which had been borrowing 12pxbody2— so the one line explaining what a page is for was smaller than that page's own table rows.typography-microis the uppercase field label above a value: the single sanctioned tier belowtext-xs, and it earns the exception by never carrying a sentence.typography-monocarriestabular-nums, because proportional digits make a column of measurements ragged and a live counter jitter as its glyphs change width. All three areTypographyvariants, not new components.codeSurfaceMixinfixes a surface that had been inverted: code blocks sat onsurface-800(#111) insidesurface-900(#0a0a0a) cards — lighter than the thing containing them, so every inset well read as raised. It issurface-950now, which is what "recessed into the card" actually looks like.
Fixed
-
Chip ink was written down instead of measured, and 63 of 120 hue/tone/mode pairs were below WCAG AA. The worst was white on
teal.solidat 1.76:1, which is not a near miss — it is unreadable. Two causes, and the larger hid behind the smaller:"#fff"was hardcoded as the ink on everysolidbackground, and this palette is Airtable-shaped, so its bright mid stops want dark ink — 14 of 15 hues were wrong. The other 8 came fromonDeep(defaulting topale) on everydeepbackground.The ink is derived now. It walks a hue-tinted starting colour toward black or white only as far as it must to clear the floor on the background it will actually sit on, and takes whichever direction has more headroom. No palette stop moved, so chips keep their colours; only the ink did. Starting from each hue's own tints rather than flat
#000/#fffkeeps the family looking related —blue.solidgets a dark navy, not black.The part that made this more than a colour tweak: an
outlinedchip drops its fill and sits on the page, but the component reused the filled ink for it. One value was being asked to be legible against two different surfaces, and for most hues it cannot be — so flipping the filled ink to dark would have made every outlined chip in dark mode invisible.outlineText/darkOutlineTextare separate now, measured against the real page backgrounds.Because it is derived rather than tabulated, a hue added later cannot land below AA: there is no per-tone ink left to forget to check. Asserted for every scheme, filled and outlined, in both modes.
-
The accent was below AA as text on a dark card.
#0070F4is tuned as a fill — white on it, it on white. Read as type on asurface-900card it measures 4.36:1, and every accent link in the product sits on exactly that surface. Dark mode usesprimary-lightfor accent text now (7.34:1), which is the same hue lifted in lightness and indistinguishable as "the blue". Fills are untouched; there the contrast question runs the other way and#0070F4was already right. -
A refused Google sign-in left the login screen silent. Every provider button failed into nothing: a popup the visitor closed, a redirect whose
statedid not match, a Google script that never loaded — none of that reaches the auth controller, which can only record what it is handed, so the screen rendered no error and the button simply appeared dead.LoginViewkeeps its own error for the half of the flow that happens before a code reaches the controller, and renders the controller's for the half after — cleared once a user is present, so a stale failure cannot sit over a screen that has since succeeded.Backing out is not a failure.
access_denied, a closed popup andimmediate_failedare answers, and showing them in red reads as a broken login, so they are swallowed rather than reported.Server side, "Registration is disabled" was a non-sequitur on this path — nobody pressed Create account, they pressed Sign in with Google, and there is no account behind that identity. All three rejection points now say both halves, since the visitor can see neither. The public demo was doing exactly this to every visitor:
--set-env-varsreplaces the whole env block on each deploy and the server defaultsALLOW_REGISTRATIONto false when it is absent, so the demo advertised "Sign in with Google" and then 403'd the account behind it. -
Every relation in a project whose collections import each other was reported as broken, by the two commands that load collections from source.
rebase generate-sdkandrebase buildreadconfig/collectionsthrough jiti, which transpiles ES modules to CommonJS. A CommonJS cycle hands the module entered second the namespace object —{ __esModule: true, default: … }— and never replaces it with a live binding, so atarget: () => otherCollectionthunk returned the namespace rather than the collection. Resolution saw an object with noslugand refused it.The measured cost, on a 63-collection project introspected from an existing database: 58 relations rejected, one warning each, and a generated SDK in which every relation field and every derived foreign-key column was silently missing.
customerIdandcustomerwere simply absent from the row type. Nothing failed — the command exited 0 and wrote a file that looked complete, which is the failure mode you find out about from the compiler months later.The value was never lost. The thunk is lazy, so by the time it runs the exporting module has finished and the collection is sitting one level down in
default. Resolution now takes it — and only when the inner value is itself a collection, because adefaultthat is not one is a genuinely wrong thunk and has to keep reaching the error.ResolvedRelation.targetis normalised rather than passed through as written, which is the half that decides whether this is a fix or a patch over one symptom. Resolution reads the target once, to derivetargetSlugand a join table; the forty-odd callers that matter read it later —PostgresBackendDriverbuilding a join, the Drizzle and DDL generators,RelationWriteService, the doctor, the admin's relation fields and table cells. Handing those the thunk as authored would have left every one of them holding the namespace, so the generated SDK would have come out right while the server that serves it stayed broken. Measured on the same project: 134 resolved relations, 0 still answering with a namespace.What made this hard to place is that the warning blamed the author — "make sure the target is
() => otherCollectionand not evaluated at module load" — for something the rejected code already did. Cycles between collection files are not an authoring mistake to be designed out: two collections that point at each other must import each other, and the lazy thunk is this framework's own answer to that. Native ESM resolves those thunks correctly, which is why the same collections load, relate and serve perfectly under the dev server while the CLI called them broken.A thunk that returns a promise —
target: () => import("./other"), one keystroke away and the mistake the namespace shape resembles — now says so, rather than reporting "not a collection". -
A write refused by a row-level-security policy answered 500, not 403. The client could not tell "you may not do this" from "the server is broken" — and a 500's message is sanitized on the way out, so the reason went with it. An operator got paged for access control working correctly.
Only
INSERTwas affected, and for a mechanical reason: a refusedUPDATEorDELETEsimply matches no rows, which was already classified as403 WRITE_DENIED, while a refusedINSERTraises42501from a failedWITH CHECKand fell through to the unclassified path. All four spellings of the denial now answer the same status and the same code.42501carries two opposite problems and only the message separates them, so the driver now does too: a policy refusing the caller is a 403, while the connecting role lacking aGRANTstays a 500 — telling an operator "forbidden" for a missing privilege would send them hunting for a policy bug that does not exist. The message used to name both causes because it could not tell them apart; it now names whichever happened.
Changed
-
The admin panel's Logs view streams, instead of polling every three seconds. The old view re-fetched the whole window on a timer, which was wrong in three ways at once: an entry could sit up to three seconds before appearing, each client cost a request every three seconds to be told nothing had happened, and — because that request passed through the same middleware that fills the log buffer — the view's own polling became the loudest thing in its own output. On a quiet server it was also what evicted real entries out of the ring.
GET /api/logs/streamis server-sent events, admin-only like the query beside it. The backlog and the live entries arrive on one connection: a client that fetched its history separately would race its own subscription, and entries logged between the two calls would belong to neither. Appends are batched over a 250ms window rather than sent per line, because a busy server logs faster than a browser can render and one frame per entry would cost a re-render per request served — worse than the poll it replaces, precisely when the logs are worth watching.The view says which it is doing. "Live" and "Polling" are not cosmetic: an empty log is ambiguous — quiet server, or a tail that died — and the studio and the server are versioned separately, so a frontend that knows this route will meet servers that do not. A 404 there is an older backend, not an error, and it degrades to the three-second poll rather than showing an empty view.
A connection holds a bounded number of entries between flushes, so a burst past roughly eight thousand a second leaves a gap — and says so, with a count, rather than presenting a tail with a hole in it as complete.
Fixed in the same work: a client that disconnected during the opening write — a fast navigation, or a reconnect storm against a restarting server — leaked its subscriber and a repeating timer, per attempt, for the life of the process. A listener added to an already-aborted
AbortSignalis never called, so the handler had no way to learn the reader had gone.