Skip to content

v0.15.0

Choose a tag to compare

@github-actions github-actions released this 17 Aug 23:39
· 1341 commits to main since this release

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

Changed

  • 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: CollectionInsightsSlotPropsCollectionWidgetsSlotProps, HomeCardInsightSlotPropsHomeCardWidgetSlotProps.

    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.

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

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