v0.13.0
Breaking
-
rebase.datais gone — userebase.dataAsAdmin. The server singleton had two names for one accessor, and the shorter one gave no hint of what it does:rebase.dataandrebase.dataAsAdminwere the same admin-scoped, RLS-bypassing driver.datais the name a browser client uses for its user-scoped accessor, so the same expression meant "whatever this user may read" on the client and "everything, no policies" on the server. That is a bad thing to have to remember at a call site that reads fine either way. -
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.
Unaffected, because their accessor is genuinely user-scoped and was never deprecated: `context.client.data` in entity callbacks, and `client.data` in a cron handler — both are `RebaseClient`. Also 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.
```diff
- 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.
-
RebaseAuthConfigis gone from@rebasepro/admin-types— useRebaseAuthViewConfig. It was a compatibility alias for a name that collides head-on withRebaseAuthConfigin@rebasepro/server, which configures the backend auth: JWT secrets, OAuth providers, password hooks. Two unrelated shapes under one name, exported from two packages whose whole job is to be imported together. -
react-router8, andreact-router-domis gone — react-router 8 deletes thereact-router-dompackage outright. It was only ever a v6-compatibility shim: everything DOM-specific had already collapsed intoreact-routeritself in v7.@rebasepro/admin,app,studioandplugin-ainow peerreact-router ^8.3.0. Two imports move, and only one of them is a rename: -
import { createBrowserRouter, RouterProvider } from "react-router-dom";
- import { createBrowserRouter } from "react-router";
- import { RouterProvider } from "react-router/dom";
Everything else — `useNavigate`, `useLocation`, `useSearchParams`, `useParams`, `Link`, `NavLink`, `Outlet`, `Navigate`, `Route`, `Routes`, `MemoryRouter`, `useBlocker` — is the same name 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/admin` 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.
```diff
- 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 branchkeeps the name you give it. Branch names were stripped of everything outside[a-zA-Z0-9_], sorebase db branch create my-featureanswered✓ Branch "myfeature" created— a different name than the one asked for, and the only onelistwould ever show. -
$ rebase db branch create my-feature
-
✓ Branch "myfeature" created successfully.
- $ rebase db branch create my-feature
- ✓ Branch "my-feature" created successfully.
Nothing needed the stripping: every identifier the branch service builds is double-quoted, which is what makes a hyphen safe, and the validator used for `--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.
### Security
- **`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
```ts
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.tshadresolveRequireAuth: no auth configured means auth is required, anAuthAdapteralways means required, and only an explicitrequireAuth: falseopens it. The socket carried its own copy, and the two disagreed on the case that matters most: with no auth configuration at all,/api/dataanswered 401 to every read while the socket admitted everyone and served the same rows. Not a weaker gate on the socket — the opposite answer.The socket's expression is gone rather than corrected; both enforcement points call
resolveRequireAuth, and the tests pin that they agree rather than restating each answer separately. -
policy.authenticated()admitted anonymous visitors. There were two sentinels for "nobody is signed in". The types, the policy compiler, the JavaScript evaluator and the anonymous-grant linter were all built onANONYMOUS_USER_ID('anonymous'); the request path scoped unauthenticated callers as'anon'. Sopolicy.authenticated()— the sanctioned, documented way to write "signed in", the thing the linter tells you to use — compiled toauth.uid() <> 'anonymous'and was true for every signed-out caller.The linter had it exactly backwards, too: it flagged
auth.uid() <> 'anon'as a Supabase habit comparing against "a string no caller ever has", when'anon'was the only spelling that worked.This is worse than a default that fails open, because it inverts a rule the author wrote deliberately. A policy that reads as a lockdown was a full grant, and nothing about it looked wrong at any layer — in one deployment it left
INSERTon companies, company memberships and jobs open to anonymous callers, and a membership row is a privilege boundary: every anonymous visitor shares one uid, so a single claim is a membership held by the internet.The request path now reports
ANONYMOUS_USER_IDeverywhere it scopes a caller — the JWT and adapter middlewares, the websocket handshake, the realtime service, and the rate limiter's "is this a real user" check. New:ANONYMOUS_USER_IDS(every spelling, newest first) andisAnonymousUid().Existing databases are fixed by upgrading the server, without regenerating a single policy: a stored
auth.uid() <> 'anonymous'starts excluding anonymous callers the moment they report that id.policy.authenticated()now compiles toNOT IN ('anonymous', 'anon')rather than a single literal, because a policy is written into the database and outlives the server that generated it — one spelling is a hole in whichever direction the versions happen to skew.What breaks: a policy that grants to anonymous callers by comparing
auth.uid() = 'anon'stops matching. That fails closed, andpolicy.not(policy.authenticated())is the supported way to say it. -
auth.requireAuth: falseno longer un-gates cron, logs, backups and the schema editor. That flag answers a question about the data plane — must a caller present a token to read/api/data, or does RLS alone decide? — andfalseis the answer the server itself recommends at boot to anyone serving a public website from their own backend. It was also, silently, the switch that decided whether the admin surfaces were gated at all.So the documented configuration for a public job board or marketing site mounted
POST /api/cron/:id/trigger,GET /api/logsand/api/admin/backupsfor anyone who could reach the service. A singlewarnper surface at boot was the only notice, and on a--allow-unauthenticatedCloud Run deployment "anyone who can reach the service" means the internet. Anyone whose cron jobs spend a metered third-party quota was paying for that.Admin surfaces are now gated whenever there is authentication to gate them with — an
AuthAdapter, or ajwtSecret— independent ofrequireAuth. Whether anonymous callers may read your posts has no bearing on whether they may run your cron jobs.If you deploy with
requireAuth: false, calls to these routes that previously succeeded unauthenticated now answer 401. They accept what every other admin surface accepts: an admin JWT, the service key, or anrk_API key created withadmin: true— the API-key pre-auth runs ahead of the JWT check, so a scheduler holding an admin key keeps working. Point Cloud Scheduler (or whatever triggers your jobs) at an admin key before upgrading.One thing comes back:
/api/meta/contractis served again on these deployments. It is only mounted when it can be gated, so a public-data-plane project had been 404ing it, and with it typed client generation from another repository. -
A backend with no authentication at all now refuses its admin surfaces instead of serving them open. With no
AuthAdapterand noauth.jwtSecretthere is no credential this server could check a caller against, so it cannot tell an admin from the internet. It used to mount cron, logs, backups and the schema editor anyway, ungated, with onewarnper surface at boot as the entire defence.They now answer 501
ADMIN_SURFACE_UNAVAILABLE, with a message naming the missing switch. They stay mounted rather than disappearing on purpose: an unexplained 404 on/api/cronreads as a broken path or a failed deploy and gets debugged as one. A token does not change the answer — there is nothing to verify it against.This is unlikely to touch you: every scaffolded backend and the bundle runtime configure
auth.jwtSecret(the runtime requires it, and auto-generates one in development), so the affected shape is a hand-rolled entrypoint that passes noauth— or one whoseJWT_SECRETquietly failed to reach the container, which is precisely the deployment that should not be serving a cron trigger to anonymous callers.The data plane is unaffected and still answers 401 there: "show me a token" is a truthful thing to say about
/api/data, and a dishonest one about a surface no token can open. -
Every
overrides:entry is a bounded security floor now. An override replaces each transitive consumer's own range, so a bare>=Xis not a floor — it is a floating pin that drags in the next major to publish, whatever asked for what.One of them had inverted completely:
js-yaml: ">=4.2.0 <5"pinned the tree at 4.2.0, which is precisely the version GHSA-52cp-r559-cp3m says to leave (patched in 4.3.0). The pin meant to protect was the thing holding the exposure.uuidhad meanwhile floated from its 11.x floor to 14 unnoticed.Closes 12 further advisories across
brace-expansion(three live majors, so its floors are keyed per-major rather than forcing one on every consumer),js-yaml,react-router,shell-quoteandprotobufjs. Re-resolving moved no package version, so the bounds themselves are hardening only. -
@hono/node-serverin the scaffolded backend goes from^1.19.12to^2.0.12, closing GHSA-frvp-7c67-39w9 (aserve-staticpath traversal on Windows via an encoded backslash). The 1.x line has no patch, and@rebasepro/serveralready peered^2.0.12— a new project was being handed an adapter two majors behind the server consuming it.
Fixed
-
customPropsin the collection editor was marked deprecated by accident. It carried a@deprecated Superseded by spantag that belonged towidthPercentageand slid onto the next field along when that one was deleted.customPropsis live — it is how a customFieldorPreviewreceives its props, andPropertyFieldBindingreads it on every render. Nothing about the behaviour changed; the tag is gone, so editors stop striking through a supported field and suggesting a replacement that does something else entirely. -
The eject warning was suppressed exactly where it mattered. The warning above the refusal — the one that exists because ejecting "is not something to discover from a runtime version going blank" — was printed behind
!isJsonMode(). JSON mode latches on whenever stdout is not a TTY, so piping the command, or running it from CI or a coding agent, deleted the warning outright, and the deploy's JSON payload carried no equivalent field. The one case with nobody watching the terminal was the one case that said nothing.Warnings now go to stderr in every output mode — stderr is not the JSON stream, so it cannot corrupt a parser — and only their formatting depends on the mode. Whether a warning is emitted at all no longer does. The deploy payload gains
warnings: [{code, message, hint}]and a denormalisedejectsManagedRuntimeboolean for CI to test directly; both fields are always present, sofalsenever has to be told from absent. -
deployprinted human progress to stdout in JSON mode, ahead of the result object, breaking any parser reading it — the🚀 Triggering deployment…banner on both the source and managed-bundle paths, the source upload's size line, and on the bundle path the entire build transcript (Building bundle…, the compiler's own log lines, frontend folding,Uploading bundle…). Progress goes through oneprogress()helper now, which drops it in JSON mode. The rule it settles: progress is not a result and disappears when stdout belongs to the JSON; a warning is not a result either, but goes to stderr and never disappears. -
A project that had never deployed reported
custom · your own image.projects.runtime_modeis a record of what the last deploy made a project, and it carriedDEFAULT 'custom'from the migration that added it — which was a true statement about the projects that existed then, and applied to every row created ever after. So a project created seconds ago, which had never built anything, named a container image nobody had built. Most visibly right after the console's create wizard, whose runtime step defaults to Managed and says outright that the choice is intent and writes no mode.It also blunted the one signal that catches an accidental eject:
customwas equally the resting value of a project nothing had happened to, so it could not distinguish "a source build moved you off managed" from "nothing has happened here yet."The column stops defaulting (control plane migration
0040_runtime_mode_undecided), making NULL the honest third state, andrebase cloud statusand the console's overview, infrastructure and apps headers all read it as "not deployed yet" rather than inventing an image. Every non-display reader already coerced absent tocustombefore use, so nothing else changes. Existing rows are deliberately not backfilled — a row sayingcustomtoday may be a project that really did ship source, and there is no way to tell those apart from the ones the default flattened. -
Several concurrent realtime subscriptions hung on a cold page load. A view that opens more than one at once — a Kanban board opens one per column — reported
Subscription timed outfor all but one of them, thirty seconds in. The socket was healthy: probed directly, six concurrentsubscribe_collectionframes all answered inside 15ms. The frames were never sent.ensureAuthenticatedpublished its in-flight guard only after awaiting the token getter, so every caller arriving in that gap started an attempt of its own — and the message queue flushing on connect delivers exactly that. Each attempt then registered underauth_${Date.now()}, the one request id with no random suffix, so attempts in the same millisecond collided in aMapand only the last survived. One promise settled; the frames waiting behind the others never reached the socket. Client-side navigation skips the path (isAuthenticatedis already true), which is why the same view worked on every visit after the first. -
Kanban drag-and-drop put cards in the wrong place, and did not persist a column change at all.
handleDragOvermoves the card between columns while the pointer is still down, so looking it up by id at drop time finds it in its destination — the board reported every cross-column drop as a same-column reorder and never wrote the column property. Separately, the drop handler passed every card in every column toonItemsReorder, whose consumer reads it as the target column and takes the moved card's neighbours from it to compute a sort key.Also: releasing over a column rather than a card no longer forces an append (which sent a card dropped mid-column to the bottom), dropping onto an empty column no longer aborts the save, and collision detection is
closestCorners— the default only reports a target while the dragged rect overlaps one, so a card held over a gap reported nothing. -
Board sort keys are
fractional-indexingkeys the database can sort. The library's default base62 output only orders correctly under byte comparison, and the sort is done by Postgres, whose default collation is not byte comparison: underen_US.UTF-8,"aa"sorts before"aC". A board dragged around enough to reach the upper-case digits stopped agreeing with its own keys. Keys are base36 and single case now. Existing keys no longer validate, which is what surfaces the board's Initialize bar — and that bar works now: it only ever looked for a null order value, so a column full of unusable-but-present values offered a button that updated nothing and never went away. -
Kanban columns could not be scrolled. A
flex-1item defaults tomin-height: auto, so the view holding the board grew to the board's full content height — 1230px inside an 883px area — and the ancestor'soverflow-hiddencut off the rest. Each column had a working scroller that never reached its limit. -
A failed column subscription rendered as an empty column. Entities cleared, no error surfaced, "No items" under a header still counting eleven of them. It falls back to a one-shot read, reports a failure only if that fails too, and no longer waits out the client's full 30-second watchdog before painting anything.
-
Date previews required a
Dateinstance, so every audit column in every revision-history entry rendered as a red "Unexpected value" box. History is raw API payload, where a timestamp is still the string Postgres sent. Any value that unambiguously names a date is accepted now. -
Chips lost three quarters of their palette. A cleanup flattened
CHIP_COLORSfrom four tones per hue to one, which left everycolorScheme="blueDark"resolving toundefined— a chip with a colour in its config rendering with no colour at all — and made seeded chips pick from ten schemes, so a five-value enum routinely drew the same background three times. The tones are generated from a per-hue table now, andChipColorKeyis a real union rather thankeyof Record<string, …>, which is why none of it was a type error. -
The Firebase example compiles again. It had not built since the property-options split, which made
urla statement about the data — it feedsformat: "uri"into the OpenAPI contract — and moved presentation toadmin.urlPreview. The example'sadmin: { url: "image" }had both halves in the wrong place, andexpandedlikewise belongs in theadminblock.
Added
-
Useris exported from@rebasepro/clientand@rebasepro/app. The removals above tell a caller to importUserfrom@rebasepro/types, which was not an instruction a browser app could follow: it installs the client (or app) package alone, and@rebasepro/typesis that package's dependency, not a specifier resolvable from its own project. So the deprecated aliases were removable in a monorepo and stranding anywhere else.Usernow sits besideRebaseSession,AuthTokensandDeviceSession, which were already re-exported for exactly this reason. -
The entity form has a layout. It had exactly one — a single centred column of full-width cards in declaration order — and one escape hatch,
formView.Builder, which replaces the whole form. Nothing in between.There is now a four-column grid, titled sections that collapse, and a metadata rail for the fields that describe a record rather than constitute it. All of it is derived by default: a collection that configures nothing gets a two-column form, its id and audit timestamps in the rail, long text and arrays full width, short enums and booleans narrow.
admin.formis for when the derived answer is wrong. See Form Layout.On the demo's products form this is 2932px of scroll down to 1587px, and 219px of dead space above the first field down to 24px.
-
The record's identity and its actions live in persistent chrome. The title, the id and the Save/Discard buttons used to sit inside the scrolling form, so the moment you touched the wheel nothing on screen said which record you were editing. They are in a bar above it now, which is also what let the 320px footer holding two buttons go away entirely.
-
JSON and revision history moved out of the tab strip and into a record inspector. They were the first two tabs — icon-only, unlabelled, ahead of the record you opened the page to edit. They are developer tools, so they sit behind the overflow (
⋮) menu and open in a panel beside the form; the tab strip is for destinations. Old#json/#historyURLs open the inspector on the pane they name. -
Two gates for things that were rotting silently.
pnpm check:examplestypechecksexamples/*, which were in no pipeline and no root script —pnpm buildcovers./packages/*and./apponly — which is why the Firebase example above stayed broken for weeks. They resolve@rebasepro/*to built output the way an installing user does, rather than to source the waypnpm typecheckdoes, so they catch a class of drift the source-resolving gate structurally cannot see.pnpm check:generatedregenerates the committed website artifacts (llms.txt,sitemap.md, the changelog mirror) and fails on a diff.llms.txthad been sitting a commit behind the docs it summarises.